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 4e34fba35a7..e4ba6b6b073 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,9 +44,15 @@ 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; +import java.nio.charset.Charset; +import java.util.LinkedHashSet; +import java.util.Set; +import java.io.DataInputStream; import java.util.regex.Pattern; /** @@ -128,6 +134,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; @@ -1121,6 +1130,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()) { @@ -4887,7 +4905,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(); @@ -4945,7 +4963,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; @@ -4976,60 +4996,159 @@ 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)"; - if (appExtension.isDirectory()) { - for (File f : appExtension.listFiles()) { - if (f.getName().endsWith(".entitlements")) { - codeSignEntitlements = extensionName + "/" + f.getName(); - } - } + // 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(); } buildSettingsMap.put("PRODUCT_BUNDLE_IDENTIFIER", request.getPackageName() + "." +extensionName); + // 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. + // What this archive IS: the SDK name xcodebuild will use (versioned), + // the configuration it builds, and the architecture it builds for. + // Everything below matches conditional settings against these. + String archiveSdk = activeIosSdkName(request); + String archiveConfiguration = "Release"; + // Derived the same way ARCHS is, rather than assumed: a debug build + // with ios.debug.archs=armv7 is handed ARCHS=armv7, and Xcode would + // then pick an [arch=armv7] setting while this read the arm64 one. + String archiveArch = request.getArg("ios.buildType", "debug").equals("debug") + && "armv7".equals(request.getArg("ios.debug.archs", null)) + ? "armv7" : "arm64"; + Map declaredSettings = appExtensionBuildSettings(appExtension); + String declaredId = appExtensionBuildSetting(appExtension, "PRODUCT_BUNDLE_IDENTIFIER"); + // The identifier THIS archive gets: a qualified setting overrides the + // 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", archiveSdk, archiveConfiguration, + archiveArch); + String declaredIdForArchive = governingId != null + && governingId.trim().length() > 0 + ? governingId.trim() + : (declaredId != null ? declaredId + : request.getPackageName() + "." + extensionName); + // Fully, or not at all: a partially expanded identifier is a + // truncation, and it would name a bundle the archive does not contain. + String fullyResolvedId = resolveSettingsFully(declaredIdForArchive, + extensionSettingsWithBuiltIns(appExtension, declaredSettings, + archiveConfiguration, archiveSdk, archiveArch)); + String resolvedBundleId = fullyResolvedId == null ? "" : fullyResolvedId; + String outOfNamespace = resolvedBundleId.length() == 0 ? null + : outOfNamespaceExtensionIdMessage(extensionName, resolvedBundleId, + request.getPackageName()); + 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, + ArchiveContext.of(archiveSdk, archiveConfiguration, archiveArch, + appExtensionBuildSettings(appExtension))); buildSettingsMap.put("PRODUCT_NAME", "$(TARGET_NAME)"); buildSettingsMap.put("PROVISIONING_PROFILE", "$(NS_PROVISIONING_PROFILE)"); buildSettingsMap.put("CODE_SIGN_ENTITLEMENTS", codeSignEntitlements); 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", + embeddedExtensionDeviceFamily(request.getArg("ios.project_type", "ios"))); + buildSettingsMap.put("SKIP_INSTALL", "YES"); + if (containsSwiftSource(appExtension)) { + // The project's Swift settings are applied to the app target + // 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()) { - Properties _buildSettingsProps = new Properties(); - try (FileInputStream fis = new FileInputStream(buildSettingsProps)) { - _buildSettingsProps.load(fis); - } - for (Object key : _buildSettingsProps.keySet()) { - if (key instanceof String) { - String val = _buildSettingsProps.getProperty((String)key); - buildSettingsMap.put((String)key, val); - } - } + // Through the same reader preflight used, rather than a second + // parse of the same file: the two disagreed about trailing + // whitespace, so the identifier that was checked and the + // identifier that was written into the target were not the same + // string. + buildSettingsMap.putAll(appExtensionBuildSettings(appExtension)); buildSettingsProps.delete(); } + // 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. + // The configuration this build hands to xcodebuild is Release for a + // device archive whatever ios.buildType says, so that is what a + // [config=...] condition must be matched against. + // The same context, now that buildSettingsMap holds the archive's own + // settings as well: BUILD_VARIANTS among them. + ArchiveContext buildContext = ArchiveContext.of(archiveSdk, + archiveConfiguration, archiveArch, buildSettingsMap); + File signingEntitlements = appExtensionSigningEntitlements(appExtension, + buildSettingsMap, extEntitlementsFile, buildContext.sdk, + buildContext.configuration, buildContext.arch); + // The BASE setting is copied into every Xcode configuration, so + // writing the archive's answer there hands Debug a minimum belonging + // to Release; it gets the base value, clamped on its own. The + // archive's own answer is what the target is created with. + String extDeploymentTarget = appExtensionDeploymentTarget( + buildSettingsMap.get("IPHONEOS_DEPLOYMENT_TARGET"), + signingEntitlements, + request.getArg("ios.deployment_target", null), + appExtension, buildSettingsMap, buildContext); + String archiveDeploymentTarget = appExtensionDeploymentTarget( + winningSetting(buildSettingsMap, "IPHONEOS_DEPLOYMENT_TARGET", + buildContext), + signingEntitlements, + request.getArg("ios.deployment_target", null), + appExtension, buildSettingsMap, buildContext); + buildSettingsMap.put("IPHONEOS_DEPLOYMENT_TARGET", extDeploymentTarget); + for (String note : repairQualifiedExtensionSettings(buildSettingsMap, + request.getPackageName(), + appExtensionDeploymentFloor(signingEntitlements), + ArchiveContext.of(archiveSdk, archiveConfiguration, archiveArch, + buildSettingsMap))) { + debug("The " + extensionName + " app extension: " + note + "."); + } + // 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, '" + archiveDeploymentTarget + "')\n" + "xcproj.targets.find{|e|e.name=='" + request.getMainClass() + "'}.build_configurations.each{|e| \n" + " e.build_settings['PROVISIONING_PROFILE']='$(APP_PROVISIONING_PROFILE)'\n" + " e.build_settings['CODE_SIGN_ENTITLEMENTS']='$(APP_CODE_SIGN_ENTITLEMENTS)'\n" @@ -5862,157 +5981,2417 @@ private void ensureTopLevelWorkspace(BuildRequest request) throws BuildException } } - static void appendFilesToXcodeProjGroup(StringBuilder sb, File dir, String serviceGroupVarName, String serviceTargetVarName, File baseDir) { + /** + * 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); + } - String basePath = baseDir.getAbsolutePath(); - if (!basePath.endsWith("/")) { - basePath += "/"; - } - int basePathLen = basePath.length(); - for (File f : dir.listFiles()) { - if (f.isDirectory() && f.getName().endsWith(".xcassets")) { - // Asset catalogs are directory packages. Adding their contents one file at a - // time flattens every Contents.json into the extension bundle and makes Xcode - // fail with "Multiple commands produce .../Contents.json". Add the catalog - // itself so Xcode compiles it with actool. - sb.append("fileref = ").append(serviceGroupVarName).append(".new_file(").append("'").append(f.getAbsolutePath().substring(basePathLen)).append("')\n"); - sb.append(serviceTargetVarName).append(".add_resources([fileref])\n"); - } else if (f.isFile()) { - sb.append("fileref = ").append(serviceGroupVarName).append(".new_file(").append("'").append(f.getAbsolutePath().substring(basePathLen)).append("')\n"); - if (f.getName().endsWith(".m") || f.getName().endsWith(".swift")) { - sb.append(serviceTargetVarName).append(".add_file_references([fileref])\n"); - } else if (!f.getName().endsWith("Info.plist") && !f.getName().endsWith(".entitlements") - && !f.getName().endsWith(".h") && !f.getName().endsWith(".mobileprovision")){ - sb.append(serviceTargetVarName).append(".add_resources([fileref])\n"); - } - } else { - appendFilesToXcodeProjGroup(sb, f, serviceGroupVarName, serviceTargetVarName, baseDir); + /** + * 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. + * + *

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.

+ */ + void stampAppExtensionInfoPlist(File appExtension, BuildRequest request, + ArchiveContext context) throws IOException { + Map plists = appExtensionInfoPlists(appExtension, context); + Set stamped = new LinkedHashSet(); + for (Map.Entry candidate : plists.entrySet()) { + File infoPlist = candidate.getValue(); + 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 (isHostAppInfoPlist(infoPlist, appExtension.getParentFile(), request.getMainClass())) { + // The app's OWN plist, named by one of the extension's settings. Everything under + // the project directory is writable on purpose -- an extension may share a plist + // that sits beside its folder -- but the identity written here is an EXTENSION's, + // and putting it in the container rewrites the app's version or hands its + // identifier to $(PRODUCT_BUNDLE_IDENTIFIER), which for the app target is a + // different value entirely. + // + // Note this is about the FILE, not about the condition. Every candidate is + // stamped, applicable or not, because the generated project keeps them all and a + // Debug rebuild off sources.tar.bz2 ships whichever one it names -- an unstamped + // one then carries the stale identity Apple rejects. Skipping the inactive ones + // instead would leave that hole open AND leave this one, since an ACTIVE setting + // naming the app's plist would still be written. Do not swap this guard for a + // conditionApplies() filter without a test that covers both. + debug("The " + appExtension.getName() + " app extension names '" + candidate.getKey() + + "' as an Info.plist, and that is the containing app's own plist. An " + + "extension's bundle identity does not belong in it, so it was left as " + + "it is; point INFOPLIST_FILE at a plist inside the extension."); + continue; + } + ArchiveContext candidateContext = infoPlistCandidateContext(candidate.getKey(), context); + if (!stamped.add(infoPlist.getCanonicalPath() + "\u0000" + candidateContext)) { + // Two settings naming the same file IN THE SAME CONTEXT. Stamping is idempotent, + // and saying so twice in the log reads like two files were touched. + // + // The context is part of the key because one physical plist is routinely named by + // the base setting and by a qualified one: a $(MARKETING_VERSION) in it resolves + // to the app's version under Release and to a stale 1.0 under + // [config=Debug], and the file cannot be right for both while the reference + // stands. Deduplicating on the path alone let the Release pass leave the + // reference and skipped the Debug pass that would have replaced it, so the Debug + // build off these sources shipped 1.0. + continue; + } + // Through the shared resolvers rather than buildVersion / the ios.bundleVersion hint + // 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. + // The settings the TARGET will carry, so a $(PRODUCT_BUNDLE_IDENTIFIER) in the plist + // is judged by the identifier it will actually resolve to -- the archive's override + // included, which is where an identifier from another project comes in. + Map settings = appExtensionBuildSettings(appExtension); + settings.put("PRODUCT_BUNDLE_IDENTIFIER", appExtensionBundleId(appExtension, + request.getPackageName() + "." + appExtension.getName())); + // In the candidate's OWN context, not this archive's. Every candidate is stamped, + // because the generated project keeps them all and a later Debug build off + // sources.tar.bz2 ships whichever one it names -- but a reference inside the Debug + // plist expands to the Debug values, and judging it against the Release ones read + // $(MARKETING_VERSION) as already matching the app. Left in place, it becomes the + // stale Debug version on the build that actually uses that file, which is the + // host-mismatch rejection this stamping exists to prevent. + List changes = stampPlistFile(infoPlist, embeddedExtensionShortVersion(request), + embeddedExtensionBundleVersion(request), request.getPackageName(), + flattenForContext(settings, candidateContext)); + if (changes == null) { + debug("Could not read " + appExtension.getName() + "/" + infoPlist.getName() + + " as an XML property list, so its bundle identity was left as it is. If " + + "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); } } } - private void removeLinesContaining(File file, String... snippets) throws IOException { - if (file == null || !file.exists() || snippets == null || snippets.length == 0) { - return; + /// 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 { + 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, + hostBundleId, archiveSettings, changes); + if (changes.isEmpty()) { + return changes; } - String content = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); - StringBuilder sb = new StringBuilder(content.length()); - try (BufferedReader reader = new BufferedReader(new StringReader(content))) { - String line; - while ((line = reader.readLine()) != null) { - boolean remove = false; - for (String snippet : snippets) { - if (snippet != null && !snippet.isEmpty() && line.contains(snippet)) { - remove = true; - break; - } + 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 (!remove) { - sb.append(line).append('\n'); + if (match) { + return bom; } } } - createFile(file, sb.toString().getBytes(StandardCharsets.UTF_8)); + return null; } - /** - * Recursively checks whether the given directory contains any {@code .swift} files. - */ - private static boolean hasSwiftFiles(File dir) { - if (dir == null || !dir.isDirectory()) { + /// 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; + } + // UTF-16 without a byte order mark: its declaration is NUL-interleaved, so the probe below + // reads gibberish and falls through to UTF-8, and the plist then fails to parse and goes + // unstamped. The first characters of an XML document are "= 4 && data[0] == 0 && data[1] == '<' && data[2] == 0 && data[3] == '?') { + return StandardCharsets.UTF_16BE; + } + if (data.length >= 4 && data[0] == '<' && data[1] == 0 && data[2] == '?' && data[3] == 0) { + return StandardCharsets.UTF_16LE; + } + // The declaration is ASCII-compatible in every encoding that can carry one, except the + // UTF-16 forms, which the marks above have already answered for. + String head = new String(data, 0, Math.min(data.length, 512), StandardCharsets.ISO_8859_1); + 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) { + return appExtensionInfoPlists(extensionFolder, null); + } + + /// Whether this path is the containing app's own Info.plist. + /// + /// The generated project puts it at {@code /-src/-Info.plist}, + /// which is inside the project directory and therefore writable -- so an extension setting + /// that names it, by relative path or through a reference, reaches the stamper like any + /// other candidate. + /// + /// @return false when anything here is unknown, since a path that cannot be compared is not + /// one to declare safe + static boolean isHostAppInfoPlist(File candidate, File distDir, String mainClass) { + if (candidate == null || distDir == null || mainClass == null + || mainClass.length() == 0) { return false; } - File[] children = dir.listFiles(); - if (children == null) { + File host = new File(new File(distDir, mainClass + "-src"), mainClass + "-Info.plist"); + try { + return candidate.getCanonicalPath().equals(host.getCanonicalPath()); + } catch (IOException cannotResolve) { return false; } - for (File f : children) { - if (f.isFile() && f.getName().endsWith(".swift")) { - return true; + } + + /// The context a candidate from {@link #appExtensionInfoPlists} belongs to. + /// + /// The candidates are keyed by the setting that names them -- "INFOPLIST_FILE[config=Debug] = + /// $(CONFIGURATION)/Info.plist" -- and the qualifier in that key is the whole difference + /// between the two files. The path was already resolved in it; what is IN the file has to be + /// resolved in it too. + /// + /// @return the archive's own context for an unqualified candidate + static ArchiveContext infoPlistCandidateContext(String candidateKey, ArchiveContext context) { + if (candidateKey == null) { + return context; + } + // Everything up to the " = " this method's callers append, and no less. Cutting at the + // first ']' instead dropped every qualifier group after the first, so + // INFOPLIST_FILE[config=Debug][sdk=iphonesimulator*] was read as a Debug candidate on the + // archive's own SDK -- and an SDK-qualified version helper inside that plist then looked + // correct and was left, while the build that uses the file expands it to another value. + // A settings key cannot contain " = ", so the first occurrence is the separator. + int separator = candidateKey.indexOf(" = "); + String key = separator < 0 ? candidateKey : candidateKey.substring(0, separator); + return key.indexOf('[') < 0 ? context : contextForCondition(key, context); + } + + /// @param context this archive, so INFOPLIST_FILE = $(CONFIGURATION)/Info.plist resolves to + /// the plist Xcode actually processes. Without it the path came back unresolvable and the + /// stamping skipped the file that ships -- the same hole the entitlements path had. + static Map appExtensionInfoPlists(File extensionFolder, ArchiveContext context) { + // The DECLARED settings, qualified keys and all: those keys are the candidates. The + // resolution map beside it is flattened for this archive, which is right for expanding a + // reference and useless for finding conditionals -- reading candidates out of it made the + // qualified entries disappear. + Map declared = appExtensionBuildSettings(extensionFolder); + Map settings = context == null ? declared + : extensionSettingsWithBuiltIns(extensionFolder, declared, context.configuration, + context.sdk, context.arch); + Map out = new LinkedHashMap(); + String base = declared.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("INFOPLIST_FILE = " + base.trim(), + resolveInfoPlistPath(base.trim(), extensionFolder, settings)); + } + for (Map.Entry setting : declared.entrySet()) { + String key = setting.getKey(); + // Closing bracket included: an unescaped conditional leaves Properties with the key + // INFOPLIST_FILE[sdk and the rest of the line as its value, and that is not a setting + // 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; } - if (f.isDirectory() && hasSwiftFiles(f)) { - return true; + String value = setting.getValue() == null ? "" : setting.getValue().trim(); + if (value.length() > 0) { + // In the context the qualifier declares: [config=Debug] means $(CONFIGURATION) is + // Debug for THAT entry, whatever this archive builds. + ArchiveContext own = contextForCondition(key, context); + // Keyed by the SETTING, not by its text: two settings can carry the same text and + // still name different files, which is exactly what + // $(CONFIGURATION)/Info.plist under [config=Debug] does. Keying by the text threw + // the second one away before it was ever resolved. + out.put(key + " = " + value, resolveInfoPlistPath(value, extensionFolder, + context == null ? declared + : extensionSettingsWithBuiltIns(extensionFolder, declared, + own.configuration, own.sdk, own.arch))); } } - return false; + return out; } - - private String convertToJavaMethod(Class type) { - if(type.isArray()) { - type = type.getComponentType(); - if(Integer.class == type || Integer.TYPE == type) { - return "nsDataToIntArray("; + /** + * 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, + 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)", + !identifierBelongsToApp(result, hostBundleId, archiveSettings), archiveSettings, changes); + result = setPlistString(result, "CFBundleShortVersionString", shortVersion, + 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); + // 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; + } + + /// 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, + Map archiveSettings) { + 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 exact = WatchNativeBuilder.plistStringContentExact( + plist.substring(openEnd + 1, valueEnd)); + String current = exact == null ? null : exact.trim(); + if (current == null || current.length() == 0) { + return true; + } + if (!exact.equals(current)) { + // A plist parser keeps that padding, so " com.example.app.Ext " is not the identifier + // it reads as -- it is an invalid one. Not ours to keep, whatever it trims to. + return false; + } + // Through the settings, because $(PRODUCT_BUNDLE_IDENTIFIER) is not automatically safe: + // the archive may override PRODUCT_BUNDLE_IDENTIFIER itself, with the identifier from the + // project the extension was exported from, and those overrides are written onto this + // target. A reference is only as good as what it lands on. + String resolved = resolveSettingsInValue(current, archiveSettings); + 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. An unknown reference is therefore the opposite of safe, and the + // one reference that IS safe, $(PRODUCT_BUNDLE_IDENTIFIER), resolves through the + // settings above because the caller puts the target's own identifier in them. + return false; + } + return resolved.startsWith(hostBundleId + "."); + } + + /** + * Sets one string key among the ROOT dict's direct children, adding it when absent. + * + *

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 overwriteNonEmpty, Map archiveSettings, List changes) { + if (value == null || value.length() == 0) { + return plist; + } + int afterKey = topLevelKeyEnd(plist, key); + if (afterKey < 0) { + 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); + } + // 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) { + 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; + } + if (plist.charAt(openEnd - 1) == '/') { + // 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); + } + int valueEnd = WatchNativeBuilder.closeOfElement(plist, openEnd + 1, ""); + if (valueEnd < 0) { + return plist; + } + String current = plist.substring(openEnd + 1, valueEnd); + if (current.equals(value)) { + return plist; + } + // What the value IS, not how it is spelled: a CDATA section resolved, a comment stripped, + // 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 = ""; + } + // 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; + } + // 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. + // 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 + + (resolved.equals(currentText) ? "" : ", which resolves to '" + resolved + "' here") + + ")"); + return plist.substring(0, openEnd + 1) + value + plist.substring(valueEnd); + } + + /// A root written as {@code } carries no keys and has nowhere to put one, so it is + /// opened into a pair before anything is added to it. + private static String openEmptyRootDict(String plist) { + int at = rootDictAt(plist); + int openEnd = at < 0 ? -1 : plist.indexOf('>', at); + if (openEnd < 0 || plist.charAt(openEnd - 1) != '/') { + return plist; + } + return plist.substring(0, at) + "\n" + plist.substring(openEnd + 1); + } + + /// Index just past the {@code } of {@code key}, when that key is a DIRECT child of the + /// root dict; -1 when the root dict has no such child. Nested dictionaries are stepped over + /// whole, so a key of the same name inside one is not mistaken for the bundle's own. + private static int topLevelKeyEnd(String plist, String key) { + int at = rootDictAt(plist); + int i = at < 0 ? -1 : plist.indexOf('>', at); + if (i < 0 || plist.charAt(i - 1) == '/') { + return -1; + } + i++; + while (true) { + int element = nextMarkupAt(plist, i); + if (element < 0 || plist.startsWith("', element); + if (openEnd < 0) { + return -1; } - if(Byte.class == type || Byte.TYPE == type) { - return "nsDataToByteArr("; + 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(Short.class == type || Short.TYPE == type) { - return "nsDataToShortArray("; + if (plist.charAt(openEnd - 1) == '/') { + i = openEnd + 1; + continue; } - if(Character.class == type || Character.TYPE == type) { - return "nsDataToCharArray("; + int close = WatchNativeBuilder.closeOfElement(plist, openEnd + 1, ""); + int afterKey = close < 0 ? -1 : plist.indexOf('>', close); + if (afterKey < 0) { + return -1; } - if(Boolean.class == type || Boolean.TYPE == type) { - return "nsDataToBooleanArray("; + 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; } - if(Float.class == type || Float.TYPE == type) { - return "nsDataToFloatArray("; + int valueElement = nextMarkupAt(plist, afterKey); + if (valueElement < 0) { + return -1; } - if(Double.class == type || Double.TYPE == type) { - return "nsDataToDoubleArray("; + i = endOfElement(plist, valueElement); + if (i < 0) { + return -1; } } - if(String.class == type) { - return "fromNSString(CN1_THREAD_GET_STATE_PASS_ARG "; - } - return ""; } - - private String convertToClosing(Class type) { - if(type.isArray()) { - return ")"; - } - if(String.class == type) { - return ")"; + + /// 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; } - return ""; } - - private String convertToObjectiveCMethod(Class type) { - if(type.isArray()) { - return "arrayToData("; - } - if(String.class == type) { - return "toNSString(CN1_THREAD_GET_STATE_PASS_ARG "; - } - return ""; + + /// 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); + // 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); } - - private String getSimpleNameWithJavaLang(Class c) { - if(c.isPrimitive()) { - return c.getSimpleName(); + + /// 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(c.isArray()) { - return getSimpleNameWithJavaLang(c.getComponentType()) + "[]"; + if (plist.charAt(openEnd - 1) == '/') { + return openEnd + 1; } - if(c.getClass().getName().startsWith("java.lang.")) { - return c.getName(); + int depth = 1; + int i = openEnd + 1; + while (depth > 0) { + int at = nextMarkupAt(plist, i); + if (at < 0) { + return -1; + } + int gt = plist.indexOf('>', at); + if (gt < 0) { + return -1; + } + if (plist.startsWith("/Info.plist} is only the default: the archive's buildSettings.properties + /// 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. + /// 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 + /// 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())) { + if (f.isDirectory()) { + // In-tree, so it escapes nothing -- but sub/loop -> . is a cycle, and every + // other walk over this folder follows it until the stack ends the build. A + // directory symlink is not something an extension needs. + return f; + } + continue; + } + if (f.isDirectory()) { + 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 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, + Map settings) { + if (configured == null || configured.trim().length() == 0 || configured.contains("$(NS_")) { + return byName; + } + // 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; + } + + /// 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) { + // 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) { + return false; + } + int element = nextMarkupAt(text, afterKey); + 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; + } + } + + /// 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 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) { + return extensionSettingsWithBuiltIns(extensionFolder, settings, null, null, null); + } + + /// @param configuration, {@code sdk} and {@code arch} the archive's own, since + /// $(CONFIGURATION) in a path or an identifier is as ordinary as $(TARGET_NAME) and this + /// build knows all three + static Map extensionSettingsWithBuiltIns(File extensionFolder, + Map settings, String configuration, String sdk, String arch) { + Map out = new LinkedHashMap(); + // Conditionals resolved to what this build gets, before anything expands a reference + // against them: a map lookup only ever sees the plain key, and the qualified value is the + // one Xcode uses. + Map flat = flattenForContext(settings, + ArchiveContext.of(sdk, configuration, arch, settings)); + if (flat != null) { + out.putAll(flat); + } + String targetName = extensionFolder.getName(); + out.put("TARGET_NAME", targetName); + 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); + } + // A project name is one of these too, and not knowing it is what left + // com.example.host.$(PROJECT_NAME) unresolvable: the identifier was then recorded as its + // own source text, which names no bundle and matches no profile. It is the .xcodeproj in + // the project directory -- the same directory SRCROOT and PROJECT_DIR come from -- and + // only when there is exactly one, since two would be a guess. + String projectName = singleXcodeProjectName(projectDir); + if (projectName != null) { + if (!out.containsKey("PROJECT_NAME")) { + out.put("PROJECT_NAME", projectName); + } + if (!out.containsKey("PROJECT")) { + out.put("PROJECT", projectName); + } + } + // Family patterns are not values. A context taken from [sdk=iphonesimulator*] that this + // archive does not match knows the family and NOT the SDK the build will use, so + // $(SDK_NAME) is left unexpanded and the candidate is reported as one this build cannot + // resolve -- which is true -- rather than resolved to a path nothing will ever be at. + if (configuration != null && !isFamilyPattern(configuration) + && !out.containsKey("CONFIGURATION")) { + out.put("CONFIGURATION", configuration); + } + if (sdk != null && !isFamilyPattern(sdk)) { + if (!out.containsKey("SDK_NAME")) { + out.put("SDK_NAME", sdk); + } + if (!out.containsKey("PLATFORM_NAME")) { + out.put("PLATFORM_NAME", platformOf(sdk)); + } + } + if (arch != null && !isFamilyPattern(arch)) { + if (!out.containsKey("CURRENT_ARCH")) { + out.put("CURRENT_ARCH", arch); + } + if (!out.containsKey("arch")) { + out.put("arch", arch); + } + } + // PRODUCT_NAME last, and only now: it is $(TARGET_NAME) unless the archive says otherwise, + // and "otherwise" may be a chain through any of the settings above -- + // PRODUCT_NAME = $(CONFIGURATION)-Wallet is one. Resolving it before CONFIGURATION, + // SDK_NAME and CURRENT_ARCH were in the map deleted the reference and left "-Wallet", + // which then went into an identifier and into the export-options key. + String productName = out.get("PRODUCT_NAME"); + String resolvedProductName = productName == null ? "" + : resolveSettingsInValue(productName, out); + out.put("PRODUCT_NAME", resolvedProductName.length() > 0 ? resolvedProductName : targetName); + return out; + } + + /// Whether this context value is a family rather than a value: "iphonesimulator*", from a + /// condition that describes some build other than this one. + static boolean isFamilyPattern(String value) { + return value != null && value.endsWith("*"); + } + + /// The name of the Xcode project in this directory, or null when there is not exactly one. + /// + /// $(PROJECT_NAME) is an Xcode built-in like $(TARGET_NAME), and an identifier written + /// through it is ordinary. Not supplying it left the identifier unresolvable, and what got + /// recorded for the export-options dictionary was the expression itself. + static String singleXcodeProjectName(File projectDir) { + if (projectDir == null) { + return null; + } + File[] entries = projectDir.listFiles(); + if (entries == null) { + return null; + } + String found = null; + for (File entry : entries) { + if (entry.getName().endsWith(".xcodeproj")) { + if (found != null) { + return null; + } + found = entry.getName().substring(0, + entry.getName().length() - ".xcodeproj".length()); + } + } + return found == null || found.length() == 0 ? null : found; + } + + /// Every entitlements file this target may be signed with: the plain CODE_SIGN_ENTITLEMENTS + /// and each qualified one. + /// + /// 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) { + 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) { + 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) { + return byName; + } + if (winner.trim().length() == 0) { + // Declared and empty is not the same as not declared. An archive that sets + // CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*] to nothing is telling Xcode to sign the + // device build with no entitlements file at all, and Xcode does -- so reading the + // by-name file here found a Wallet entitlement the target is not signed with and + // raised it to iOS 14 for something it does not carry. + return null; + } + + // With the archive's context, because $(CONFIGURATION)/Extension.entitlements is a + // standard way to write this path; without it the path did not resolve and a different + // file was read for the entitlement that sets the floor. + return appExtensionSignedEntitlements(extensionFolder, winner, byName, + extensionSettingsWithBuiltIns(extensionFolder, settings, configuration, sdk, arch)); + } + + /// @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()) { + String key = setting.getKey(); + if (!"CODE_SIGN_ENTITLEMENTS".equals(key) + && !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)) { + 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"; + } + + /// 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) { + return repairQualifiedExtensionSettings(settings, hostPackage, floor, null, null, null); + } + + /// @param sdk, {@code configuration} and {@code arch} the archive being built, so a setting + /// belonging to some OTHER build is left exactly as its author wrote it. The floor here was + /// computed from the entitlements THIS archive is signed with, and applying it to a Debug or + /// simulator condition raised a target that has nothing to do with those entitlements -- the + /// edit then lives on in the generated project and in sources.tar.bz2, so a later Debug build + /// loses iOS 12 and 13 for a Wallet entitlement it never carried. + static List repairQualifiedExtensionSettings(Map settings, + String hostPackage, String floor, String sdk, String configuration, String arch) { + return repairQualifiedExtensionSettings(settings, hostPackage, floor, + ArchiveContext.of(sdk, configuration, arch, settings)); + } + + /// @param context the whole of it. Rebuilding one here from three loose values put the variant + /// back to "normal", so an archive declaring BUILD_VARIANTS=profile had its + /// [variant=profile] entries skipped -- and an under-floor target among them was then copied + /// onto the target and won for the build Xcode actually makes. + static List repairQualifiedExtensionSettings(Map settings, + String hostPackage, String floor, ArchiveContext context) { + List notes = new ArrayList(); + // Flattened once, for the references these values make. A qualified identifier written as + // $(EXTENSION_ID) resolved against the raw map, which only ever answers with the plain + // EXTENSION_ID -- so an archive whose EXTENSION_ID[config=Release] is a perfectly good + // extension of this app had the base value read instead, and the qualified identifier + // Xcode would have used was dropped for being out of namespace. The generated target then + // fell back to its base bundle id while the export options still named the custom one. + Map flat = flattenForContext(settings, context); + for (Map.Entry setting : new ArrayList>( + settings.entrySet())) { + String key = setting.getKey(); + if (!conditionApplies(key, context)) { + continue; + } + String value = setting.getValue() == null ? "" : setting.getValue().trim(); + // What the value RESOLVES to, since a qualified setting may be written through another + // one -- IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*] = $(EXTENSION_MIN) with + // 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. An identifier that resolves to nothing is left exactly as written: this + // build cannot evaluate it, which is not the same as knowing it is wrong. A + // deployment target is the other way round, for the reason below. + String resolved = resolveSettingsInValue(value, flat); + if (resolved.length() == 0) { + // Nothing left after expansion. For a deployment target that is not "cannot + // tell": Xcode expands the same missing reference to the same nothing, and an + // empty IPHONEOS_DEPLOYMENT_TARGET is not the base value -- it is no minimum at + // all, so the qualified entry overrides the clamped base with a blank and the + // floor is bypassed. Raised to the floor like any other under-floor value. + // + // Except $(inherited), which is not a setting this build failed to find but a + // directive: Xcode replaces it with the value from the level above, and writing + // the floor over it would pin an extension that inherits iOS 16 down to 12. + if (!isQualified(key, "IPHONEOS_DEPLOYMENT_TARGET") + || value.toLowerCase().indexOf("$(inherited)") >= 0 + || value.toLowerCase().indexOf("${inherited}") >= 0) { + continue; + } + settings.put(key, floor); + notes.add(key + " = " + value + " resolves to nothing, and an empty deployment " + + "target is no minimum at all, so it was set to " + floor); + continue; + } + if (isQualified(key, "IPHONEOS_DEPLOYMENT_TARGET") + && isDeploymentTargetBelow(resolved, floor)) { + settings.put(key, floor); + notes.add(key + " raised from " + value + " to " + floor); + } else if (isQualified(key, "PRODUCT_BUNDLE_IDENTIFIER") && hostPackage != null + && !resolved.startsWith(hostPackage + ".")) { + settings.remove(key); + notes.add(key + " = " + value + " dropped, since an embedded extension must be " + + "under " + hostPackage); + } + } + return notes; + } + + /// The iOS SDK this build archives against. + /// + /// A local device build passes no -sdk at all and lets the destination pick the active one, + /// so there is no version to assume -- and assuming a stale one (14.4 was the default in the + /// hint) made an exact qualifier like [sdk=iphoneos26.0] read as some other build's. The hint + /// answers when it is set; otherwise xcrun is asked, and if that cannot answer either the + /// bare platform name is used, which matches any version of it. + String activeIosSdkName(BuildRequest request) { + // Deliberately NOT the ios.sdk hint. This builder passes no -sdk to xcodebuild -- the + // destination picks the active SDK -- and the hint has no other use in this module, so it + // does not control the archive. Matching an exact [sdk=iphoneos14.4] condition against a + // stale hint under Xcode 26 selects settings the build never applies. + try { + // Through the Xcode this build actually uses. resolveXcodebuild honours XCODEBUILD, + // DEVELOPER_DIR and XCODE_APP, and asking the system default instead can report a + // different installation's SDK -- which then makes an exact [sdk=iphoneosNN] condition + // match, or fail to match, on a version this archive never sees. + ProcessBuilder builder = new ProcessBuilder(xcrunForSelectedXcode(), "--sdk", + "iphoneos", "--show-sdk-version"); + String developerDir = selectedDeveloperDir(); + if (developerDir != null) { + builder.environment().put("DEVELOPER_DIR", developerDir); + } + Process p = builder.redirectErrorStream(false).start(); + java.io.BufferedReader in = new java.io.BufferedReader( + new java.io.InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8)); + String version; + try { + version = in.readLine(); + } finally { + in.close(); + } + if (p.waitFor() == 0 && version != null && version.trim().length() > 0) { + return "iphoneos" + version.trim(); + } + } catch (Exception noXcrun) { + // Not a Mac, or no Xcode: the bare platform name still matches every version of it. + } + return "iphoneos"; + } + + /// The xcrun beside the xcodebuild this build selected, or the system one. + private String xcrunForSelectedXcode() { + String selected = resolveXcodebuild(); + if (selected != null) { + File beside = new File(new File(selected).getParentFile(), "xcrun"); + if (beside.canExecute()) { + return beside.getAbsolutePath(); + } + } + return "/usr/bin/xcrun"; + } + + /// The developer directory of the selected Xcode -- /Contents/Developer -- so a + /// tool run through the system xcrun still resolves inside it. Null when it cannot be told. + private String selectedDeveloperDir() { + String fromEnvironment = System.getenv("DEVELOPER_DIR"); + if (fromEnvironment != null && fromEnvironment.length() > 0) { + return fromEnvironment; + } + String selected = resolveXcodebuild(); + if (selected == null) { + return null; + } + // .../Contents/Developer/usr/bin/xcodebuild -> .../Contents/Developer + File developer = new File(selected).getParentFile(); + for (int i = 0; i < 2 && developer != null; i++) { + developer = developer.getParentFile(); + } + return isDeveloperDir(developer) ? developer.getAbsolutePath() : null; + } + + /// Whether this really is an Xcode developer directory. + /// + /// Two levels up from /usr/bin/xcodebuild -- the shim most machines have on PATH, and what + /// `which xcodebuild` reports into XCODEBUILD -- is the filesystem root, where usr/bin exists + /// and is not a developer directory at all. Handing DEVELOPER_DIR=/ to xcrun makes it fail, + /// the SDK name falls back to the unversioned "iphoneos", and an exact [sdk=iphoneosNN] + /// condition is then decided by map order rather than by the SDK the archive is built with. + /// + /// Platforms is the thing no other directory has: the CommandLineTools tree carries usr/bin + /// without it, and so does the root. + static boolean isDeveloperDir(File developer) { + return developer != null + && new File(developer, "usr/bin/xcodebuild").isFile() + && new File(developer, "Platforms").isDirectory(); + } + + /// The archive's settings with every conditional resolved to the value THIS build gets. + /// + /// A reference is expanded against a map, and a map lookup only ever sees the plain key -- so + /// $(MARKETING_VERSION) resolved to the base 5.4 while MARKETING_VERSION[sdk=iphoneos*] = 5.3 + /// sat beside it and was the value Xcode used for the device archive. The extension then + /// carried a version its containing app does not have, which is the rejection this stamping + /// exists to prevent. Flattened here, once, so everything downstream expands against the + /// values the build really has. + static Map flattenForContext(Map settings, + ArchiveContext context) { + if (settings == null) { + return null; + } + Map flat = new LinkedHashMap(); + for (String key : settings.keySet()) { + int open = key.indexOf('['); + String name = open < 0 ? key : key.substring(0, open); + if (flat.containsKey(name)) { + continue; + } + String winner = context == null ? settings.get(name) + : winningSetting(settings, name, context); + if (winner != null) { + flat.put(name, winner); + } + } + return flat; + } + + /// The context a qualified setting describes, over the top of the archive's own. + /// + /// INFOPLIST_FILE[config=Debug] = $(CONFIGURATION)/Info.plist means Debug/Info.plist, not + /// Release/Info.plist -- resolving every candidate in the ACTIVE context stamped a file that + /// belongs to another configuration and left the one the qualifier names untouched. Whatever + /// the condition does not name stays as this archive has it. + static ArchiveContext contextForCondition(String key, ArchiveContext context) { + ArchiveContext active = context; + int open = key == null ? -1 : key.indexOf('['); + if (open < 0) { + return active; + } + String sdk = active == null ? null : active.sdk; + String configuration = active == null ? null : active.configuration; + String arch = active == null ? null : active.arch; + List variants = active == null ? null : active.variants; + for (String condition : key.substring(open).split("[\\[\\],]")) { + int equals = condition.indexOf('='); + if (equals < 0) { + continue; + } + String name = condition.substring(0, equals).trim(); + String value = condition.substring(equals + 1).trim(); + boolean pattern = value.endsWith("*"); + if (pattern) { + // A pattern names a family. If THIS archive is in that family, its own value is + // the one Xcode will expand -- $(SDK_NAME) under [sdk=iphoneos*] is iphoneos14.4, + // not "iphoneos", and looking for the stem's file left the real one unstamped. + // Only when the pattern describes some other build does the stem stand in for it. + String archiveValue = "sdk".equals(name) ? (context == null ? null : context.sdk) + : "config".equals(name) ? (context == null ? null : context.configuration) + : "arch".equals(name) ? (context == null ? null : context.arch) : null; + if (archiveValue != null && matchesCondition(value, archiveValue)) { + continue; + } + // The pattern is kept whole, star and all. Stripped to its stem it read as a + // concrete value and was expanded as one: [sdk=iphonesimulator*] with + // Wallet/$(SDK_NAME)/Info.plist went looking for Wallet/iphonesimulator, while + // the simulator build that file belongs to expands SDK_NAME to a versioned + // iphonesimulator18.0 -- so the wrong path was reported missing and the real + // plist was never stamped. As a pattern it still matches the family for + // conditionApplies, and the built-ins it cannot supply are simply not supplied. + } + if (value.length() == 0) { + continue; + } + if ("sdk".equals(name)) { + sdk = value; + } else if ("config".equals(name)) { + configuration = value; + } else if ("arch".equals(name)) { + arch = value; + } else if ("variant".equals(name)) { + variants = java.util.Collections.singletonList(value); + } + } + return new ArchiveContext(sdk, configuration, arch, variants); + } + + /// What this archive IS, for matching conditional build settings against. + /// + /// These four travelled as loose parameters and kept growing -- and the one that was NOT a + /// parameter, the build variant, was hard-coded to "normal" and turned out to be settable by + /// the archive (BUILD_VARIANTS). Held together here so the next dimension is one field rather + /// than a signature change in six places. + static final class ArchiveContext { + final String sdk; + final String configuration; + final String arch; + /// Every variant this build produces; a [variant=...] condition applies if it names one. + final List variants; + + ArchiveContext(String sdk, String configuration, String arch, List variants) { + this.sdk = sdk; + this.configuration = configuration; + this.arch = arch; + this.variants = variants; + } + + /// The four dimensions, for logs and for use as a map key. + @Override + public String toString() { + return "sdk=" + sdk + ",config=" + configuration + ",arch=" + arch + + ",variants=" + variants; + } + + /// @param settings the extension's own, since BUILD_VARIANTS in them is copied onto the + /// target and decides which variants Xcode actually builds + static ArchiveContext of(String sdk, String configuration, String arch, + Map settings) { + List variants = new ArrayList(); + // The variant list is what the OTHER conditions are matched against, so it has to be + // chosen before any of them and cannot be chosen BY one: this bootstrap context knows + // the archive's sdk, configuration and architecture and has no variants at all, which + // makes a [variant=...] qualifier on BUILD_VARIANTS itself apply to nothing. Xcode is + // in the same position -- the setting decides the variants, so it cannot be selected + // by them -- and reading only the plain key missed BUILD_VARIANTS[sdk=iphoneos*], + // which Xcode does honour: preflight then judged the device archive as "normal" and + // skipped the [variant=profile] target that outranked the clamped base on it. + ArchiveContext selection = new ArchiveContext(sdk, configuration, arch, + java.util.Collections.emptyList()); + String declaredRaw = settings == null ? null + : winningSetting(settings, "BUILD_VARIANTS", selection); + // Resolved first: BUILD_VARIANTS = $(EXTENSION_VARIANTS) is a chain Xcode expands, and + // splitting the raw text recorded "$(EXTENSION_VARIANTS)" as the variant -- so the + // [variant=profile] settings Xcode applies were matched against a literal reference. + // Against the flattened settings, since the helper it names may itself be qualified. + String declared = declaredRaw == null ? null + : resolveSettingsInValue(declaredRaw, flattenForContext(settings, selection)); + if (declared != null) { + for (String variant : declared.trim().split("\\s+")) { + if (variant.length() > 0) { + variants.add(variant); + } + } + } + if (variants.isEmpty()) { + variants.add("normal"); + } + return new ArchiveContext(sdk, configuration, arch, variants); + } + } + + /// The value of {@code name} that governs THIS archive. + /// + /// Xcode does not merge a qualified setting with the plain one, it OVERRIDES it: with both + /// 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) { + return winningSetting(settings, name, sdk, configuration, null); + } + + static String winningSetting(Map settings, String name, String sdk, + String configuration, String arch) { + return winningSetting(settings, name, + ArchiveContext.of(sdk, configuration, arch, settings)); + } + + static String winningSetting(Map settings, String name, + ArchiveContext context) { + if (settings == null) { + return null; + } + String winner = null; + long 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, context)) { + continue; + } + long specificity = qualified ? conditionSpecificity(key) : 0; + if (specificity > winningSpecificity) { + winningSpecificity = specificity; + winner = setting.getValue(); + } + } + return winner; + } + + /// How specific a qualified key is, the way Xcode ranks it. + /// + /// More conditions beat fewer, and an EXACT value beats a wildcard: with + /// [sdk=iphoneos26.0] beside [sdk=iphoneos*], Xcode uses the exact one. Counting the '=' + /// characters alone scored those equal and left the winner to Properties' iteration order -- + /// which could read the wrong entitlements file, compute the wrong floor, or judge the wrong + /// identifier, all silently. + static long conditionSpecificity(String key) { + int open = key.indexOf('['); + if (open < 0) { + return 0; + } + long conditions = 0; + long precision = 0; + for (String condition : key.substring(open).split("[\\[\\],]")) { + int equals = condition.indexOf('='); + if (equals < 0) { + continue; + } + conditions++; + String value = condition.substring(equals + 1).trim(); + // An exact value beats any wildcard; between wildcards the longer prefix is the + // narrower pattern, which is the one Xcode picks: [sdk=iphoneos14.*] over + // [sdk=iphoneos*] for an iphoneos14.4 archive. Scoring every wildcard alike left that + // to Properties' iteration order. + precision += value.endsWith("*") ? value.length() - 1 : PRECISION_EXACT; + } + // Conditions first, precision as the tiebreak: two conditions describe a narrower build + // than one, however precisely that one is written. + return conditions * PRECISION_SCALE + Math.min(precision, PRECISION_SCALE - 1); + } + + /// A value with no wildcard is as precise as a condition gets. + private static final long PRECISION_EXACT = 1000; + + /// Wide enough that the precision sum cannot reach into the condition count above it. + private static final long PRECISION_SCALE = 1000000; + + /// Whether a qualified setting's condition can apply to the build being made. + /// + /// Only the two conditions this build knows its own answer to are judged -- sdk and config. + /// 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) { + 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) { + return conditionApplies(key, new ArchiveContext(sdk, configuration, arch, + java.util.Collections.singletonList("normal"))); + } + + static boolean conditionApplies(String key, ArchiveContext context) { + String sdk = context.sdk; + String configuration = context.configuration; + String arch = context.arch; + int open = key.indexOf('['); + if (open < 0) { + return true; + } + 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 && !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; + } + // Against the variants this build actually produces. "normal" unless the extension's + // own BUILD_VARIANTS says otherwise -- that setting is copied onto the target, so an + // archive declaring profile really is built as profile and its [variant=profile] + // settings are the ones Xcode applies. + if ("variant".equals(name)) { + boolean matches = false; + for (String variant : context.variants) { + matches |= matchesCondition(value, variant); + } + if (!matches) { + return false; + } + } + } + return true; + } + + /// 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()); + if (sdkVersion.length() == 0) { + // This build does not know its own SDK version -- a local archive lets the destination + // choose it -- so a versioned condition may or may not be this one. Counted, by the + // same rule as any condition that cannot be evaluated here. + return true; + } + // Xcode matches an unwildcarded condition against the versioned SDK_NAME exactly, so + // [sdk=iphoneos] does NOT apply to an archive built with iphoneos14.4; [sdk=iphoneos*] is + // the spelling that does. Treating the bare one as a match picked settings Xcode ignores: + // an entitlements file the target is not signed with, or an identifier it is not built + // with. Erring toward applicable is for what this build cannot evaluate, not for what it + // can evaluate and Xcode says no to. + return conditionVersion.equals(sdkVersion); + } + + /// The letters an SDK name starts with, which is its platform: "iphoneos" of "iphoneos14.4". + 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("*")) { + 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) { + 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 + /// 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. + /// + /// 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) { + 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, appTarget, extensionFolder, + settings, null); + } + + static String appExtensionDeploymentTarget(String declared, File entitlements, String appTarget, + File extensionFolder, Map settings, ArchiveContext context) { + return appExtensionDeploymentTarget(declared, + entitlements == null ? new ArrayList() : Arrays.asList(entitlements), + appTarget, extensionFolder, settings, context); + } + + /// @param entitlements every file this target may be signed with, since a qualified + /// CODE_SIGN_ENTITLEMENTS can be the one that carries the Wallet entitlement + static String appExtensionDeploymentTarget(String declared, List entitlements, + String appTarget, File extensionFolder, Map settings) { + return appExtensionDeploymentTarget(declared, entitlements, appTarget, extensionFolder, + settings, null); + } + + /// @param context the archive's, so a target written through another setting is resolved with + /// the conditionals THIS build gets. Rebuilding an empty context here let an inactive + /// qualifier -- EXTENSION_MIN[config=Debug] beside a Release build -- win by specificity, and + /// the expression was then kept on the strength of a value Xcode never expands it to. + static String appExtensionDeploymentTarget(String declared, List entitlements, + String appTarget, File extensionFolder, Map settings, + ArchiveContext context) { + // The floor is a floor, not a default. An archive exported from an old project may carry + // IPHONEOS_DEPLOYMENT_TARGET = 10.0 of its own, and honouring that unconditionally would + // reproduce the very rejection this exists to prevent -- 10.0 does not even build against + // the current SDK, and an issuer-provisioning Wallet extension is refused below 14. + String floor = appExtensionDeploymentFloor(entitlements); + 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: a reference that lands + // on a version clearing the floor is kept as written, because Xcode resolves it on the + // target and that is the archive author's expression to keep. + String resolved = extensionFolder == null ? "" : resolveSettingsInValue(chosen, + context == null + ? extensionSettingsWithBuiltIns(extensionFolder, settings) + : extensionSettingsWithBuiltIns(extensionFolder, settings, + context.configuration, context.sdk, context.arch)); + if (resolved.length() == 0) { + // And a reference to a setting nothing defines is not "unknown" -- Xcode expands + // it to the empty string, so the extension would declare no minimum at all. The + // floor is the answer, not the expression. + return floor; + } + return isDeploymentTargetBelow(resolved, floor) ? floor : chosen; + } + 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 + /// 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) { + // 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; + } + 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) { + 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, settings); + if (path == null || path.length() == 0) { + return null; + } + File resolved = new File(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; + } + } + + /// The device families an extension embedded in THIS app may declare. + /// + /// The app target's own come from ios.project_type: the translator rewrites the template's + /// TARGETED_DEVICE_FAMILY to "1" for iphone and "2" for anything else that is not "ios". + /// Every extension here was pinned to "1,2" regardless, so an iPhone-only app shipped an + /// extension claiming iPad support -- the project builds, and App Store validation refuses + /// the upload for an embedded bundle whose device families its container does not have. + /// + /// This is the DEFAULT only. An archive that states its own TARGETED_DEVICE_FAMILY is applied + /// over it further down, since an extension deliberately narrower than its app -- a widget on + /// iPhone alone -- is the author's call to make. + static String embeddedExtensionDeviceFamily(String projectType) { + if (projectType == null || "ios".equalsIgnoreCase(projectType)) { + return "1,2"; + } + return "iphone".equalsIgnoreCase(projectType) ? "1" : "2"; + } + + /// The identifier this extension's archive declares, or the one derived from the app. + /// + /// Named rather than inlined because four call sites have to agree on it: an archive that + /// overrides PRODUCT_BUNDLE_IDENTIFIER decides the export-options key, the profile that can + /// sign it, the plist that is stamped and the namespace refusal, and one of them reading the + /// derived default instead pairs the target with a bundle the archive does not contain. + static String appExtensionBundleId(File extensionFolder, String defaultBundleId) { + String override = appExtensionBuildSetting(extensionFolder, "PRODUCT_BUNDLE_IDENTIFIER"); + return override == null ? defaultBundleId : override; + } + + /// One build setting as the extension's own buildSettings.properties overrides it, or null + /// when the archive carries no such override. + /// + /// 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) { + 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 out; + } + Properties props = new Properties(); + FileInputStream fis = null; + try { + fis = new FileInputStream(settings); + props.load(fis); + } catch (IOException ex) { + return out; + } finally { + if (fis != null) { + try { fis.close(); } catch (Throwable t) {} + } + } + for (Object key : props.keySet()) { + if (key instanceof String) { + String value = props.getProperty((String) key); + // Trimmed, because Properties keeps trailing whitespace and Xcode does not: + // xcodebuild -showBuildSettings reports a padded value without its padding, and + // the parser for that output trims too. Untrimmed, preflight judged + // "com.example.app.Ext" while the target was handed "com.example.app.Ext " -- + // an identifier no profile matches and no bundle may carry, arrived at by two + // readers of the same file disagreeing about what it says. + out.put((String) key, value == null ? null : value.trim()); + } + } + return out; + } + + /// 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) { + 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 = settings != null && settings.get("PRODUCT_NAME") != null + ? settings.get("PRODUCT_NAME").trim() + : 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; + // 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); + out = replaceBuildSetting(out, "PRODUCT_NAME", productName); + return out.indexOf('$') >= 0 ? null : out; + } + + /// The same as {@link #resolveSettingsInValue}, but null when a reference is left over. + /// + /// resolveSettingsInValue deletes what it cannot expand, which is right when the question is + /// "what will this be on the device" -- Xcode deletes it too. It is wrong when the answer is + /// about to be RECORDED: com.example.app.$(SOMETHING_UNKNOWN) came out as "com.example.app.", + /// and that partial string went into the export-options dictionary as the key for a bundle + /// the archive does not contain, so a manual export could not pair the extension with its + /// profile. A value this build cannot resolve completely is better left alone than recorded + /// as a truncation of itself. + static String resolveSettingsFully(String value, Map settings) { + if (value == null) { + return null; + } + String resolved = resolveSettingsInValue(value, settings); + return BUILD_SETTING_REFERENCE.matcher(value).find() + && BUILD_SETTING_REFERENCE.matcher(stripResolved(value, settings)).find() + ? null : resolved; + } + + /// The value with every reference this build CAN expand already expanded, so what remains is + /// exactly what it cannot. + private static String stripResolved(String value, Map settings) { + String out = value; + if (settings != null) { + for (int pass = 0; pass < MAX_SETTING_EXPANSIONS + && BUILD_SETTING_REFERENCE.matcher(out).find(); pass++) { + String before = out; + for (Map.Entry setting : settings.entrySet()) { + if (setting.getValue() != null) { + out = replaceBuildSetting(out, setting.getKey(), setting.getValue()); + } + } + if (out.equals(before)) { + break; + } + } + } + return out; + } + + /// A plist value with the archive's own build settings substituted, so a {@code $(...)} + /// reference can be compared with the version it will actually resolve to on the device. + /// + /// 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) { + // 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()); + } + if (out.equals(before)) { + // Nothing left that this archive defines; the strip below handles the rest. + break; + } + } + } + // 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. + // 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. + /// A build-setting reference, modifiers included: $(NAME), ${NAME} and + /// ${NAME:rfc1034identifier}. Without the modifier form an identifier written that way looked + /// like plain text -- "fully resolved" -- and the literal expression was recorded as a bundle + /// id while Xcode archived the expansion of it. + private static final Pattern BUILD_SETTING_REFERENCE = + Pattern.compile("\\$[({][A-Za-z0-9_]+(?::[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) { + String out = path.replace("$(" + name + ")", value).replace("${" + name + "}", value); + return applyModifiers(out, name, value); + } + + /// Expands $(NAME:modifier) for the modifiers Xcode defines and this can reproduce. + /// + /// rfc1034identifier is the one that matters here: it is how an extension's identifier is + /// ordinarily written from a product name, ${PRODUCT_NAME:rfc1034identifier}, and Xcode + /// archives the expansion. A modifier this does not know is left in place, which keeps the + /// value "not fully resolved" rather than recording an expression as an identifier. + private static String applyModifiers(String value, String name, String settingValue) { + Matcher reference = Pattern.compile("\\$[({]" + Pattern.quote(name) + + "((?::[A-Za-z0-9_]+)+)[)}]").matcher(value); + StringBuffer out = new StringBuffer(); + while (reference.find()) { + String expanded = settingValue; + boolean known = true; + for (String modifier : reference.group(1).split(":")) { + if (modifier.length() == 0) { + continue; + } + if ("lower".equalsIgnoreCase(modifier)) { + expanded = expanded.toLowerCase(java.util.Locale.ENGLISH); + } else if ("upper".equalsIgnoreCase(modifier)) { + expanded = expanded.toUpperCase(java.util.Locale.ENGLISH); + } else if ("rfc1034identifier".equalsIgnoreCase(modifier)) { + // Anything outside a host-name label becomes a hyphen, which is what Xcode + // does to make a product name usable in a bundle identifier. + expanded = expanded.replaceAll("[^A-Za-z0-9.-]", "-"); + } else if ("identifier".equalsIgnoreCase(modifier) + || "c99extidentifier".equalsIgnoreCase(modifier)) { + expanded = expanded.replaceAll("[^A-Za-z0-9_]", "_"); + } else { + known = false; + break; + } + } + reference.appendReplacement(out, known + ? Matcher.quoteReplacement(expanded) + : Matcher.quoteReplacement(reference.group())); + } + reference.appendTail(out); + return out.toString(); + } + + /** + * 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(); + if (!basePath.endsWith("/")) { + basePath += "/"; + } + int basePathLen = basePath.length(); + for (File f : dir.listFiles()) { + if (f.isDirectory() && f.getName().endsWith(".xcassets")) { + // Asset catalogs are directory packages. Adding their contents one file at a + // time flattens every Contents.json into the extension bundle and makes Xcode + // fail with "Multiple commands produce .../Contents.json". Add the catalog + // itself so Xcode compiles it with actool. + sb.append("fileref = ").append(serviceGroupVarName).append(".new_file(").append("'").append(f.getAbsolutePath().substring(basePathLen)).append("')\n"); + sb.append(serviceTargetVarName).append(".add_resources([fileref])\n"); + } else if (f.isFile()) { + sb.append("fileref = ").append(serviceGroupVarName).append(".new_file(").append("'").append(f.getAbsolutePath().substring(basePathLen)).append("')\n"); + if (f.getName().endsWith(".m") || f.getName().endsWith(".swift")) { + sb.append(serviceTargetVarName).append(".add_file_references([fileref])\n"); + } else if (!f.getName().endsWith("Info.plist") && !f.getName().endsWith(".entitlements") + && !f.getName().endsWith(".h") && !f.getName().endsWith(".mobileprovision")){ + sb.append(serviceTargetVarName).append(".add_resources([fileref])\n"); + } + } else { + appendFilesToXcodeProjGroup(sb, f, serviceGroupVarName, serviceTargetVarName, baseDir); + } + } + } + + private void removeLinesContaining(File file, String... snippets) throws IOException { + if (file == null || !file.exists() || snippets == null || snippets.length == 0) { + return; + } + String content = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + StringBuilder sb = new StringBuilder(content.length()); + try (BufferedReader reader = new BufferedReader(new StringReader(content))) { + String line; + while ((line = reader.readLine()) != null) { + boolean remove = false; + for (String snippet : snippets) { + if (snippet != null && !snippet.isEmpty() && line.contains(snippet)) { + remove = true; + break; + } + } + if (!remove) { + sb.append(line).append('\n'); + } + } + } + createFile(file, sb.toString().getBytes(StandardCharsets.UTF_8)); + } + + /** + * Recursively checks whether the given directory contains any {@code .swift} files. + */ + private static boolean hasSwiftFiles(File dir) { + if (dir == null || !dir.isDirectory()) { + return false; + } + File[] children = dir.listFiles(); + if (children == null) { + return false; + } + for (File f : children) { + if (f.isFile() && f.getName().endsWith(".swift")) { + return true; + } + if (f.isDirectory() && hasSwiftFiles(f)) { + return true; + } + } + return false; + } + + + private String convertToJavaMethod(Class type) { + if(type.isArray()) { + type = type.getComponentType(); + if(Integer.class == type || Integer.TYPE == type) { + return "nsDataToIntArray("; + } + if(Long.class == type || Long.TYPE == type) { + return "nsDataToLongArray("; + } + if(Byte.class == type || Byte.TYPE == type) { + return "nsDataToByteArr("; + } + if(Short.class == type || Short.TYPE == type) { + return "nsDataToShortArray("; + } + if(Character.class == type || Character.TYPE == type) { + return "nsDataToCharArray("; + } + if(Boolean.class == type || Boolean.TYPE == type) { + return "nsDataToBooleanArray("; + } + if(Float.class == type || Float.TYPE == type) { + return "nsDataToFloatArray("; + } + if(Double.class == type || Double.TYPE == type) { + return "nsDataToDoubleArray("; + } + } + if(String.class == type) { + return "fromNSString(CN1_THREAD_GET_STATE_PASS_ARG "; + } + return ""; + } + + private String convertToClosing(Class type) { + if(type.isArray()) { + return ")"; + } + if(String.class == type) { + return ")"; + } + return ""; + } + + private String convertToObjectiveCMethod(Class type) { + if(type.isArray()) { + return "arrayToData("; + } + if(String.class == type) { + return "toNSString(CN1_THREAD_GET_STATE_PASS_ARG "; + } + return ""; + } + + private String getSimpleNameWithJavaLang(Class c) { + if(c.isPrimitive()) { + return c.getSimpleName(); + } + if(c.isArray()) { + return getSimpleNameWithJavaLang(c.getComponentType()) + "[]"; + } + if(c.getClass().getName().startsWith("java.lang.")) { + return c.getName(); + } + return c.getSimpleName(); + } + + private String typeToXMLVMJavaName(Class type) { + if(type.isArray()) { + return getSimpleNameWithJavaLang(type.getComponentType()).replace('.', '_') + "_1ARRAY"; + } + return getSimpleNameWithJavaLang(type).replace('.', '_'); + } + private String typeToXMLVMName(Class type) { + if(type.getName().equals("com.codename1.ui.PeerComponent")) { return "JAVA_LONG"; } if(Integer.class == type || Integer.TYPE == type) { @@ -6204,7 +8583,8 @@ private void appendMatterExtensionTarget(StringBuilder sb, BuildRequest request, buildSettingsMap.put("CODE_SIGN_ENTITLEMENTS", name + "/" + name + ".entitlements"); buildSettingsMap.put("IPHONEOS_DEPLOYMENT_TARGET", MatterExtensionBuilder.deploymentTarget(ownFabric)); - buildSettingsMap.put("TARGETED_DEVICE_FAMILY", "1,2"); + buildSettingsMap.put("TARGETED_DEVICE_FAMILY", + embeddedExtensionDeviceFamily(request.getArg("ios.project_type", "ios"))); buildSettingsMap.put("LD_RUNPATH_SEARCH_PATHS", "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"); buildSettingsMap.put("SKIP_INSTALL", "YES"); @@ -6295,7 +8675,8 @@ private void appendWalletExtensionRuby(StringBuilder sb, BuildRequest request, S // PKIssuerProvisioningExtensionHandler requires iOS 14; the extension target // keeps its own deployment target even when the app targets lower. buildSettingsMap.put("IPHONEOS_DEPLOYMENT_TARGET", "14.0"); - buildSettingsMap.put("TARGETED_DEVICE_FAMILY", "1,2"); + buildSettingsMap.put("TARGETED_DEVICE_FAMILY", + embeddedExtensionDeviceFamily(request.getArg("ios.project_type", "ios"))); buildSettingsMap.put("LD_RUNPATH_SEARCH_PATHS", "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"); buildSettingsMap.put("SKIP_INSTALL", "YES"); buildSettingsMap.put("CLANG_ENABLE_OBJC_ARC", "YES"); @@ -6858,42 +9239,6 @@ private void parseSurfacesManifest(File resDir, BuildRequest request) throws Bui * @param distDir the generated project's dist folder * @param appSrcDir the {@code -src} folder */ - /** - * The marketing version an embedded bundle must declare to match this app. - * - *

Not simply the project version: {@code ios.plistInject} REPLACES the phone's default - * version injection where it sets the key, so a project that overrides the version there - * ships an app whose version is not {@code shortVersion(request)} at all. An embedded bundle - * whose versions differ from its container is rejected by App Store validation, which is the - * one failure that shows up only at submission.

- * - * @param request the build being generated - * @return the CFBundleShortVersionString the app itself will declare - */ - private static String embeddedExtensionShortVersion(BuildRequest request) { - String injected = WatchNativeBuilder.injectedPlistString(request, - "CFBundleShortVersionString"); - return injected != null ? injected : WatchNativeBuilder.shortVersion(request); - } - - /** - * The build version an embedded bundle must declare to match this app. - * - *

The fallback is {@code shortVersion(request)} and deliberately NOT - * {@link #embeddedExtensionShortVersion}: the two keys are independent, the app's CFBundleVersion is - * {@code ios.bundleVersion} defaulting to the build version, and it does not follow an - * injected marketing version. Deriving one from the other produces the very mismatch this - * exists to prevent.

- * - * @param request the build being generated - * @return the CFBundleVersion the app itself will declare - */ - private static String embeddedExtensionBundleVersion(BuildRequest request) { - String injected = WatchNativeBuilder.injectedPlistString(request, "CFBundleVersion"); - return injected != null ? injected - : request.getArg("ios.bundleVersion", WatchNativeBuilder.shortVersion(request)); - } - private void writeWatchWidgetExtension(BuildRequest request, File distDir, File appSrcDir) throws IOException { if (!surfacesWatchEnabled) { @@ -7014,7 +9359,8 @@ private void appendWidgetExtensionRuby(StringBuilder sb, BuildRequest request, String extensionName = widgetBuilder.getExtensionName(); Map buildSettingsMap = new LinkedHashMap(); buildSettingsMap.put("PRODUCT_NAME", "$(TARGET_NAME)"); - buildSettingsMap.put("TARGETED_DEVICE_FAMILY", "1,2"); + buildSettingsMap.put("TARGETED_DEVICE_FAMILY", + embeddedExtensionDeviceFamily(request.getArg("ios.project_type", "ios"))); buildSettingsMap.put("LD_RUNPATH_SEARCH_PATHS", "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"); buildSettingsMap.put("CLANG_ENABLE_MODULES", "YES"); // The builder's buildSettings.properties supplies the deployment target, Swift @@ -7082,12 +9428,50 @@ 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); } List out = new ArrayList<>(); - for (File appExtension : sourceDirectory.listFiles()) { + File[] entries = sourceDirectory.listFiles(); + if (entries == null) { + return new File[0]; + } + for (File appExtension : entries) { if (!appExtension.getName().endsWith(".ios.appext")) { // Only interested in files ending in .ios.appext since // Maven would have bundled the app extensions in this way. @@ -7104,6 +9488,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; 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 1053f2011f2..107b46ee981 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 @@ -1041,7 +1041,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); @@ -1064,7 +1068,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))) { @@ -1085,7 +1089,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++) { @@ -1169,7 +1173,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); @@ -1211,7 +1215,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 @@ -1295,11 +1299,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; @@ -1320,7 +1332,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. 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..b2b27f5b7bd --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionBuildSettingsTest.java @@ -0,0 +1,99 @@ +/* + * 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; +import static org.junit.Assert.assertTrue; + +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")); + } + + @Test + public void paddingIsStrippedFromArchiveSettings() throws Exception { + java.io.File dist = java.nio.file.Files.createTempDirectory("appext").toFile(); + java.io.File extension = new java.io.File(dist, "WalletUIExtension"); + assertTrue(extension.mkdirs()); + java.io.FileWriter w = new java.io.FileWriter( + new java.io.File(extension, "buildSettings.properties")); + w.write("PRODUCT_BUNDLE_IDENTIFIER=com.example.app.Ext \n"); + w.close(); + + // Properties keeps the trailing space and Xcode does not. Kept, preflight validated + // "com.example.app.Ext" while the target was handed "com.example.app.Ext " -- an + // identifier no profile matches, from two readers of one file disagreeing. + assertEquals("com.example.app.Ext", IPhoneBuilder.appExtensionBuildSettings(extension) + .get("PRODUCT_BUNDLE_IDENTIFIER")); + } + + @Test + public void extensionDeviceFamiliesFollowTheApp() { + // The translator gives the app target "1" for iphone and "2" for anything else that is + // not "ios"; an extension pinned to "1,2" beside an iPhone-only app is an upload + // rejection for an embedded bundle its container does not support. + assertEquals("1", IPhoneBuilder.embeddedExtensionDeviceFamily("iphone")); + assertEquals("2", IPhoneBuilder.embeddedExtensionDeviceFamily("ipad")); + assertEquals("1,2", IPhoneBuilder.embeddedExtensionDeviceFamily("ios")); + assertEquals("1,2", IPhoneBuilder.embeddedExtensionDeviceFamily(null)); + } +} 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..425a102527e --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionDeploymentTargetTest.java @@ -0,0 +1,1016 @@ +/* + * 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.assertNull; +import static org.junit.Assert.assertFalse; +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")); + } + + @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")); + } + + @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")); + } + + @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)); + } + + @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 aReferenceToNothingIsNotAMinimumAtAll() throws Exception { + File extension = tmp.newFolder("dist21", "WalletUIExtension"); + // Xcode expands a reference nothing defines to the empty string, so the extension would + // declare no minimum -- which is why this is the floor's answer and not the expression's. + assertEquals("14.0", IPhoneBuilder.appExtensionDeploymentTarget("$(SOMETHING_ELSE)", + walletEntitlements(), "11", extension, + new java.util.LinkedHashMap())); + } + + @Test + 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()); + } + + @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)); + } + + @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)); + } + + @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")); + } + + @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")); + // Not this one: Xcode matches an unwildcarded condition against the versioned SDK_NAME + // exactly, so [sdk=iphoneos] never applies to iphoneos14.4 and selecting on it would pick + // a setting Xcode ignores. [sdk=iphoneos*] is the spelling that applies. + assertFalse(IPhoneBuilder.conditionApplies("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos]", + "iphoneos14.4", "Release")); + // But when THIS build does not know its own SDK version, a versioned condition still + // counts, because it cannot be evaluated either way. + assertTrue(IPhoneBuilder.conditionApplies("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos26.0]", + "iphoneos", "Release")); + assertTrue(IPhoneBuilder.conditionApplies("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]", + "iphoneos14.4", "Release")); + // a different platform still does not + assertFalse(IPhoneBuilder.conditionApplies("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphonesimulator14.4]", + "iphoneos14.4", "Release")); + // and a different version of the same platform, when both name one + assertFalse(IPhoneBuilder.conditionApplies("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos13.0]", + "iphoneos14.4", "Release")); + } + + @Test + public void anArchitectureQualifierIsDecidedByTheArchiveNotByMapOrder() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + // x86_64 first, so map order would pick it. + settings.put("CODE_SIGN_ENTITLEMENTS[arch=x86_64]", "WalletUIExtension/Sim.entitlements"); + settings.put("CODE_SIGN_ENTITLEMENTS[arch=arm64]", "WalletUIExtension/Device.entitlements"); + + assertEquals("WalletUIExtension/Device.entitlements", IPhoneBuilder.winningSetting( + settings, "CODE_SIGN_ENTITLEMENTS", "iphoneos14.4", "Release", "arm64")); + // and with no architecture to judge by, both still count rather than one being guessed away + assertTrue(IPhoneBuilder.conditionApplies("CODE_SIGN_ENTITLEMENTS[arch=x86_64]", + "iphoneos14.4", "Release", null)); + } + + @Test + public void theArchivesConfigurationSdkAndArchResolve() throws Exception { + File extension = tmp.newFolder("dist18", "WalletUIExtension"); + java.util.Map settings = IPhoneBuilder.extensionSettingsWithBuiltIns( + extension, new java.util.LinkedHashMap(), "Release", + "iphoneos14.4", "arm64"); + assertEquals("Release", settings.get("CONFIGURATION")); + assertEquals("iphoneos14.4", settings.get("SDK_NAME")); + assertEquals("iphoneos", settings.get("PLATFORM_NAME")); + assertEquals("arm64", settings.get("CURRENT_ARCH")); + } + + @Test + public void anEntitlementsPathThroughTheConfigurationResolves() throws Exception { + File extension = tmp.newFolder("dist19", "WalletUIExtension"); + File release = new File(extension, "Release.entitlements"); + write(release, "\ncom.apple.developer.payment-pass-provisioning\n" + + "\n"); + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("CODE_SIGN_ENTITLEMENTS", "WalletUIExtension/$(CONFIGURATION).entitlements"); + + File signing = IPhoneBuilder.appExtensionSigningEntitlements(extension, settings, null, + "iphoneos14.4", "Release", "arm64"); + + // Falling back to a by-name file here left a payment-pass extension on the 12.0 floor. + assertEquals(release, signing); + assertEquals("14.0", IPhoneBuilder.appExtensionDeploymentFloor(signing)); + } + + @Test + public void aPartiallyResolvableValueIsNotRecordedAsATruncation() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("CONFIGURATION", "Release"); + // Known: expands. Unknown: the whole answer is withheld rather than truncated, because + // "com.example.app." as an export-options key names no bundle in the archive. + assertEquals("com.example.app.Release", IPhoneBuilder.resolveSettingsFully( + "com.example.app.$(CONFIGURATION)", settings)); + assertNull(IPhoneBuilder.resolveSettingsFully( + "com.example.app.$(SOMETHING_UNKNOWN)", settings)); + assertEquals("com.example.app.Ext", IPhoneBuilder.resolveSettingsFully( + "com.example.app.Ext", settings)); + } + + @Test + public void aProductNameThroughTheConfigurationResolvesToo() throws Exception { + File extension = tmp.newFolder("dist20", "WalletUIExtension"); + java.util.Map declared = new java.util.LinkedHashMap(); + declared.put("PRODUCT_NAME", "$(CONFIGURATION)-Wallet"); + + java.util.Map settings = IPhoneBuilder.extensionSettingsWithBuiltIns( + extension, declared, "Release", "iphoneos14.4", "arm64"); + + // Resolving PRODUCT_NAME before the context was in the map left "-Wallet", and that went + // into the identifier and into the export-options key. + assertEquals("Release-Wallet", settings.get("PRODUCT_NAME")); + assertEquals("com.example.app.Release-Wallet", IPhoneBuilder.resolveSettingsFully( + "com.example.app.$(PRODUCT_NAME)", settings)); + } + + @Test + public void repairsLeaveOtherBuildsSettingsAlone() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*]", "10.0"); + settings.put("IPHONEOS_DEPLOYMENT_TARGET[sdk=iphonesimulator*]", "10.0"); + settings.put("IPHONEOS_DEPLOYMENT_TARGET[config=Debug]", "10.0"); + + java.util.List notes = IPhoneBuilder.repairQualifiedExtensionSettings(settings, + "com.example.app", "14.0", "iphoneos14.4", "Release", "arm64"); + + // The floor came from the entitlements THIS archive is signed with. Applying it to a + // simulator or Debug condition edits a target those entitlements have nothing to do with, + // and the edit lives on in the generated project. + assertEquals("14.0", settings.get("IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*]")); + assertEquals("10.0", settings.get("IPHONEOS_DEPLOYMENT_TARGET[sdk=iphonesimulator*]")); + assertEquals("10.0", settings.get("IPHONEOS_DEPLOYMENT_TARGET[config=Debug]")); + assertEquals(1, notes.size()); + } + + @Test + public void anIdentifierForAnotherBuildIsNotDroppedEither() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphonesimulator*]", "com.other.Sim"); + IPhoneBuilder.repairQualifiedExtensionSettings(settings, "com.example.app", "12.0", + "iphoneos14.4", "Release", "arm64"); + assertTrue(settings.containsKey("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphonesimulator*]")); + } + + @Test + public void aModifierReferenceResolvesAsXcodeExpandsIt() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("PRODUCT_NAME", "Wallet UI"); + // ${PRODUCT_NAME:rfc1034identifier} is the ordinary way to write this, and treating it as + // plain text recorded the expression itself as the bundle identifier. + assertEquals("com.example.app.Wallet-UI", IPhoneBuilder.resolveSettingsFully( + "com.example.app.${PRODUCT_NAME:rfc1034identifier}", settings)); + assertEquals("com.example.app.wallet ui", IPhoneBuilder.resolveSettingsFully( + "com.example.app.$(PRODUCT_NAME:lower)", settings)); + } + + @Test + public void aModifierThisBuildDoesNotKnowIsNotCalledResolved() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("PRODUCT_NAME", "Wallet"); + assertNull(IPhoneBuilder.resolveSettingsFully( + "com.example.app.$(PRODUCT_NAME:somethingNew)", settings)); + } + + @Test + public void anExactConditionOutranksAWildcardOne() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + // Wildcard first, so iteration order would pick it if the two scored equal. + settings.put("CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]", "Wildcard.entitlements"); + settings.put("CODE_SIGN_ENTITLEMENTS[sdk=iphoneos26.0]", "Exact.entitlements"); + + assertEquals("Exact.entitlements", IPhoneBuilder.winningSetting(settings, + "CODE_SIGN_ENTITLEMENTS", "iphoneos26.0", "Release", "arm64")); + assertTrue(IPhoneBuilder.conditionSpecificity("X[sdk=iphoneos26.0]") + > IPhoneBuilder.conditionSpecificity("X[sdk=iphoneos*]")); + // and more conditions still beat fewer + assertTrue(IPhoneBuilder.conditionSpecificity("X[sdk=iphoneos*,config=Release]") + > IPhoneBuilder.conditionSpecificity("X[sdk=iphoneos*]")); + } + + @Test + public void aNarrowerWildcardOutranksABroaderOne() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + // Broader first, so iteration order would pick it if the two scored equal. + settings.put("CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]", "Broad.entitlements"); + settings.put("CODE_SIGN_ENTITLEMENTS[sdk=iphoneos14.*]", "Narrow.entitlements"); + + assertEquals("Narrow.entitlements", IPhoneBuilder.winningSetting(settings, + "CODE_SIGN_ENTITLEMENTS", "iphoneos14.4", "Release", "arm64")); + assertTrue(IPhoneBuilder.conditionSpecificity("X[sdk=iphoneos14.*]") + > IPhoneBuilder.conditionSpecificity("X[sdk=iphoneos*]")); + // and an exact value still beats both + assertTrue(IPhoneBuilder.conditionSpecificity("X[sdk=iphoneos14.4]") + > IPhoneBuilder.conditionSpecificity("X[sdk=iphoneos14.*]")); + } + + @Test + public void aVariantThisBuilderNeverArchivesDoesNotWin() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("PRODUCT_BUNDLE_IDENTIFIER", "com.example.app.Ext"); + settings.put("PRODUCT_BUNDLE_IDENTIFIER[variant=profile]", "com.other.Ext"); + + // This builder archives the normal variant, so a profile-variant setting belongs to a + // build that does not happen here -- and it is more specific than the plain one, so + // accepting it let it win. + assertEquals("com.example.app.Ext", IPhoneBuilder.winningSetting(settings, + "PRODUCT_BUNDLE_IDENTIFIER", "iphoneos14.4", "Release", "arm64")); + assertFalse(IPhoneBuilder.conditionApplies("X[variant=profile]", "iphoneos14.4", + "Release", "arm64")); + assertTrue(IPhoneBuilder.conditionApplies("X[variant=normal]", "iphoneos14.4", + "Release", "arm64")); + } + + @Test + public void anArchiveThatDeclaresItsVariantGetsThatVariantsSettings() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("BUILD_VARIANTS", "profile"); + settings.put("PRODUCT_BUNDLE_IDENTIFIER", "com.example.app.Ext"); + settings.put("PRODUCT_BUNDLE_IDENTIFIER[variant=profile]", "com.example.app.Ext.profile"); + + // BUILD_VARIANTS is copied onto the target, so Xcode really does build the profile variant + // and apply its settings; hard-coding "normal" discarded them. + assertEquals("com.example.app.Ext.profile", IPhoneBuilder.winningSetting(settings, + "PRODUCT_BUNDLE_IDENTIFIER", "iphoneos14.4", "Release", "arm64")); + + java.util.Map ordinary = new java.util.LinkedHashMap(); + ordinary.put("PRODUCT_BUNDLE_IDENTIFIER", "com.example.app.Ext"); + ordinary.put("PRODUCT_BUNDLE_IDENTIFIER[variant=profile]", "com.other.Ext"); + assertEquals("com.example.app.Ext", IPhoneBuilder.winningSetting(ordinary, + "PRODUCT_BUNDLE_IDENTIFIER", "iphoneos14.4", "Release", "arm64")); + } + + @Test + public void anInfoPlistPathThroughTheConfigurationResolves() throws Exception { + File dist = tmp.newFolder("dist22"); + File extension = new File(dist, "WalletUIExtension"); + assertTrue(extension.mkdirs()); + File release = new File(extension, "Release.plist"); + write(release, ""); + write(new File(extension, "buildSettings.properties"), + "INFOPLIST_FILE = WalletUIExtension/$(CONFIGURATION).plist\n"); + + java.util.Map plists = IPhoneBuilder.appExtensionInfoPlists(extension, + IPhoneBuilder.ArchiveContext.of("iphoneos14.4", "Release", "arm64", null)); + + // Unresolvable here meant the stamping skipped the plist that actually ships. + assertTrue(plists.toString(), plists.values().contains(release)); + } + + @Test + public void repairsSeeTheVariantsTheArchiveDeclares() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("BUILD_VARIANTS", "profile"); + settings.put("IPHONEOS_DEPLOYMENT_TARGET[variant=profile]", "10.0"); + + java.util.List notes = IPhoneBuilder.repairQualifiedExtensionSettings(settings, + "com.example.app", "14.0", + IPhoneBuilder.ArchiveContext.of("iphoneos14.4", "Release", "arm64", settings)); + + // Rebuilding a context here from loose values put the variant back to "normal", so this + // entry was skipped -- and it is the one Xcode uses for the build it actually makes. + assertEquals("14.0", settings.get("IPHONEOS_DEPLOYMENT_TARGET[variant=profile]")); + assertEquals(1, notes.size()); + } + + @Test + public void aQualifiedPlistResolvesInItsOwnConfiguration() throws Exception { + File dist = tmp.newFolder("dist23"); + File extension = new File(dist, "WalletUIExtension"); + assertTrue(new File(extension, "Debug").mkdirs()); + assertTrue(new File(extension, "Release").mkdirs()); + File debugPlist = new File(extension, "Debug/Info.plist"); + write(debugPlist, ""); + File releasePlist = new File(extension, "Release/Info.plist"); + write(releasePlist, ""); + write(new File(extension, "buildSettings.properties"), + "INFOPLIST_FILE = WalletUIExtension/$(CONFIGURATION)/Info.plist\n" + + "INFOPLIST_FILE[config\\=Debug] = WalletUIExtension/$(CONFIGURATION)/Info.plist\n"); + + java.util.Map plists = IPhoneBuilder.appExtensionInfoPlists(extension, + IPhoneBuilder.ArchiveContext.of("iphoneos14.4", "Release", "arm64", null)); + + // The Debug-qualified entry means Debug/Info.plist. Resolving it in the ACTIVE context + // stamped Release/Info.plist twice and left the Debug one untouched. + assertTrue(plists.toString(), plists.values().contains(releasePlist)); + assertTrue(plists.toString(), plists.values().contains(debugPlist)); + } + + @Test + public void aConditionsOwnContextOverridesOnlyWhatItNames() throws Exception { + IPhoneBuilder.ArchiveContext active = IPhoneBuilder.ArchiveContext.of("iphoneos14.4", + "Release", "arm64", null); + IPhoneBuilder.ArchiveContext own = IPhoneBuilder.contextForCondition("X[config=Debug]", active); + assertEquals("Debug", own.configuration); + assertEquals("iphoneos14.4", own.sdk); + assertEquals("arm64", own.arch); + // A pattern names a FAMILY and is kept as one. Its stem was standing in for a value: + // $(SDK_NAME) then expanded to "iphonesimulator", while the simulator build the setting + // belongs to expands it to a versioned iphonesimulator18.0 -- a path nothing is at. + assertEquals("iphonesimulator*", IPhoneBuilder.contextForCondition( + "X[sdk=iphonesimulator*]", active).sdk); + assertTrue(IPhoneBuilder.isFamilyPattern("iphonesimulator*")); + assertFalse(IPhoneBuilder.isFamilyPattern("iphoneos14.4")); + } + + @Test + public void aHelperSettingResolvesInTheActiveContext() throws Exception { + File extension = tmp.newFolder("dist24", "WalletUIExtension"); + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("EXTENSION_MIN", "10.0"); + settings.put("EXTENSION_MIN[config=Debug]", "16.0"); + settings.put("IPHONEOS_DEPLOYMENT_TARGET", "$(EXTENSION_MIN)"); + + String target = IPhoneBuilder.appExtensionDeploymentTarget("$(EXTENSION_MIN)", + (File) null, "11", extension, settings, + IPhoneBuilder.ArchiveContext.of("iphoneos14.4", "Release", "arm64", settings)); + + // Without the archive's context the Debug qualifier wins by specificity, the reference + // looks like 16.0, and the expression is kept -- while Xcode expands it to the Release + // base 10.0 and the floor is bypassed. + assertEquals("12.0", target); + } + + @Test + public void variantsWrittenThroughAnotherSettingAreExpanded() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("EXTENSION_VARIANTS", "profile"); + settings.put("BUILD_VARIANTS", "$(EXTENSION_VARIANTS)"); + settings.put("PRODUCT_BUNDLE_IDENTIFIER", "com.example.app.Ext"); + settings.put("PRODUCT_BUNDLE_IDENTIFIER[variant=profile]", "com.example.app.Ext.profile"); + + // Xcode expands the chain and applies the profile settings; splitting the raw text + // recorded "$(EXTENSION_VARIANTS)" as the variant and matched nothing. + assertEquals("com.example.app.Ext.profile", IPhoneBuilder.winningSetting(settings, + "PRODUCT_BUNDLE_IDENTIFIER", "iphoneos14.4", "Release", "arm64")); + } + + @Test + public void aQualifiedVariantListSelectsTheArchivesVariants() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("BUILD_VARIANTS", "normal"); + settings.put("BUILD_VARIANTS[sdk=iphoneos*]", "profile"); + settings.put("IPHONEOS_DEPLOYMENT_TARGET[variant=profile]", "10.0"); + + java.util.List notes = IPhoneBuilder.repairQualifiedExtensionSettings(settings, + "com.example.app", "14.0", + IPhoneBuilder.ArchiveContext.of("iphoneos14.4", "Release", "arm64", settings)); + + // Xcode honours the qualified list for the device archive; reading the plain key alone + // judged that archive as "normal", skipped the profile target, and copied an under-floor + // 10.0 onto the target where it outranks the clamped base. + assertEquals("14.0", settings.get("IPHONEOS_DEPLOYMENT_TARGET[variant=profile]")); + assertEquals(1, notes.size()); + } + + @Test + public void aVariantListQualifiedByItsOwnVariantIsIgnored() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("BUILD_VARIANTS", "normal"); + settings.put("BUILD_VARIANTS[variant=profile]", "profile"); + + // The list decides the variants, so it cannot be selected by them -- and neither can + // Xcode select it that way. Letting this qualifier apply would be a self-fulfilling + // reading in which every archive builds every variant it mentions. + assertEquals(java.util.Collections.singletonList("normal"), IPhoneBuilder.ArchiveContext + .of("iphoneos14.4", "Release", "arm64", settings).variants); + } + + @Test + public void aQualifiedIdentifierResolvesItsHelperInTheArchivesContext() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("EXTENSION_ID", "com.other.Ext"); + settings.put("EXTENSION_ID[config=Release]", "com.example.app.Custom"); + settings.put("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]", "$(EXTENSION_ID)"); + + java.util.List notes = IPhoneBuilder.repairQualifiedExtensionSettings(settings, + "com.example.app", "12.0", + IPhoneBuilder.ArchiveContext.of("iphoneos14.4", "Release", "arm64", settings)); + + // Resolved against the raw map the helper answers with its base foreign value, the + // identifier is dropped as out of namespace, and the target falls back to a bundle id the + // export options and the signing profile do not name. + assertEquals("$(EXTENSION_ID)", settings.get("PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]")); + assertEquals(notes.toString(), 0, notes.size()); + } + + @Test + public void anIdentifierWrittenThroughAConditionalHelperResolvesToTheConditional() + throws Exception { + File dist = tmp.newFolder("dist31"); + File extension = new File(dist, "WalletUIExtension"); + assertTrue(extension.mkdirs()); + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("EXTENSION_ID", "com.other.Ext"); + settings.put("EXTENSION_ID[sdk=iphoneos*]", "com.example.app.Wallet"); + settings.put("PRODUCT_BUNDLE_IDENTIFIER", "$(EXTENSION_ID)"); + + // extensionSettingsWithBuiltIns flattens before it hands anything to the resolver, so the + // reference already expands to the value the device archive gets. Pinned because a review + // twice read that map as keeping qualified values under their bracketed keys only: it + // does not, and a change that makes it do so has to break this test first. + assertEquals("com.example.app.Wallet", IPhoneBuilder.resolveSettingsFully( + IPhoneBuilder.winningSetting(settings, "PRODUCT_BUNDLE_IDENTIFIER", + IPhoneBuilder.ArchiveContext.of("iphoneos14.4", "Release", "arm64", + settings)), + IPhoneBuilder.extensionSettingsWithBuiltIns(extension, settings, "Release", + "iphoneos14.4", "arm64"))); + } + + @Test + public void anIdentifierWrittenThroughTheProjectNameResolves() throws Exception { + File dist = tmp.newFolder("dist40"); + assertTrue(new File(dist, "MyApp.xcodeproj").mkdirs()); + File extension = new File(dist, "WalletUIExtension"); + assertTrue(extension.mkdirs()); + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("PRODUCT_BUNDLE_IDENTIFIER", "com.example.app.$(PROJECT_NAME)"); + + // $(PROJECT_NAME) is an Xcode built-in like $(TARGET_NAME), and not supplying it left the + // identifier unresolvable -- recorded for the export-options dictionary as its own source + // text, which names no bundle and matches no profile. + assertEquals("com.example.app.MyApp", IPhoneBuilder.resolveSettingsFully( + "com.example.app.$(PROJECT_NAME)", + IPhoneBuilder.extensionSettingsWithBuiltIns(extension, settings, "Release", + "iphoneos14.4", "arm64"))); + } + + @Test + public void twoProjectsInTheFolderLeaveTheProjectNameUnknown() throws Exception { + File dist = tmp.newFolder("dist41"); + assertTrue(new File(dist, "MyApp.xcodeproj").mkdirs()); + assertEquals("MyApp", IPhoneBuilder.singleXcodeProjectName(dist)); + + // Two would be a guess, and a guessed identifier is the thing being fixed here. + assertTrue(new File(dist, "OtherApp.xcodeproj").mkdirs()); + assertNull(IPhoneBuilder.singleXcodeProjectName(dist)); + } + + @Test + public void anInactiveWildcardDoesNotBecomeAConcreteSdk() throws Exception { + File dist = tmp.newFolder("dist42"); + File extension = new File(dist, "WalletUIExtension"); + assertTrue(extension.mkdirs()); + write(new File(extension, "buildSettings.properties"), + "INFOPLIST_FILE[sdk\\=iphonesimulator*] = WalletUIExtension/$(SDK_NAME)/Info.plist\n"); + + java.util.Map plists = IPhoneBuilder.appExtensionInfoPlists(extension, + IPhoneBuilder.ArchiveContext.of("iphoneos14.4", "Release", "arm64", null)); + + // The simulator build this file belongs to expands SDK_NAME to a versioned + // iphonesimulator18.0, so the stem named a path nothing is ever at: the wrong file was + // reported missing and the real one was never stamped. Unresolvable is the honest + // answer, and it is reported as one this build will not edit. + for (java.util.Map.Entry candidate : plists.entrySet()) { + if (candidate.getKey().contains("iphonesimulator")) { + assertNull(candidate.getKey(), candidate.getValue()); + } + } + } + + @Test + public void aQualifiedTargetThatResolvesToNothingIsClamped() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("IPHONEOS_DEPLOYMENT_TARGET", "14.0"); + settings.put("IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*]", "$(MISSING_MIN)"); + + java.util.List notes = IPhoneBuilder.repairQualifiedExtensionSettings(settings, + "com.example.app", "14.0", + IPhoneBuilder.ArchiveContext.of("iphoneos14.4", "Release", "arm64", settings)); + + // Xcode expands the same missing reference to the same nothing, and an empty deployment + // target is not the base value -- it is no minimum at all, so the qualified entry + // overrides the clamped base with a blank and the floor is bypassed. + assertEquals("14.0", settings.get("IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*]")); + assertEquals(notes.toString(), 1, notes.size()); + } + + @Test + public void anInheritedTargetIsLeftToInherit() throws Exception { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*]", "$(inherited)"); + + java.util.List notes = IPhoneBuilder.repairQualifiedExtensionSettings(settings, + "com.example.app", "12.0", + IPhoneBuilder.ArchiveContext.of("iphoneos14.4", "Release", "arm64", settings)); + + // Not a setting this build failed to find but a directive: Xcode replaces it with the + // value from the level above, and writing a floor over it pins an extension that + // inherits iOS 16 down to 12. + assertEquals("$(inherited)", settings.get("IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*]")); + assertEquals(notes.toString(), 0, notes.size()); + } + + @Test + public void anEmptyEntitlementsOverrideMeansNoEntitlements() throws Exception { + File dist = tmp.newFolder("dist50"); + File extension = new File(dist, "WalletUIExtension"); + assertTrue(extension.mkdirs()); + File byName = new File(extension, "WalletUIExtension.entitlements"); + write(byName, "\n\n" + + "com.apple.developer.payment-pass-provisioning\n" + + "\n"); + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("CODE_SIGN_ENTITLEMENTS", "WalletUIExtension/WalletUIExtension.entitlements"); + settings.put("CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]", ""); + + // Declared and empty is not the same as not declared: Xcode signs the device build with + // no entitlements file, so reading the by-name one found a Wallet entitlement the target + // does not carry and raised it to iOS 14 for it. + assertNull(IPhoneBuilder.appExtensionSigningEntitlements(extension, settings, byName, + "iphoneos14.4", "Release", "arm64")); + assertEquals("12.0", IPhoneBuilder.appExtensionDeploymentFloor( + IPhoneBuilder.appExtensionSigningEntitlements(extension, settings, byName, + "iphoneos14.4", "Release", "arm64"))); + + // And a missing winner still falls back to the file named after the extension. + settings.remove("CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]"); + settings.remove("CODE_SIGN_ENTITLEMENTS"); + assertEquals(byName, IPhoneBuilder.appExtensionSigningEntitlements(extension, settings, + byName, "iphoneos14.4", "Release", "arm64")); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistPathTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistPathTest.java new file mode 100644 index 00000000000..41c68bfed2b --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistPathTest.java @@ -0,0 +1,248 @@ +/* + * 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.assertTrue; +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 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(); + 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(); + } + } + + @Test + public void theContainingAppsPlistIsNotStamped() throws Exception { + File dist = tmp.newFolder("hostplist"); + File appSrc = new File(dist, "MyApp-src"); + assertTrue(appSrc.mkdirs()); + File hostPlist = new File(appSrc, "MyApp-Info.plist"); + assertTrue(hostPlist.createNewFile()); + File extension = new File(dist, "WalletUIExtension"); + assertTrue(extension.mkdirs()); + File ownPlist = new File(extension, "Info.plist"); + assertTrue(ownPlist.createNewFile()); + + // Everything under the project directory is writable on purpose, so an extension setting + // that names the app's own plist -- by relative path or through a reference -- reaches + // the stamper like any other candidate. What would be written there is an EXTENSION's + // identity: the app's version, or its identifier handed to a $(PRODUCT_BUNDLE_IDENTIFIER) + // that means something else in the app target. + assertTrue(IPhoneBuilder.isHostAppInfoPlist(hostPlist, dist, "MyApp")); + assertTrue(IPhoneBuilder.isHostAppInfoPlist( + new File(extension, "../MyApp-src/MyApp-Info.plist"), dist, "MyApp")); + assertFalse(IPhoneBuilder.isHostAppInfoPlist(ownPlist, dist, "MyApp")); + assertFalse(IPhoneBuilder.isHostAppInfoPlist(hostPlist, dist, null)); + } + + @Test + public void oneFileNamedByTwoConditionsIsStampedInBoth() throws Exception { + File dist = tmp.newFolder("shared-plist"); + File extension = new File(dist, "WalletUIExtension"); + assertTrue(extension.mkdirs()); + File shared = new File(extension, "Info.plist"); + write(shared, "\n\n\n" + + "\tCFBundleShortVersionString\n\t$(MARKETING_VERSION)\n" + + "\tCFBundleVersion\n\t$(MARKETING_VERSION)\n" + + "\tCFBundleIdentifier\n\tcom.example.app.WalletUIExtension\n" + + "\n\n"); + write(new File(extension, "buildSettings.properties"), + "INFOPLIST_FILE = WalletUIExtension/Info.plist\n" + + "INFOPLIST_FILE[config\\=Debug] = WalletUIExtension/Info.plist\n" + + "MARKETING_VERSION = 5.4\n" + + "MARKETING_VERSION[config\\=Debug] = 1.0\n"); + + BuildRequest request = new BuildRequest(); + request.setMainClass("MyApp"); + request.setPackageName("com.example.app"); + request.setVersion("5.4"); + new IPhoneBuilder().stampAppExtensionInfoPlist(extension, request, + IPhoneBuilder.ArchiveContext.of("iphoneos14.4", "Release", "arm64", null)); + + // One physical plist, named by the base setting and by a Debug-qualified one. Under + // Release the reference already resolves to the app's 5.4 and is left; deduplicating on + // the path alone then skipped the Debug pass, and the Debug build off these sources + // shipped $(MARKETING_VERSION) = 1.0. The file cannot be right for both while the + // reference stands, so the literal has to win. + String stamped = new String(Files.readAllBytes(shared.toPath()), "UTF-8"); + assertTrue(stamped, stamped.contains("CFBundleShortVersionString\n\t5.4")); + assertFalse(stamped, stamped.contains("$(MARKETING_VERSION)")); + } +} 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..7e362b66439 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionInfoPlistTest.java @@ -0,0 +1,618 @@ +/* + * 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.assertSame; +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()); + } + + @Test + public void aVersionReferenceFollowsTheConditionalTheArchiveGets() { + // The base matches the app, the device-qualified value does not -- and the qualified one + // is what Xcode uses for this archive, so the extension shipped a version its container + // does not have. + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleShortVersionString\n\t$(MARKETING_VERSION)\n" + + "\tCFBundleName"); + Map settings = new HashMap(); + settings.put("MARKETING_VERSION", "5.4"); + settings.put("MARKETING_VERSION[sdk=iphoneos*]", "5.3"); + + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", null, + IPhoneBuilder.flattenForContext(settings, + IPhoneBuilder.ArchiveContext.of("iphoneos14.4", "Release", "arm64", settings)), + changes); + + assertTrue(out, out.contains("CFBundleShortVersionString\n\t5.4")); + assertTrue(changes.toString(), changes.toString().contains("resolves to '5.3'")); + } + + @Test + public void aConditionalThatMatchesTheAppIsLeftAlone() { + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleShortVersionString\n\t$(MARKETING_VERSION)\n" + + "\tCFBundleName"); + Map settings = new HashMap(); + settings.put("MARKETING_VERSION", "1.0"); + settings.put("MARKETING_VERSION[sdk=iphoneos*]", "5.4"); + + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", null, + IPhoneBuilder.flattenForContext(settings, + IPhoneBuilder.ArchiveContext.of("iphoneos14.4", "Release", "arm64", settings)), + changes); + + // The qualified value is the app's version, so the reference is right as written. + assertTrue(out.contains("$(MARKETING_VERSION)")); + assertFalse(changes.toString().contains("CFBundleShortVersionString")); + } + + @Test + public void aPaddedIdentifierIsNotTheIdentifierItReadsAs() { + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleIdentifier\n\t com.new.app.Ext \n" + + "\tCFBundleName"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", "com.new.app", + NO_SETTINGS, changes); + // A plist parser keeps the padding, so this ships as " com.new.app.Ext " -- an identifier + // Apple refuses, however well it trims. + assertTrue(out, out.contains("$(PRODUCT_BUNDLE_IDENTIFIER)")); + } + + @Test + public void paddingInsideCdataIsNoDifferent() { + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleIdentifier\n\t\n" + + "\tCFBundleName"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", "com.new.app", + NO_SETTINGS, changes); + assertTrue(out, out.contains("$(PRODUCT_BUNDLE_IDENTIFIER)")); + } + + @Test + public void aQualifiedCandidateIsStampedInItsOwnContext() { + java.util.Map settings = new java.util.LinkedHashMap(); + settings.put("MARKETING_VERSION", "5.4"); + settings.put("MARKETING_VERSION[config=Debug]", "1.0"); + IPhoneBuilder.ArchiveContext release = IPhoneBuilder.ArchiveContext.of("iphoneos14.4", + "Release", "arm64", settings); + + // The Debug plist's context, taken from the setting that names it. + IPhoneBuilder.ArchiveContext candidate = IPhoneBuilder.infoPlistCandidateContext( + "INFOPLIST_FILE[config=Debug] = $(CONFIGURATION)/Info.plist", release); + String plist = NO_IDENTITY.replace("CFBundleName", + "CFBundleShortVersionString\n\t$(MARKETING_VERSION)\n" + + "\tCFBundleName"); + List changes = new ArrayList(); + String out = IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", + IPhoneBuilder.flattenForContext(settings, candidate), changes); + + // Judged in the archive's Release context the reference reads as 5.4, already the app's + // version, and is left -- while the build that uses this file expands it to the Debug + // 1.0 and ships a version its container does not have. + assertTrue(out.contains("CFBundleShortVersionString\n\t5.4")); + assertTrue(changes.toString(), changes.toString().contains("resolves to '1.0'")); + + // The contrast, pinned: in the archive's own context the same plist is left untouched, + // so this is the candidate's context doing the work and not some general strictness. + List underRelease = new ArrayList(); + assertTrue(IPhoneBuilder.stampInfoPlistIdentity(plist, "5.4", "5.4", + IPhoneBuilder.flattenForContext(settings, release), underRelease) + .contains("$(MARKETING_VERSION)")); + } + + @Test + public void anUnqualifiedCandidateKeepsTheArchivesContext() { + java.util.Map settings = new java.util.LinkedHashMap(); + IPhoneBuilder.ArchiveContext release = IPhoneBuilder.ArchiveContext.of("iphoneos14.4", + "Release", "arm64", settings); + + // Nothing to narrow: the base INFOPLIST_FILE is the file this archive builds with. + assertSame(release, IPhoneBuilder.infoPlistCandidateContext( + "INFOPLIST_FILE = WalletUIExtension/Info.plist", release)); + assertSame(release, IPhoneBuilder.infoPlistCandidateContext("Info.plist", release)); + } + + @Test + public void everyQualifierGroupReachesTheCandidatesContext() { + java.util.Map settings = new java.util.LinkedHashMap(); + IPhoneBuilder.ArchiveContext release = IPhoneBuilder.ArchiveContext.of("iphoneos14.4", + "Release", "arm64", settings); + + // Cut at the first ']' this read as a Debug candidate on the archive's own SDK, so an + // SDK-qualified helper inside that plist was judged against the device values and left + // as it was. + IPhoneBuilder.ArchiveContext both = IPhoneBuilder.infoPlistCandidateContext( + "INFOPLIST_FILE[config=Debug][sdk=iphonesimulator*] = $(CONFIGURATION)/Info.plist", + release); + assertEquals("Debug", both.configuration); + assertEquals("iphonesimulator*", both.sdk); + + // One group, and the grouped form Xcode also accepts. + assertEquals("Debug", IPhoneBuilder.infoPlistCandidateContext( + "INFOPLIST_FILE[config=Debug] = Debug/Info.plist", release).configuration); + IPhoneBuilder.ArchiveContext commaSeparated = IPhoneBuilder.infoPlistCandidateContext( + "INFOPLIST_FILE[config=Debug,arch=x86_64] = Debug/Info.plist", release); + assertEquals("Debug", commaSeparated.configuration); + assertEquals("x86_64", commaSeparated.arch); + + // And a path that happens to contain a bracket is not a qualifier. + assertSame(release, IPhoneBuilder.infoPlistCandidateContext( + "INFOPLIST_FILE = Wallet[beta]/Info.plist", release)); + } +} 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..08102aad092 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AppExtensionStagingTest.java @@ -0,0 +1,174 @@ +/* + * 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(); + } + } + + @Test + public void anInTreeDirectoryCycleIsRefused() throws Exception { + File extension = tmp.newFolder("dist", "WalletUIExtension"); + File sub = new File(extension, "sub"); + assertTrue(sub.mkdirs()); + // sub/loop -> . escapes nothing, and every walk over the folder follows it until the + // stack ends the build. + Files.createSymbolicLink(new File(sub, "loop").toPath(), extension.toPath()); + + assertTrue(IPhoneBuilder.symlinkEscaping(extension, extension) != null); + } + + @Test + public void anInTreeFileLinkIsStillFine() throws Exception { + File extension = tmp.newFolder("dist2", "WalletUIExtension"); + write(new File(extension, "Info.plist")); + Files.createSymbolicLink(new File(extension, "alias.plist").toPath(), + new File(extension, "Info.plist").toPath()); + assertNull(IPhoneBuilder.symlinkEscaping(extension, extension)); + } + + @Test + public void theFilesystemRootIsNotADeveloperDirectory() throws Exception { + File fakeRoot = tmp.newFolder("fakeroot"); + assertTrue(new File(fakeRoot, "usr/bin").mkdirs()); + assertTrue(new File(fakeRoot, "usr/bin/xcodebuild").createNewFile()); + + // Two levels up from /usr/bin/xcodebuild -- the shim `which xcodebuild` reports -- is the + // root, which has usr/bin and is not a developer directory. DEVELOPER_DIR=/ makes xcrun + // fail, the SDK name falls back to the unversioned "iphoneos", and an exact + // [sdk=iphoneosNN] condition is then decided by map order. + assertFalse(IPhoneBuilder.isDeveloperDir(fakeRoot)); + + File developer = tmp.newFolder("Xcode.app-Contents-Developer"); + assertTrue(new File(developer, "usr/bin").mkdirs()); + assertTrue(new File(developer, "usr/bin/xcodebuild").createNewFile()); + assertTrue(new File(developer, "Platforms").mkdirs()); + assertTrue(IPhoneBuilder.isDeveloperDir(developer)); + assertFalse(IPhoneBuilder.isDeveloperDir(null)); + } +}