diff --git a/maven/build-engine/src/main/java/com/codename1/maven/AppBuilder.java b/maven/build-engine/src/main/java/com/codename1/maven/AppBuilder.java
index 6dffd18a586..ff06e6c3ad0 100644
--- a/maven/build-engine/src/main/java/com/codename1/maven/AppBuilder.java
+++ b/maven/build-engine/src/main/java/com/codename1/maven/AppBuilder.java
@@ -634,7 +634,8 @@ private boolean isIOSDeviceBuild() {
* property. This is where a profile-kind/distribution-method mismatch is decided, since
* a library can still change the method after the early pass has run.
*/
- private void applyIOSProvisioningPreflight(Properties mergedSettings) throws BuildFailureException {
+ private void applyIOSProvisioningPreflight(Properties mergedSettings, File stagedJar)
+ throws BuildFailureException {
if (!isIOSDeviceBuild()) {
return;
}
@@ -647,7 +648,10 @@ private void applyIOSProvisioningPreflight(Properties mergedSettings) throws Bui
// the ios module under Maven, src/ios under Gradle, native/ios under Ant.
report(IOSProvisioningPreflight.checkAppExtensions(mergedSettings, release,
host.layout().iosAppExtensionsDir().getParentFile()));
- report(IOSProvisioningPreflight.checkGeneratedExtensions(mergedSettings, release));
+ // CN1Widgets is generated whenever a class names com.codename1.surfaces, so the staged
+ // classes decide it, the same scan the builder makes.
+ report(IOSProvisioningPreflight.checkGeneratedExtensions(mergedSettings, release,
+ IOSProvisioningPreflight.referencesSurfaces(stagedJar)));
report(IOSProvisioningPreflight.checkContinuitySync(mergedSettings, release));
}
@@ -2016,7 +2020,7 @@ private void createAntProject() throws IOException, LibraryPropertiesException,
// Same reason, for the same reason: a CN1Lib can supply ios.*.distributionMethod, so the
// profile's kind is compared against the export method only now that those properties
// have been merged -- an early refusal would reject a build the merge was about to fix.
- applyIOSProvisioningPreflight(cn1SettingsProps);
+ applyIOSProvisioningPreflight(cn1SettingsProps, jarWithDependencies);
// Validate the exact settings and classes sent to every cloud/local target before
// invoking the build client. A renamed entry point must not cost a cloud build.
diff --git a/maven/build-engine/src/main/java/com/codename1/maven/IOSProvisioningPreflight.java b/maven/build-engine/src/main/java/com/codename1/maven/IOSProvisioningPreflight.java
index 199e459fb83..447d43460a8 100644
--- a/maven/build-engine/src/main/java/com/codename1/maven/IOSProvisioningPreflight.java
+++ b/maven/build-engine/src/main/java/com/codename1/maven/IOSProvisioningPreflight.java
@@ -434,8 +434,9 @@ static List checkAppExtensions(Properties settings, boolean release, Fi
* build and an Xcode failure minutes into a cloud one, naming a bundle id the developer never
* typed.
*
- * Only the document provider is checked here. The other generated extensions (the Wallet
- * pair, CN1Widgets, CN1MatterSetup) have exactly this shape and could join the table below,
+ *
The document provider is checked here, and CN1Widgets by
+ * {@link #checkGeneratedExtensions(Properties, boolean, boolean)}. The other generated
+ * extensions (the Wallet pair, CN1MatterSetup) have exactly this shape and could join them,
* but each one turns a late cloud failure into an early local one for projects that build
* today, so they are left for a change that can be verified against real projects rather than
* folded in as a side effect.
@@ -463,6 +464,221 @@ static List checkAppExtensions(Properties settings, boolean release, Fi
/// feature behind a hint the developer set deliberately, having first
/// obtained a capability Apple grants by hand.
static List checkGeneratedExtensions(Properties settings, boolean release) {
+ return checkGeneratedExtensions(settings, release, false);
+ }
+
+ /// The same, plus the CN1Widgets WidgetKit target when `usesSurfaces` says the app references
+ /// `com.codename1.surfaces` (see [#referencesSurfaces]).
+ ///
+ /// Unlike the document provider, nothing in the settings enables CN1Widgets: the builder
+ /// generates it whenever a class names the surfaces package, so the caller has to scan the
+ /// staged classes for the same signal. Issue #6008 is the real-project evidence the note above
+ /// asked for -- an app supplying only its own profile ran a whole cloud build to have Xcode
+ /// report that profile's App ID "does not match the bundle ID .CN1Widgets", a
+ /// message that names neither the extension's hint nor the Certificate Wizard.
+ ///
+ /// No wildcard exemption, for the reason the document provider gives: the widget target
+ /// always declares the App Group its timeline travels through, and a wildcard App ID cannot
+ /// carry App Groups.
+ static List checkGeneratedExtensions(Properties settings, boolean release,
+ boolean usesSurfaces) {
+ List problems = checkDocumentProvider(settings, release);
+ if (settings == null || !usesSurfaces
+ || "false".equals(trimmed(settings.getProperty("codename1.arg.ios.surfaces.extension")))) {
+ // ios.surfaces.extension=false skips the whole iOS lowering: no target, nothing to sign.
+ return problems;
+ }
+ String packageName = trimmed(settings.getProperty("codename1.packageName"));
+ if (packageName == null || packageName.isEmpty()) {
+ return problems;
+ }
+ String name = "CN1Widgets";
+ if (hasOwnProfileSetting(settings, name, release)) {
+ // Only a profile given as a local PATH can be read; a hosted URL or base64 blob is
+ // judged by the server. The App Group is not checked here: it comes from
+ // surfaces.json, which is a resource of the app rather than a setting.
+ Profile ext = extensionProfileFromPath(settings, name, release);
+ String bundleId = generatedExtensionBundleId(settings, "ios.surfaces", packageName,
+ name);
+ if (ext != null && ext.applicationIdentifier != null
+ && !profileCoversBundleId(ext.applicationIdentifier, bundleId)) {
+ problems.add(new Problem("The provisioning profile configured for the " + name
+ + " extension (\"" + ext.name + "\", App ID "
+ + appIdPattern(ext.applicationIdentifier) + ") cannot sign its bundle ID \""
+ + bundleId + "\". Generate a profile for the App ID " + bundleId
+ + " (with App Groups) and configure that one instead; the Certificate "
+ + "Wizard does this for you.", true));
+ }
+ return problems;
+ }
+ Profile appProfile = appProfile(settings, release);
+ if (appProfile == null || appProfile.applicationIdentifier == null) {
+ // check() reports a missing or unreadable app profile as itself.
+ return problems;
+ }
+ String bundleId = generatedExtensionBundleId(settings, "ios.surfaces", packageName, name);
+ String buildType = release ? "release" : "debug";
+ problems.add(new Problem("This app uses com.codename1.surfaces (home-screen widgets or "
+ + "live activities), so the iOS build adds the " + name + " WidgetKit extension, "
+ + "and no provisioning profile is configured for it. The profile this build signs "
+ + "with (\"" + appProfile.name + "\", App ID "
+ + appIdPattern(appProfile.applicationIdentifier) + ") cannot sign it: Xcode would "
+ + "fail with: Provisioning profile \"" + appProfile.name + "\" has app ID \""
+ + appIdPattern(appProfile.applicationIdentifier) + "\", which does not match the "
+ + "bundle ID \"" + bundleId + "\".\n"
+ + "Create an App ID for " + bundleId + " with the App Groups capability in the "
+ + "Apple Developer portal, generate a provisioning profile for it against the same "
+ + "certificate as the app, and supply it in one of these ways:\n"
+ + " 1. Set codename1.ios." + buildType + ".appext." + name + ".provision="
+ + "{path to the .mobileprovision} in codenameone_settings.properties.\n"
+ + " 2. Host the profile and name its URL in codename1.arg.ios.appext." + name
+ + ".provisioningURL.\n"
+ + "The Certificate Wizard does all of this for you. To build without widgets, set "
+ + "codename1.arg.ios.surfaces.extension=false.", true));
+ return problems;
+ }
+
+ /// The extension profile configured for this build type as a local path, or null when there
+ /// is none or it cannot be read.
+ private static Profile extensionProfileFromPath(Properties settings, String name,
+ boolean release) {
+ String qualifier = release ? "release" : "debug";
+ String[] keys = {
+ "codename1.ios." + qualifier + ".appext." + name + ".provision",
+ "codename1.ios.appext." + name + ".provision"
+ };
+ for (String key : keys) {
+ String value = trimmed(settings.getProperty(key));
+ if (value == null || value.isEmpty()) {
+ continue;
+ }
+ String resolved = resolvePlaceholders(value, settings);
+ File file = new File(resolved);
+ if (resolved.indexOf("${") >= 0 || !file.isFile()) {
+ continue;
+ }
+ try {
+ return parse(readFile(file));
+ } catch (Exception ex) {
+ return null;
+ }
+ }
+ return null;
+ }
+
+ /// Whether any class in `jar` REFERENCES a type in `com.codename1.surfaces` -- the signal
+ /// IPhoneBuilder sets `usesSurfaces` from, which is its class-reference callback. So this reads
+ /// the constant pool and counts only CONSTANT_Class names and descriptors (including those of
+ /// the class's own fields and methods), never a string literal that merely spells the package: a false match here is
+ /// a fatal refusal. The staged jar carries the app and its cn1libs but not the core.
+ ///
+ /// A missing surfaces.json is deliberately not reported here: the builder refuses that with
+ /// its own message, and this check cannot read the app's resources.
+ ///
+ /// A jar or class that cannot be read answers false: this is a preflight, and the server
+ /// refuses the same build before Xcode runs, so a miss costs a late failure, never a wrong one.
+ static boolean referencesSurfaces(File jar) {
+ if (jar == null || !jar.isFile()) {
+ return false;
+ }
+ try (ZipFile zip = new ZipFile(jar)) {
+ Enumeration extends ZipEntry> entries = zip.entries();
+ while (entries.hasMoreElements()) {
+ ZipEntry entry = entries.nextElement();
+ if (entry.isDirectory() || !entry.getName().endsWith(".class")) {
+ continue;
+ }
+ try (InputStream in = zip.getInputStream(entry)) {
+ if (classReferencesSurfaces(readAll(in))) {
+ return true;
+ }
+ }
+ }
+ } catch (IOException ex) {
+ return false;
+ }
+ return false;
+ }
+
+ private static final String SURFACES_PACKAGE = "com/codename1/surfaces/";
+
+ /// Walks one class file's constant pool. Malformed input answers false.
+ static boolean classReferencesSurfaces(byte[] b) {
+ try {
+ if (b.length < 10 || (b[0] & 0xff) != 0xCA || (b[1] & 0xff) != 0xFE) {
+ return false;
+ }
+ int count = u2(b, 8);
+ String[] utf8 = new String[count];
+ int[] classIdx = new int[count];
+ boolean[] literal = new boolean[count];
+ int nClass = 0;
+ int p = 10;
+ for (int i = 1; i < count; i++) {
+ int tag = b[p] & 0xff;
+ switch (tag) {
+ case 1: {
+ int len = u2(b, p + 1);
+ utf8[i] = new String(b, p + 3, len, StandardCharsets.UTF_8);
+ p += 3 + len;
+ break;
+ }
+ case 7:
+ classIdx[nClass++] = u2(b, p + 1);
+ p += 3;
+ break;
+ case 8:
+ literal[u2(b, p + 1)] = true;
+ p += 3;
+ break;
+ case 16: case 19: case 20:
+ p += 3;
+ break;
+ case 15:
+ p += 4;
+ break;
+ case 3: case 4: case 9: case 10: case 11: case 12: case 17: case 18:
+ p += 5;
+ break;
+ case 5: case 6:
+ p += 9;
+ i++;
+ break;
+ default:
+ return false;
+ }
+ }
+ for (int i = 0; i < nClass; i++) {
+ String name = utf8[classIdx[i]];
+ // An array class is spelled as a descriptor, "[Lcom/...;".
+ if (name != null && (name.startsWith(SURFACES_PACKAGE)
+ || name.indexOf("L" + SURFACES_PACKAGE) >= 0)) {
+ return true;
+ }
+ }
+ // Every descriptor -- of a referenced member, and of this class's own fields and
+ // methods, whose descriptors sit in the pool but are named from field_info and
+ // method_info rather than from a pool entry -- is a UTF-8 entry no string literal
+ // points at. A literal that happens to be shaped like a descriptor is skipped too.
+ // That also counts a generic Signature naming the package (a List of a surfaces type);
+ // such an app is using surfaces in every sense that matters, and the server-side
+ // refusal is the same either way.
+ for (int i = 1; i < count; i++) {
+ if (utf8[i] != null && !literal[i] && utf8[i].indexOf("L" + SURFACES_PACKAGE) >= 0) {
+ return true;
+ }
+ }
+ return false;
+ } catch (RuntimeException ex) {
+ return false;
+ }
+ }
+
+ private static int u2(byte[] b, int p) {
+ return ((b[p] & 0xff) << 8) | (b[p + 1] & 0xff);
+ }
+
+ private static List checkDocumentProvider(Properties settings, boolean release) {
List problems = new ArrayList();
if (settings == null) {
return problems;
@@ -507,7 +723,8 @@ static List checkGeneratedExtensions(Properties settings, boolean relea
// neither is something to refuse a build over here.
return problems;
}
- String bundleId = generatedExtensionBundleId(settings, packageName, name);
+ String bundleId = generatedExtensionBundleId(settings, "ios.documentProvider", packageName,
+ name);
if (profileCoversBundleId(appProfile.applicationIdentifier, bundleId)) {
// No wildcard exemption here, unlike the folder-driven check above. A wildcard App ID
// does cover the bundle id, but this extension always declares
@@ -588,7 +805,7 @@ private static Problem appGroupProblem(Properties settings, String name, boolean
// report, not this one's.
return null;
}
- String bundleId = generatedExtensionBundleId(settings,
+ String bundleId = generatedExtensionBundleId(settings, "ios.documentProvider",
trimmed(settings.getProperty("codename1.packageName")), name);
if (!profileCoversBundleId(profile.applicationIdentifier, bundleId)) {
// A profile for a DIFFERENT App ID that happens to grant the same group. It is
@@ -624,9 +841,9 @@ private static Problem appGroupProblem(Properties settings, String name, boolean
/// the profile against the default would reject the profile issued for the real identifier
/// and accept one issued for a target that is not being built -- the failure moving to Xcode,
/// after the archive, which is what this whole class exists to prevent.
- private static String generatedExtensionBundleId(Properties settings, String packageName,
- String name) {
- String override = settings.getProperty("codename1.arg.ios.documentProvider.buildSettings."
+ private static String generatedExtensionBundleId(Properties settings, String hintPrefix,
+ String packageName, String name) {
+ String override = settings.getProperty("codename1.arg." + hintPrefix + ".buildSettings."
+ "PRODUCT_BUNDLE_IDENTIFIER");
if (override != null) {
String resolved = resolvePlaceholders(override.trim(), settings);
@@ -1492,13 +1709,17 @@ private static int indexOf(byte[] haystack, byte[] needle, int from) {
private static byte[] readFile(File f) throws IOException {
try (InputStream is = new FileInputStream(f)) {
- ByteArrayOutputStream baos = new ByteArrayOutputStream();
- byte[] buffer = new byte[8192];
- int amount;
- while ((amount = is.read(buffer)) > -1) {
- baos.write(buffer, 0, amount);
- }
- return baos.toByteArray();
+ return readAll(is);
+ }
+ }
+
+ private static byte[] readAll(InputStream is) throws IOException {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ byte[] buffer = new byte[8192];
+ int amount;
+ while ((amount = is.read(buffer)) > -1) {
+ baos.write(buffer, 0, amount);
}
+ return baos.toByteArray();
}
}
diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/IOSAppExtensionSigningPreflightTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/IOSAppExtensionSigningPreflightTest.java
index 5099346646c..995e767e452 100644
--- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/IOSAppExtensionSigningPreflightTest.java
+++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/IOSAppExtensionSigningPreflightTest.java
@@ -567,4 +567,161 @@ public void appIdMatchingFollowsApplesRule() {
assertTrue(IOSProvisioningPreflight.profileCoversBundleId(null, "com.example.app"));
assertTrue(IOSProvisioningPreflight.profileCoversBundleId("", "com.example.app"));
}
+
+ // ---- CN1Widgets, generated whenever a class names com.codename1.surfaces (#6008) ----
+
+ private static List checkWidgets(Properties p,
+ boolean usesSurfaces) {
+ return IOSProvisioningPreflight.checkGeneratedExtensions(p, true, usesSurfaces);
+ }
+
+ @Test
+ public void surfacesWithOnlyTheAppProfileAreRefused() throws Exception {
+ Properties p = settings(profile("PROD", "ABCD1234.com.example.app", GROUP));
+ List problems = checkWidgets(p, true);
+ assertEquals(1, problems.size());
+ assertTrue(problems.get(0).fatal);
+ String message = problems.get(0).message;
+ assertTrue(message, message.contains("com.example.app.CN1Widgets"));
+ assertTrue(message, message.contains("App Groups"));
+ assertTrue(message, message.contains("codename1.ios.release.appext.CN1Widgets.provision"));
+ assertTrue(message, message.contains("ios.appext.CN1Widgets.provisioningURL"));
+ assertTrue(message, message.contains("Certificate Wizard"));
+ }
+
+ @Test
+ public void surfacesWithAWidgetProfileAreAccepted() throws Exception {
+ Properties p = settings(profile("PROD", "ABCD1234.com.example.app", GROUP));
+ p.setProperty("codename1.ios.appext.CN1Widgets.provision",
+ profile("Widgets", "ABCD1234.com.example.app.CN1Widgets", GROUP).getAbsolutePath());
+ assertTrue(checkWidgets(p, true).isEmpty());
+
+ Properties hinted = settings(profile("PROD2", "ABCD1234.com.example.app", GROUP));
+ hinted.setProperty("codename1.arg.ios.appext.CN1Widgets.provisioningURL",
+ "https://example.com/widgets.mobileprovision");
+ assertTrue(checkWidgets(hinted, true).isEmpty());
+ }
+
+ @Test
+ public void anAppThatNeverNamesSurfacesIsNotAskedForAWidgetProfile() throws Exception {
+ Properties p = settings(profile("PROD", "ABCD1234.com.example.app", GROUP));
+ assertTrue(checkWidgets(p, false).isEmpty());
+ // ios.surfaces.extension=false skips the target, so there is nothing to sign either.
+ p.setProperty("codename1.arg.ios.surfaces.extension", "false");
+ assertTrue(checkWidgets(p, true).isEmpty());
+ }
+
+ @Test
+ public void automaticSigningTargetsAreNotChecked() {
+ // ios-source is an Xcode project the developer signs (automatically or otherwise), and the
+ // simulator signs nothing: the preflight never runs for either.
+ assertFalse(IOSProvisioningPreflight.appliesTo("ios", "ios-source"));
+ assertFalse(IOSProvisioningPreflight.appliesTo("ios", "ios-simulator"));
+ assertTrue(IOSProvisioningPreflight.appliesTo("ios", "ios-device"));
+ }
+
+ @Test
+ public void theSurfacesScanCountsClassReferencesOnly() throws Exception {
+ // A CONSTANT_Class naming the package: the app uses it.
+ assertTrue(IOSProvisioningPreflight.classReferencesSurfaces(
+ classFile(7, "com/codename1/surfaces/ExternalSurfaces")));
+ // A field or method descriptor naming it (NameAndType's descriptor).
+ assertTrue(IOSProvisioningPreflight.classReferencesSurfaces(
+ classFile(12, "(Lcom/codename1/surfaces/SurfaceKind;)V")));
+ // A descriptor of the class's OWN method, named from method_info rather than from the
+ // pool: implementing a getter that returns a surfaces type is a use.
+ assertTrue(IOSProvisioningPreflight.classReferencesSurfaces(
+ classFile(1, "()Lcom/codename1/surfaces/spi/SurfaceBridge;")));
+ // An array class.
+ assertTrue(IOSProvisioningPreflight.classReferencesSurfaces(
+ classFile(7, "[Lcom/codename1/surfaces/SurfaceKind;")));
+ // A string literal that only spells the package is not a use, and a false match here
+ // would refuse a build that does not generate the extension at all.
+ assertFalse(IOSProvisioningPreflight.classReferencesSurfaces(
+ classFile(8, "com/codename1/surfaces/ExternalSurfaces")));
+ assertFalse(IOSProvisioningPreflight.classReferencesSurfaces(
+ classFile(7, "com/example/app/Main")));
+ // Malformed input answers false rather than throwing.
+ assertFalse(IOSProvisioningPreflight.classReferencesSurfaces(new byte[] {1, 2, 3}));
+ byte[] truncated = classFile(7, "com/codename1/surfaces/ExternalSurfaces");
+ byte[] cut = new byte[truncated.length - 20];
+ System.arraycopy(truncated, 0, cut, 0, cut.length);
+ assertFalse(IOSProvisioningPreflight.classReferencesSurfaces(cut));
+ }
+
+ @Test
+ public void theSurfacesScanReadsTheStagedJar() throws Exception {
+ File uses = tmp.newFile("uses.jar");
+ writeJar(uses, "com/example/app/Main.class",
+ classFile(7, "com/codename1/surfaces/ExternalSurfaces"));
+ assertTrue(IOSProvisioningPreflight.referencesSurfaces(uses));
+
+ File not = tmp.newFile("not.jar");
+ writeJar(not, "com/example/app/Main.class",
+ classFile(8, "com/codename1/surfaces/ExternalSurfaces"));
+ assertFalse(IOSProvisioningPreflight.referencesSurfaces(not));
+
+ File corrupt = tmp.newFile("corrupt.jar");
+ write(corrupt, "not a zip");
+ assertFalse(IOSProvisioningPreflight.referencesSurfaces(corrupt));
+ assertFalse(IOSProvisioningPreflight.referencesSurfaces(new File(tmp.getRoot(), "none.jar")));
+ }
+
+ @Test
+ public void aWidgetProfileForAnotherAppIdIsRefused() throws Exception {
+ // Most plausibly the app's own profile configured for the extension by mistake.
+ File app = profile("PROD", "ABCD1234.com.example.app", GROUP);
+ Properties p = settings(app);
+ p.setProperty("codename1.ios.appext.CN1Widgets.provision", app.getAbsolutePath());
+ List problems = checkWidgets(p, true);
+ assertEquals(1, problems.size());
+ assertTrue(problems.get(0).fatal);
+ assertTrue(problems.get(0).message,
+ problems.get(0).message.contains("com.example.app.CN1Widgets"));
+ }
+
+ /// A minimal class file whose constant pool holds one UTF-8 entry and one entry of `tag`
+ /// pointing at it (1: none): 7 CONSTANT_Class, 8 CONSTANT_String, 12 NameAndType (as its descriptor).
+ private static byte[] classFile(int tag, String utf8) throws Exception {
+ java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream();
+ java.io.DataOutputStream out = new java.io.DataOutputStream(bytes);
+ out.writeInt(0xCAFEBABE);
+ out.writeShort(0);
+ out.writeShort(52);
+ out.writeShort(tag == 12 ? 4 : tag == 1 ? 2 : 3);
+ out.writeByte(1);
+ out.writeUTF(utf8);
+ if (tag == 1) {
+ // Only the UTF-8 entry, as a member descriptor sits in the pool.
+ } else if (tag == 12) {
+ out.writeByte(1);
+ out.writeUTF("run");
+ out.writeByte(12);
+ out.writeShort(2);
+ out.writeShort(1);
+ } else {
+ out.writeByte(tag);
+ out.writeShort(1);
+ }
+ out.writeShort(0x21);
+ out.writeShort(0);
+ out.writeShort(0);
+ out.writeShort(0);
+ out.writeShort(0);
+ out.writeShort(0);
+ out.writeShort(0);
+ out.flush();
+ return bytes.toByteArray();
+ }
+
+ private static void writeJar(File jar, String entry, byte[] contents) throws Exception {
+ ZipOutputStream zip = new ZipOutputStream(new FileOutputStream(jar));
+ try {
+ zip.putNextEntry(new ZipEntry(entry));
+ zip.write(contents);
+ zip.closeEntry();
+ } finally {
+ zip.close();
+ }
+ }
}