diff --git a/CodenameOne/src/com/codename1/annotations/buildhints/Ios.java b/CodenameOne/src/com/codename1/annotations/buildhints/Ios.java index 73a05db6a88..f51ca64117a 100644 --- a/CodenameOne/src/com/codename1/annotations/buildhints/Ios.java +++ b/CodenameOne/src/com/codename1/annotations/buildhints/Ios.java @@ -173,14 +173,6 @@ @Hint(valuePattern = "auto|modern|ios7|legacy") ThemeMode themeMode() default ThemeMode.DEFAULT; - /// true/false (defaults to true). Enables iOS UIScene lifecycle support. - /// UIScene lets iOS manage one or more app UI sessions independently, - /// improving lifecycle handling in modern iOS versions. Apple has indicated - /// UIScene will be required starting with iOS 27, so this is now on by - /// default; set the flag to `false` only if you need to temporarily fall back - /// to the legacy `UIApplicationDelegate` lifecycle. - Toggle uiscene() default Toggle.DEFAULT; - /// Allows intercepting a URL call using the syntax `urlPrefix` String urlScheme() default ""; } diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.h b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.h index f5256126d5d..ad2d89c66b6 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.h +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.h @@ -21,9 +21,7 @@ * need additional information or have any questions. */ #import "CN1AppleUI.h" -#ifdef CN1_USE_UI_SCENE #import -#endif //#define CN1_INCLUDE_NOTIFICATIONS #ifdef CN1_INCLUDE_NOTIFICATIONS #import @@ -51,11 +49,17 @@ } -@property (nonatomic, retain) IBOutlet UIWindow *window; +// No IBOutlet on either: nothing loads a nib into this class any more. MainWindow.xib wired +// both, and it went away with NSMainNibFile -- UIApplicationMain creates the delegate from the +// class name, the scene delegate hands it the window, and cn1EnsureViewController builds the +// view controller. +@property (nonatomic, retain) UIWindow *window; -@property (nonatomic, retain) IBOutlet CodenameOne_GLViewController *viewController; +@property (nonatomic, retain) CodenameOne_GLViewController *viewController; -#ifdef CN1_USE_UI_SCENE +// Called by CodenameOne_GLSceneDelegate, which is the only lifecycle there is: UIKit +// creates the window from the scene, and these are the app-level steps the delegate used +// to run itself when it owned the window. - (void)cn1InstallRootViewControllerIntoWindow:(UIWindow *)window; - (void)cn1ApplicationWillResignActive; - (void)cn1ApplicationDidEnterBackground; @@ -66,6 +70,5 @@ url:(NSURL *)url sourceApplication:(NSString *)sourceApplication annotation:(id)annotation; -#endif @end diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m index 53238a95f40..180019d33d8 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m @@ -24,9 +24,7 @@ #if !TARGET_OS_WATCH #import "CodenameOne_GLAppDelegate.h" -#ifdef CN1_USE_UI_SCENE #import "CodenameOne_GLSceneDelegate.h" -#endif #import "CN1JailbreakDetector.h" #include "xmlvm.h" #import @@ -586,9 +584,9 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( cn1_debugger_start(); #endif [self cn1EnsureViewController]; -#ifndef CN1_USE_UI_SCENE - [self cn1InstallRootViewControllerIntoWindow:self.window]; -#endif + // The root view controller is installed by CodenameOne_GLSceneDelegate, not here: under + // the scene lifecycle self.window is nil at this point, because UIKit has not connected + // a scene yet. NSURL *url = (NSURL *)[launchOptions valueForKey:UIApplicationLaunchOptionsURLKey]; [self cn1StoreAppArgForURL:url]; if (@available(iOS 8, *)) { @@ -767,20 +765,18 @@ - (BOOL)application:(UIApplication *)application willFinishLaunchingWithOptions: return YES; } -#ifdef CN1_USE_UI_SCENE - (UISceneConfiguration *)application:(UIApplication *)application configurationForConnectingSceneSession:(UISceneSession *)connectingSceneSession options:(UISceneConnectionOptions *)options API_AVAILABLE(ios(13.0)) { UISceneConfiguration *sceneConfiguration = [UISceneConfiguration configurationWithName:@"Default Configuration" sessionRole:connectingSceneSession.role]; sceneConfiguration.delegateClass = [CodenameOne_GLSceneDelegate class]; return sceneConfiguration; } -#endif -// Compiled for universal links OR intents OR continuity: without the second and third conditions -// a Spotlight tap, or a handoff from the user's other device, on a legacy-lifecycle build -// (ios.uiscene=false) would silently do nothing, since the scene delegate is what routes this on -// a default build. Continuity was added here for exactly the reason intents was: the branch it -// needs inside cn1ContinueUserActivity: is compiled, and on a legacy build nothing ever calls it. +// Compiled for universal links OR intents OR continuity. The scene delegate is what routes this +// on a live app, so this app-level callback is the path UIKit takes when it hands the activity +// to the application rather than to a scene -- a Spotlight tap or a handoff from the user's +// other device. Intents and continuity are in the condition because the branch each needs inside +// cn1ContinueUserActivity: has to be compiled for that call to do anything. #if defined(CN1_HANDLE_UNIVERSAL_LINKS) || defined(CN1_USE_INTENTS) \ || defined(CN1_USE_CONTINUITY) // https://developer.apple.com/documentation/uikit/core_app/allowing_apps_and_websites_to_link_to_your_content?language=objc diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLSceneDelegate.h b/Ports/iOSPort/nativeSources/CodenameOne_GLSceneDelegate.h index 819ac14f5a8..62d5c149d0f 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLSceneDelegate.h +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLSceneDelegate.h @@ -23,11 +23,9 @@ #import "CN1AppleUI.h" #import "CodenameOne_GLAppDelegate.h" -#ifdef CN1_USE_UI_SCENE API_AVAILABLE(ios(13.0)) @interface CodenameOne_GLSceneDelegate : UIResponder @property (nonatomic, retain) UIWindow *window; @end -#endif diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLSceneDelegate.m b/Ports/iOSPort/nativeSources/CodenameOne_GLSceneDelegate.m index 278e82f6c44..72beb02ba48 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLSceneDelegate.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLSceneDelegate.m @@ -35,7 +35,6 @@ extern void CN1MacWindowDeliverVisibility(int windowId, BOOL shown); #endif -#ifdef CN1_USE_UI_SCENE @implementation CodenameOne_GLSceneDelegate @synthesize window=_window; @@ -351,7 +350,6 @@ - (void)scene:(UIScene *)scene continueUserActivity:(NSUserActivity *)userActivi } @end -#endif #else // Compiled out on watchOS: this file is OpenGL ES / Metal / UIKit-only and the watch diff --git a/Ports/iOSPort/nativeSources/MainWindow.xib b/Ports/iOSPort/nativeSources/MainWindow.xib deleted file mode 100644 index 972a0c4d32e..00000000000 --- a/Ports/iOSPort/nativeSources/MainWindow.xib +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Ports/iOSPort/nativeSources/MainWindowMETAL.xib b/Ports/iOSPort/nativeSources/MainWindowMETAL.xib deleted file mode 100644 index 972a0c4d32e..00000000000 --- a/Ports/iOSPort/nativeSources/MainWindowMETAL.xib +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/developer-guide/Working-With-iOS.asciidoc b/docs/developer-guide/Working-With-iOS.asciidoc index ec9872b3fd0..801bd7a5d9e 100644 --- a/docs/developer-guide/Working-With-iOS.asciidoc +++ b/docs/developer-guide/Working-With-iOS.asciidoc @@ -21,19 +21,31 @@ If you've access to a Mac, connect the device, open Xcode, and use the device ex [[section-ios-launch-screen]] [[ios-launch-storyboard]] -=== Launch screen storyboard best practices +=== Launch screen best practices -Launch screen storyboards are the default approach for Codename One iOS builds. Apple requires a storyboard-based launch experience for modern devices, so the legacy screenshot generator was removed in favor of a single adaptive layout. You can still opt back into the old behavior by setting the `ios.generateSplashScreens=true` build hint, but it's best to use a storyboard unless your use case can't be expressed with Auto Layout. +Every iOS build declares a launch screen. Apps linked with the iOS 27 SDK or later are rejected unless the bundle declares one of `UILaunchStoryboardName`, `UILaunchStoryboards`, `UILaunchScreen` or `UILaunchScreens`, and `UIRequiresFullScreen` isn't a substitute for any of them. The build checks the finished `Info.plist` and fails if none of the four is there, so an `ios.plistInject` that removes the generated key stops the build rather than producing an archive the App Store refuses. + +What the build declares by default is `UILaunchScreen`: the system background color, which follows light and dark mode on its own, with `Launch.Foreground.png` centered on it. That's deliberate rather than a simplification. Every Codename One app runs on the UIScene lifecycle, and SplashBoard doesn't render a launch *storyboard* for a scene-based app -- it animates from a black frame instead. Since iOS prefers the storyboard whenever both keys are present, declaring one would mean a black launch. + +You can still take the storyboard, and the build still ships `LaunchScreen.storyboard` for you to point at. Declare the key yourself, which overrides the generated one: + +[source] +---- +codename1.arg.ios.plistInject=UILaunchStoryboardNameLaunchScreen +---- + +Do that only if you've verified the result on the devices you ship to. The same applies to `UILaunchStoryboards` and `UILaunchScreens`: declare either one through `ios.plistInject` and the build leaves your launch experience alone. + +The `ios.generateSplashScreens`, `ios.uiscene` and `ios.launchStoryboardName` build hints have been removed, and a build that still sets one fails with a message explaining what replaced it. The first named the pre-storyboard `Default*.png` generator, which iOS stopped using long ago; the second selected the legacy `UIApplicationDelegate` lifecycle, which Apple no longer permits; the third named the storyboard for a key only that legacy lifecycle emitted. ==== Key files -The build server provides a minimal launch storyboard automatically. Customize it by adding any of the following files under your project's `ios/src/main/resources` directory: +The build provides the default launch screen automatically. Customize it by adding either of the following files under your project's `ios/src/main/resources` directory: -. `Launch.Foreground.png` - Shown in the center of the screen instead of your app icon. -. `Launch.Background.png` - Drawn behind the content to provide a color or illustration. -. `LaunchScreen.storyboard` - A custom storyboard created in Xcode that replaces the default layout entirely. +. `Launch.Foreground.png` - Shown in the center of the screen instead of your app icon. Used by the default `UILaunchScreen`. +. `Launch.Background.png` - Drawn behind the content to provide a color or illustration. Read by `LaunchScreen.storyboard`, so it only applies if you opt into the storyboard as shown above. -IMPORTANT: Make sure to add the `ios.multitasking=true` build hint or your launch storyboard won't be used. +You can also replace `LaunchScreen.storyboard` itself with a custom storyboard created in Xcode. ==== Designing a flexible Layout @@ -58,14 +70,14 @@ The default storyboard expects PNG assets with the following characteristics. Al |Provide optional `Launch.Foreground@2x.png` (304×304) and `Launch.Foreground@3x.png` (456×456) for sharper output. Use transparency to let the background show through. |`Launch.Background.png` -|Full-screen backdrop +|Full-screen backdrop, storyboard only |1024×1024 |Supply complementary `Launch.Background@2x.png` (2048×2048) and `Launch.Background@3x.png` (3072×3072) if you rely on artwork instead of a flat color. Keep file sizes small (<2 MB) to avoid slowing startup. |`LaunchScreen.storyboard` |Complete custom layout |N/A -|Target iOS 12.0 and later, enable Auto Layout, and include constraints for every view. Avoid timers or code connections. +|Only used if you declare `UILaunchStoryboardName` through `ios.plistInject`. Enable Auto Layout and include constraints for every view. Avoid timers or code connections. |=== ==== Testing changes diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java index 4346828a792..31602f89910 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java @@ -516,14 +516,6 @@ static void register(List h) { .def("false") .platform("ios")); - h.add(new Hint("ios.generateSplashScreens") - .group(HintGroup.IOS) - .type(HintType.BOOLEAN) - .def("false") - .platform("ios") - .doc("Boolean true/false defaults to false. Enables legacy generation of splash screen images " - + "instead of the current launch storyboards.")); - h.add(new Hint("ios.glAppDelegateBody") .group(HintGroup.IOS) .type(HintType.STRING) @@ -654,12 +646,6 @@ static void register(List h) { .def("true") .platform("ios")); - h.add(new Hint("ios.launchStoryboardName") - .group(HintGroup.IOS) - .type(HintType.STRING) - .def("LaunchScreen") - .platform("ios")); - h.add(new Hint("ios.locationUsageDescription") .group(HintGroup.IOS) .type(HintType.STRING) 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 4437fb16d45..ea44ca07260 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 @@ -140,6 +140,16 @@ public class IPhoneBuilder extends Executor { /// not go through getDeploymentTarget(). private String sdkDeploymentFloor; + /// The major version of the iOS SDK this build links against, or -1 when it cannot be + /// told. Read once in build(); the launch-metadata rules below are conditional on it + /// because Apple's are: an SDK 26 bundle is unaffected by either of them. + private int iosSdkMajor = -1; + + /// The first iOS SDK that requires a launch screen and the UIScene lifecycle of every + /// app linked against it. See iOS & iPadOS 27 release notes, UIKit items 168247372 + /// (launch screen) and 141837548 (scene lifecycle), and TN3187. + static final int FIRST_SDK_REQUIRING_LAUNCH_METADATA = 27; + // StringBuilder used for constructing ruby script with xcodeproj // which adds localized strings files to the project. private StringBuilder installLocalizedStringsScript = new StringBuilder(); @@ -2157,6 +2167,21 @@ public boolean build(File sourceZip, BuildRequest request) throws BuildException xcodeVersion = 10; } + // The SDK, not the Xcode, is what Apple's launch-screen rule is conditional on -- so ask + // for it directly, and only fall back to the Xcode version when xcrun cannot answer. The + // fallback is sound in the direction it is used: the two have only matched since the + // Xcode 26 renumbering, and every Xcode that predates it reports a version far below the + // floor this is compared against, so a wrong answer there can never turn the rule ON for + // a build the SDK exempts. + iosSdkMajor = iosSdkMajorVersion(activeIosSdkName(request)); + if (iosSdkMajor < 0) { + iosSdkMajor = xcodeVersion; + } + String removedHintRejection = removedHintRejection(request.getArgs()); + if (removedHintRejection != null) { + throw new BuildException(removedHintRejection); + } + String facebookAppId = request.getArg("facebook.appId", null); boolean usePodsForFacebook = !request.getArg("ios.facebook.usePods", "true").equals("false") && facebookAppId != null && facebookAppId.length() > 0; if (usePodsForFacebook) { @@ -3361,6 +3386,15 @@ public void usesClassMethod(String cls, String method) { } catch (IOException ex) { throw new BuildException("Failed to extract nativeios.jar",ex); } + String portSkew; + try { + portSkew = sceneLifecyclePortSkewRejection(buildinRes); + } catch (IOException ex) { + throw new BuildException("Failed to read the extracted iOS port natives", ex); + } + if (portSkew != null) { + throw new BuildException(portSkew); + } stopwatch.split("Extract Libs"); if(request.getArg("noExtraResources", "false").equals("true")) { @@ -3449,13 +3483,11 @@ public void usesClassMethod(String cls, String method) { replaceInFile(CN1ES2compat, "//#define CN1_USE_METAL", "#define CN1_USE_METAL"); String colorSpaceDefine = resolveMetalColorSpaceDefine(request.getArg("ios.metal.colorSpace", "sRGB")); replaceInFile(CN1ES2compat, "//#define CN1_METAL_COLORSPACE_PLACEHOLDER", colorSpaceDefine); - copy(new File(buildinRes, "MainWindowMETAL.xib"), new File(buildinRes, "MainWindow.xib")); copy(new File(buildinRes, "CodenameOne_METALViewController.xib"), new File(buildinRes, "CodenameOne_GLViewController.xib")); } catch (Exception ex) { throw new BuildException("Failed to inject Metal controllers", ex); } } else { - new File(buildinRes, "MainWindowMETAL.xib").delete(); new File(buildinRes, "CodenameOne_METALViewController.xib").delete(); // The .metal shader file isn't guarded by an #ifdef like the // companion .m files, so leaving it in the project forces Xcode @@ -3507,7 +3539,6 @@ public void usesClassMethod(String cls, String method) { } File glAppDelegate = new File(buildinRes, "CodenameOne_GLAppDelegate.m"); - boolean useUIScene = "true".equalsIgnoreCase(request.getArg("ios.uiscene", "true")); String integrateFacebook = ""; @@ -3685,14 +3716,6 @@ public void usesClassMethod(String cls, String method) { throw new BuildException("Failure while processing ios.blockScreenshotsOnEnterBackground build hint", ex); } } - - if (useUIScene) { - try { - replaceInFile(new File(buildinRes, "CodenameOne_GLAppDelegate.h"), "#ifdef CN1_USE_UI_SCENE", "#define CN1_USE_UI_SCENE\n#ifdef CN1_USE_UI_SCENE"); - } catch (IOException ex) { - throw new BuildException("Failure while processing ios.uiscene build hint", ex); - } - } String applicationDidEnterBackground = request.getArg("ios.applicationDidEnterBackground", null); if(applicationDidEnterBackground != null) { @@ -8014,8 +8037,15 @@ && conditionCovers(governingKey, tvNativeBuilder.applyXcodeSettings(request, tmpFile, buildVersion); } + } catch (BuildException alreadyDiagnosed) { + // A BuildException raised in here is a refusal this builder decided on and + // already worded -- "your bundle declares no launch screen, here is how to fix + // it". Wrapping it below replaced that with "Failed to inject into plist" and + // dropped the cause, so the developer was told a build step failed and nothing + // about which one or why. + throw alreadyDiagnosed; } catch (Exception ex) { - throw new BuildException("Failed to inject into plist"); + throw new BuildException("Failed to inject into plist", ex); } @@ -10324,6 +10354,277 @@ String activeIosSdkName(BuildRequest request) { return "iphoneos"; } + /// The major version in an SDK name, or -1 when the name carries no version. + /// + /// activeIosSdkName answers "iphoneos27.2" when xcrun can be asked and the bare + /// "iphoneos" when it cannot, and the bare name deliberately matches every version -- + /// which is right for an [sdk=...] qualifier and wrong here, where it would have to + /// stand for some particular version. -1 says "unknown" instead, and the caller + /// resolves that rather than guessing high. + /// + /// Only the major is returned. Apple's launch rules are stated against the SDK major, + /// and API 37 has already shown what gathering the digits of a dotted version does: + /// "27.2" read as 272 compares greater than every floor in the file at once. + static int iosSdkMajorVersion(String sdkName) { + if (sdkName == null) { + return -1; + } + int digit = 0; + while (digit < sdkName.length() && !Character.isDigit(sdkName.charAt(digit))) { + digit++; + } + int end = digit; + while (end < sdkName.length() && Character.isDigit(sdkName.charAt(end))) { + end++; + } + if (end == digit) { + return -1; + } + try { + return Integer.parseInt(sdkName.substring(digit, end)); + } catch (NumberFormatException tooManyDigits) { + return -1; + } + } + + /// Build hints this builder used to read and no longer does, each with the reason. + /// + /// A hint nothing reads is accepted, ignored, and silent -- the exact failure the build-hint + /// catalog exists to prevent -- and these three are worse than merely inert: every one of + /// them asks for a bundle Apple no longer accepts. So they are refused by name rather than + /// dropped, and the message says what replaced them. + /// + /// Read off the request's supplied keys, not through getArg, because getArg is how a hint is + /// *read* -- and a removed hint has no reader. That also keeps the catalog honest: these + /// names are gone from it, and a getArg site for a name it does not describe is what + /// check-build-hint-catalog fails on. + private static final String[][] REMOVED_HINTS = { + {"ios.uiscene", + "ios.uiscene has been removed and the UIScene lifecycle is now the only one this " + + "builder generates. Apple requires it of every app linked with the iOS 27 SDK or " + + "later -- an app built without it does not launch (iOS & iPadOS 27 release notes, " + + "UIKit 141837548) -- so there is nothing left for the hint to select. Delete it " + + "from your build hints. Migration guidance for app code is in Apple TN3187, and if " + + "your app worked under the legacy UIApplicationDelegate lifecycle and not under " + + "scenes, please report it: that is a bug in Codename One and no longer has a way " + + "around it."}, + {"ios.generateSplashScreens", + "ios.generateSplashScreens has been removed. It selected the legacy Default*.png " + + "splash-image generator, which iOS stopped using long ago and which this builder no " + + "longer contains; all the hint still did was suppress the launch screen, and an app " + + "linked with the iOS 27 SDK is rejected without one (iOS & iPadOS 27 release notes, " + + "UIKit 168247372). Delete it from your build hints. Every build now declares " + + "UILaunchScreen and shows Launch.Foreground.png -- drop your own Launch.Foreground.png " + + "into the project to replace the image, or declare your own launch key through " + + "ios.plistInject."}, + {"ios.launchStoryboardName", + "ios.launchStoryboardName has been removed. It named the storyboard for the " + + "UILaunchStoryboardName key, which is only emitted on the legacy lifecycle the " + + "ios.uiscene hint used to select: SplashBoard does not render a launch storyboard " + + "for a scene-based app, so on every build this builder now produces the key would " + + "mean a black launch (issue #5210). Delete it from your build hints. To point the " + + "launch at a storyboard of your own anyway, declare UILaunchStoryboardName through " + + "ios.plistInject, which overrides the generated UILaunchScreen."}, + }; + + /// Why a build must be refused for asking for a hint that no longer exists, or null. + /// + /// #### Parameters + /// + /// - `suppliedHints`: every build-hint name the request carries + /// + /// #### Returns + /// + /// the message to fail the build with, or null when none of them was supplied + static String removedHintRejection(Set suppliedHints) { + if (suppliedHints == null) { + return null; + } + StringBuilder message = new StringBuilder(); + for (String[] removed : REMOVED_HINTS) { + if (suppliedHints.contains(removed[0])) { + if (message.length() > 0) { + message.append("\n\n"); + } + message.append(removed[1]); + } + } + return message.length() == 0 ? null : message.toString(); + } + + /// Why the extracted iOS port cannot service the lifecycle this build declares, or null. + /// + /// The port's natives and this plugin ship as one unit: `codenameone-ios` is a dependency + /// OF the plugin, version-managed to the plugin's own version, and + /// `Executor.getResourceAsStream` reads the plugin realm -- so nothing a generated project + /// configures, `cn1.version` included, can steer which bundle is unzipped here. A + /// hand-written `` override on the plugin declaration can, and Maven honours + /// it. + /// + /// That combination used to be merely odd and is now fatal, silently. The scene delegate is + /// what installs the window, and an older bundle either predates it entirely (7.0.214 ships + /// no CodenameOne_GLSceneDelegate.m at all) or guards it behind `#ifdef CN1_USE_UI_SCENE`, a + /// define this plugin stopped injecting when the legacy lifecycle was deleted. Measured on + /// the guarded sources against the iOS 27 SDK: 19432 bytes of object code with the define, + /// 1248 without -- no class, no methods. Meanwhile the Info.plist this build writes names + /// `CodenameOne_GLSceneDelegate` as the scene delegate, so UIKit looks up a class the binary + /// does not contain. Nothing fails at build time; the app fails at launch. + /// + /// Note the main NIB is NOT the fallback it looks like. An older bundle still carries + /// MainWindow.xib -- 7.0.214 does -- but `NSMainNibFile` comes from the translator template, + /// which is `codenameone-parparvm` at the PLUGIN's version, so the key that would load it is + /// gone whatever the port says. + /// + /// #### Parameters + /// + /// - `nativeSources`: the directory nativeios.jar was extracted into + /// + /// #### Returns + /// + /// the message to fail the build with, or null when the port matches this plugin + static String sceneLifecyclePortSkewRejection(File nativeSources) throws IOException { + File sceneDelegate = new File(nativeSources, "CodenameOne_GLSceneDelegate.m"); + String reason; + if (!sceneDelegate.exists()) { + reason = "it contains no CodenameOne_GLSceneDelegate.m at all"; + } else if (new String(readFileBytes(sceneDelegate), StandardCharsets.UTF_8) + .contains("CN1_USE_UI_SCENE")) { + // Present but compiled out: the define that used to enable it is gone from this + // plugin, so the class would vanish from the binary with the build still green. + reason = "its CodenameOne_GLSceneDelegate is still behind #ifdef CN1_USE_UI_SCENE, " + + "a define this version no longer sets"; + } else { + return null; + } + return "The iOS port bundle on this build's classpath is older than the Codename One " + + "Maven plugin running it: " + reason + ". The scene delegate is what creates " + + "the application window, and the Info.plist this build writes names it, so the " + + "result would be an app that builds cleanly and fails to launch. These two " + + "artifacts are released together and are not meant to be mixed -- remove the " + + " override pinning com.codenameone:codenameone-ios under the " + + "codenameone-maven-plugin declaration in your pom, or move the plugin back to " + + "the version that matches it. Note cn1.version is not what selects this: the " + + "port is a dependency of the plugin, not of your project."; + } + + /// The launch-screen keys Apple accepts, in the order its release notes name them. + /// + /// All four, because an app is entitled to supply whichever one describes its launch + /// experience. Everything here that asks about a launch screen asks about the whole set: + /// a check that knows only the two this builder can emit would append a second launch + /// experience beside a UILaunchStoryboards the developer supplied, and would fail a build + /// whose launch screen is perfectly valid. + static final String[] ACCEPTED_LAUNCH_KEYS = { + "UILaunchStoryboardName", "UILaunchStoryboards", "UILaunchScreen", "UILaunchScreens" + }; + + /// The same plist without the scene manifest or any launch key of its root dictionary. + /// + /// The translator template declares a manifest and a UILaunchScreen so that a project it + /// produces on its own can launch. This build is about to write its own pair, so the + /// template's come out first: a property list takes the LAST of a duplicated key, which + /// would make the one UIKit reads depend on where the injection was spliced. + /// + /// All four launch keys, not only the one the template declares: whichever of them is + /// there, it is ours, and leaving a second kind behind is the same duplication by another + /// name. Nothing a developer wrote reaches this text -- ios.plistInject is a separate + /// fragment, added after this runs. + /// + /// #### Parameters + /// + /// - `plist`: the template plist document + /// + /// #### Returns + /// + /// the document with those keys removed, or the input when it declared none + static String plistStrippedOfGeneratedLaunchMetadata(String plist) { + String stripped = plistWithoutRootMembers(plist, "UIApplicationSceneManifest"); + for (String launchKey : ACCEPTED_LAUNCH_KEYS) { + stripped = plistWithoutRootMembers(stripped, launchKey); + } + return stripped; + } + + /// Whether an injected plist fragment names any of Apple's launch-screen keys. + /// + /// Deliberately `contains`, not a parse. This decides only whether to ADD a key of our + /// own, and matching too eagerly there just leaves the developer's fragment alone -- the + /// safe direction. The finished document is parsed properly by launchMetadataRejection, + /// which is what fails a build, and it is the one that has to tell a real declaration + /// from a mention. + /// + /// #### Parameters + /// + /// - `inject`: the injected plist fragment + /// + /// #### Returns + /// + /// true when the fragment already names a launch key + static boolean plistNamesAnyLaunchKey(String inject) { + if (inject == null) { + return false; + } + for (String key : ACCEPTED_LAUNCH_KEYS) { + if (inject.contains(key)) { + return true; + } + } + return false; + } + + /// Why a finished Info.plist must be refused, or null when it satisfies Apple's rules. + /// + /// Asked of the document this builder actually wrote, not of the fragments it assembled. + /// The keys can arrive from three places -- the translator's template, the injection + /// below, and the developer's own ios.plistInject -- and only the finished file knows + /// what survived all three. Reading the generator strings instead is how a bundle with + /// no launch key at all was produced by a build whose generator looked correct. + /// + /// #### Parameters + /// + /// - `plist`: the complete Info.plist document + /// + /// - `sdkMajor`: the major version of the SDK being linked against, or -1 if unknown + /// + /// #### Returns + /// + /// the message to fail the build with, or null when the document is acceptable + static String launchMetadataRejection(String plist, int sdkMajor) { + if (sdkMajor < FIRST_SDK_REQUIRING_LAUNCH_METADATA) { + return null; + } + int[] root = plistRootDictBody(plist); + if (root == null) { + // Not a document this parser can read. Xcode will have its own opinion about that + // and will say so; inventing a launch-screen failure for it would be a misdiagnosis. + return null; + } + boolean declared = false; + for (String key : ACCEPTED_LAUNCH_KEYS) { + if (plistMemberRange(plist, root[0], root[1], key) != null) { + declared = true; + break; + } + } + if (!declared) { + return "The generated Info.plist declares no launch screen. Apps linked with the " + + "iOS " + sdkMajor + " SDK are rejected unless the bundle declares one of " + + "UILaunchStoryboardName, UILaunchStoryboards, UILaunchScreen or " + + "UILaunchScreens (iOS & iPadOS 27 release notes, UIKit 168247372). " + + "UIRequiresFullScreen is not a substitute (TN3192). Remove any " + + "ios.plistInject that strips the generated launch key, or declare your " + + "own launch screen there."; + } + if (plistMemberRange(plist, root[0], root[1], "UIApplicationSceneManifest") == null) { + return "The generated Info.plist declares no UIApplicationSceneManifest. Apps " + + "linked with the iOS " + sdkMajor + " SDK must adopt the UIScene " + + "lifecycle or they fail to launch (iOS & iPadOS 27 release notes, UIKit " + + "141837548). Remove any ios.plistInject that strips the generated scene " + + "manifest, or declare your own there."; + } + return null; + } + /// A ruby fragment that raises every app-extension target to the SDK's minimum. /// /// Appended AFTER the fragment that creates the extensions, which is the whole point. @@ -15369,16 +15670,17 @@ static String sceneManifestRejection(String inject) { /// The Mac slice's version of a finished plist: one that supports multiple scenes /// and declares the window role to create them with. /// - /// The shared plist is left exactly as the iOS slice needs it, which for a default - /// Catalyst build means it carries no scene manifest at all -- declaring one - /// activates the UIScene lifecycle, and the iPhone/iPad artifact still carries its - /// main NIB, which is a window with no scene and a launch FrontBoard terminates. - /// So this adds whatever is missing, and only the Mac slice ever reads the result: + /// The shared plist is left exactly as the iOS slice needs it, which means + /// UIApplicationSupportsMultipleScenes stays false: it is ONE Info.plist, and the same + /// build ships the iPhone/iPad slice, which never asked for multiple windows. Only the + /// Mac slice reads the result of this, so this is where windows are turned on. + /// + /// It still handles a manifest it did not generate, because ios.plistInject can supply + /// one and then the generator steps aside entirely: /// /// - no manifest at all: a whole one is added to the root dictionary; /// - a manifest without multiple-scene support: the key is set, or added; - /// - a manifest whose scene configurations have no window role -- which is what a - /// CarPlay build with ios.uiscene off produces -- the role is added to them. + /// - a manifest whose scene configurations have no window role: the role is added. /// /// That last case is why this cannot simply flip a boolean: a manifest can exist /// and still describe no window UIKit could create. @@ -15397,14 +15699,10 @@ static String plistForMacSlice(String plist) { return null; } plist = plistWithExpandedDict(plist, 0); - // The Mac slice always ends up with a scene manifest, and a scene lifecycle - // beside a legacy main NIB is the orphan window FrontBoard terminates -- the - // very pairing that keeps the manifest out of the shared plist. The shared - // plist only drops NSMainNibFile under ios.uiscene, so for the default Catalyst - // build it is still there and has to go here. - // - // The Mac build settings exclude MainWindow.xib from compilation anyway, so the - // key names a NIB that is not in this bundle even before the lifecycle argument. + // Nothing this builder generates declares NSMainNibFile any more -- the key left the + // translator template with the main NIB it named. What can still put one here is + // ios.plistInject, and a scene lifecycle beside a legacy main NIB is an orphan window + // FrontBoard terminates at launch, so it goes. plist = plistWithoutRootMembers(plist, "NSMainNibFile"); int[] root = plistRootDictBody(plist); if (root == null) { @@ -16145,12 +16443,31 @@ public boolean accept(File file, String string) { replaceAllInFile(infoPlist, "English", "" + lang + ""); } - if ("true".equalsIgnoreCase(request.getArg("ios.uiscene", "true"))) { - // MainWindow.xib auto-instantiates a UIWindow with visibleAtLaunch=YES; under - // UIScene the window has no scene and FrontBoard kills the launch in iOS 26. - // UIApplicationMain(..., @"CodenameOne_GLAppDelegate") still creates the - // delegate from the class name, so the NIB is no longer needed. - replaceAllInFile(infoPlist, "NSMainNibFile\\s*[^<]*", ""); + // No NSMainNibFile strip here any more, and none in the template either. MainWindow.xib + // auto-instantiated a UIWindow with visibleAtLaunch=YES; under the scene lifecycle that + // window has no scene and FrontBoard kills the launch, so the key had to go on every + // build this produces -- and the nib it named went with it. + // UIApplicationMain(..., @"CodenameOne_GLAppDelegate") creates the delegate from the + // class name. A developer who injects the key through ios.plistInject still gets it + // stripped from the Mac slice, which is a different concern; see plistForMacSlice. + + // What replaced it lives in the template as well: the scene manifest and UILaunchScreen + // are unconditional now, so the translator template declares both and a project produced + // by ByteCodeTranslator alone -- without this builder ever running -- has a lifecycle and + // a launch screen of its own. The port's natives stopped being able to launch without a + // scene manifest the moment the legacy lifecycle was deleted from them, and a native that + // depends on a plist key only one of its two producers writes is a key it can lose. + // + // This build writes its own, because CarPlay adds a second role and ios.plistInject can + // replace either outright, so the template's copies come out first: a property list takes + // the LAST of a duplicated key, and shipping two of these would make which one UIKit + // reads a function of where the injection happened to be spliced. + if (infoPlist.exists()) { + PlistText template = readPlistText(infoPlist); + String withoutGenerated = plistStrippedOfGeneratedLaunchMetadata(template.text); + if (!withoutGenerated.equals(template.text)) { + writePlistText(infoPlist, template, withoutGenerated); + } } // nothing to inject here? move along @@ -16236,10 +16553,6 @@ public boolean accept(File file, String string) { } boolean multitasking = "true".equals(request.getArg("ios.multitasking", "true")); - if(request.getArg("ios.generateSplashScreens", "false").equals( - "true")) { - multitasking = false; - } if (multitasking && useMetal && getDeploymentTargetInt(request) < 14) { // An explicit ios.deployment_target below 14 cannot satisfy the // App Store launch screen rule for iPad multitasking apps via the @@ -16259,67 +16572,45 @@ public boolean accept(File file, String string) { inject += "\nUIRequiresFullScreen\n"; } } - if (!"true".equals(request.getArg("ios.generateSplashScreens", "false"))) { - if ("true".equalsIgnoreCase(request.getArg("ios.uiscene", "true"))) { - // SplashBoard never renders the launch storyboard for scene-based - // CN1 apps -- the system animates from a black frame instead - // (issue #5210). The iOS 14+ UILaunchScreen generated launch - // screen does work under UIScene: system background color - // (light/dark aware) with the launch icon centered, matching the - // native launch placeholder the app shows until the first EDT - // frame. UILaunchStoryboardName must be OMITTED here: when both - // keys are present iOS prefers the storyboard, which is exactly - // the broken path (verified on the iOS 26 simulator with a cold - // SplashBoard cache). The ios.launchStoryboardName hint is - // therefore only honored with ios.uiscene=false; injecting - // either key via ios.plistInject overrides this default. - // UIImageName points at the loose Launch.Foreground.png in the - // bundle root (guaranteed by generateLaunchScreen); SplashBoard - // resolves it there but fails to render the same image from an - // actool compiled imageset, so do NOT move it into - // Images.xcassets. - if (!inject.contains("UILaunchScreen") && !inject.contains("UILaunchStoryboardName")) { - inject += "\nUILaunchScreen\n" - + "\n" - + " UIImageName\n" - + " Launch.Foreground\n" - + ""; - } - } else if (!inject.contains("UILaunchStoryboardName")) { - inject += "\nUILaunchStoryboardName"+request.getArg("ios.launchStoryboardName", "LaunchScreen")+""; - } - } - boolean useUISceneManifest = "true".equalsIgnoreCase(request.getArg("ios.uiscene", "true")); + // SplashBoard never renders a launch storyboard for a scene-based CN1 app -- the + // system animates from a black frame instead (issue #5210) -- and every build is + // scene-based now, so UILaunchStoryboardName is never what this generates. + // UILaunchScreen does work under UIScene: system background color (light/dark aware) + // with the launch icon centered, matching the native launch placeholder the app shows + // until the first EDT frame. When both keys are present iOS prefers the storyboard, + // which is exactly the broken path (verified on the iOS 26 simulator with a cold + // SplashBoard cache), so this generates one key and only one. + // + // UIImageName points at the loose Launch.Foreground.png in the bundle root (guaranteed + // by generateLaunchScreen); SplashBoard resolves it there but fails to render the same + // image from an actool compiled imageset, so do NOT move it into Images.xcassets. + // + // All four of Apple's launch keys are consulted before adding this one, not just the + // two this builder can emit. A project that declares UILaunchStoryboards or + // UILaunchScreens through ios.plistInject has supplied a launch experience, and + // appending ours next to it produces two -- with iOS picking between them rather than + // the developer. + if (!plistNamesAnyLaunchKey(inject)) { + inject += "\nUILaunchScreen\n" + + "\n" + + " UIImageName\n" + + " Launch.Foreground\n" + + ""; + } // com.codename1.ui.Window needs multiple scenes, and a Window only exists on // the Mac Catalyst slice, so the key follows macNative.enabled exactly. boolean multiWindow = macNativeBuilder.isMultiWindow(); - // CarPlay requires the UIScene lifecycle and a dedicated - // CPTemplateApplicationSceneSessionRoleApplication scene wired to - // CodenameOne_CarPlaySceneDelegate. Emit the manifest when either UIScene is on or the app - // uses CarPlay; include the phone window role only under UIScene, and the CarPlay role only - // when the app references com.codename1.car. - // multiWindow is in the condition as well as the value below. A Catalyst build - // with ios.uiscene=false and no CarPlay skipped the whole block, so the bundle - // got neither UIApplicationSupportsMultipleScenes nor a scene configuration -- - // and getWindowManager() reads that key back out of the bundle, so windows were - // reported unsupported and constructing one threw, in the very build that had - // just asked for them. if (multiWindow) { String rejection = sceneManifestRejection(inject); if (rejection != null) { throw new BuildException(rejection); } } - // multiWindow is deliberately NOT in this condition. Declaring - // UIApplicationSceneManifest activates the UIScene lifecycle, and the - // NSMainNibFile removal above runs only under ios.uiscene -- so putting a - // manifest in the shared plist for a Catalyst build would hand the iPhone/iPad - // artifact a scene lifecycle while it still carries its main NIB, which is a - // window with no scene and a launch FrontBoard terminates on iOS 26. The Mac - // slice's copy is where a manifest appears for windows; see + // multiWindow is deliberately NOT in this condition -- it decides the VALUE of + // UIApplicationSupportsMultipleScenes below, never whether a manifest is written at + // all. The Mac slice's copy is where multi-window support appears; see // plistForMacSlice. - if ((useUISceneManifest || usesCar) - && !plistDeclaresKey(inject, "UIApplicationSceneManifest")) { + if (!plistDeclaresKey(inject, "UIApplicationSceneManifest")) { String carPlayScene = usesCar ? " CPTemplateApplicationSceneSessionRoleApplication\n" + " \n" @@ -16331,7 +16622,6 @@ public boolean accept(File file, String string) { + " \n" + " \n" : ""; - String windowScene = useUISceneManifest ? WINDOW_SCENE_ROLE : ""; inject += "\nUIApplicationSceneManifest\n" + "\n" + " UIApplicationSupportsMultipleScenes\n" @@ -16354,7 +16644,10 @@ public boolean accept(File file, String string) { + " \n" + " UISceneConfigurations\n" + " \n" - + windowScene + // Unconditional: the app role is what UIKit creates the main window from, + // and a manifest that configures nothing for it describes an app with no + // window. CarPlay is a second, distinct role beside it, never instead of it. + + WINDOW_SCENE_ROLE + carPlayScene + " \n" + ""; @@ -16917,10 +17210,21 @@ public boolean accept(File file, String string) { line = infoReader.readLine(); } infoReader.close(); - + try(FileOutputStream fo = new FileOutputStream(infoPlist)) { fo.write(b.toString().getBytes(StandardCharsets.UTF_8)); } + + // The last word on Apple's launch rules, and the only one that sees what the developer's + // own ios.plistInject did. Every producer above decides whether to ADD a key, and each of + // them steps aside when the injection already names it -- so a plistInject that mentions + // UILaunchScreen inside a comment, or declares it somewhere other than the root + // dictionary, silences the generator without leaving UIKit anything to read. Asking the + // finished document is what distinguishes those from a real declaration. + String rejection = launchMetadataRejection(b.toString(), iosSdkMajor); + if (rejection != null) { + throw new BuildException(rejection); + } } /// The window scene role, wired to Codename One's scene delegate. A diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacNativeBuilder.java index a5e2ad45748..4fef2545549 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacNativeBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacNativeBuilder.java @@ -764,8 +764,8 @@ void applyXcodeSettings(BuildRequest request, File tmpFile, String buildVersion) // time is safe. The iOS slice keeps loading them normally. s.append(" bs['EXCLUDED_SOURCE_FILE_NAMES[sdk=macosx*]'] = ") .append("'CN1ES2compat.m CN1ES1compat.m EAGLView.m ") - .append("CodenameOne_GLViewController.xib MainWindow.xib ") - .append("CodenameOne_METALViewController.xib MainWindowMETAL.xib'\n"); + .append("CodenameOne_GLViewController.xib ") + .append("CodenameOne_METALViewController.xib'\n"); // Header search path stubs for the Mac slice: the iOS port ships an // umbrella set of empty/stub GLKit and OpenGLES headers under // macCatalystStubs/. diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java index d520b5fbd3c..9340e8f27df 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/TvNativeBuilder.java @@ -69,13 +69,13 @@ class TvNativeBuilder { // OpenGL-only source files with no tvOS substitute (tvOS has no OpenGL ES / // GLKit). Excluded from the tvOS target exactly as MacNativeBuilder excludes // them from the Mac Catalyst slice; the rendering-op .m files take their - // internal `#elif defined(CN1_USE_METAL)` branch on tvOS. The four iOS XIBs + // internal `#elif defined(CN1_USE_METAL)` branch on tvOS. The two iOS XIBs // are excluded for the same reason they are on Mac (IBAgent UIKit errors / // the runtime never loads them by name on the non-iPhone slice). private static final String EXCLUDED_TV_SOURCES = "CN1ES2compat.m CN1ES1compat.m EAGLView.m " - + "CodenameOne_GLViewController.xib MainWindow.xib " - + "CodenameOne_METALViewController.xib MainWindowMETAL.xib " + + "CodenameOne_GLViewController.xib " + + "CodenameOne_METALViewController.xib " // App Intents and the snippet renderer are staged into
-src for the iOS // app target, and this builder copies that directory wholesale -- so declaring // an intent made the tvOS slice compile App Intents types that need tvOS 16 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 5b589c6b1d1..84fe3ccf655 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 @@ -148,13 +148,13 @@ String minDeploymentTarget() { // one. Everything that CAN be guarded is guarded in the source instead, with // `#if !TARGET_OS_WATCH` wrapping the whole file, so a new GL/Metal/UIKit // source carries its own exclusion and cannot silently break the watch build - // by being forgotten here. These five have no preprocessor to run: + // by being forgotten here. These three have no preprocessor to run: // a .metal shader is compiled by the Metal compiler (absent on watchOS) and a // .xib is Interface Builder data. private static final String[] EXCLUDED_WATCH_SOURCES = { "CN1MetalShaders.metal", - "CodenameOne_GLViewController.xib", "MainWindow.xib", - "CodenameOne_METALViewController.xib", "MainWindowMETAL.xib" + "CodenameOne_GLViewController.xib", + "CodenameOne_METALViewController.xib" }; // Frameworks the watch target must not link; ParparVM weak-links these (see diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderLaunchMetadataTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderLaunchMetadataTest.java new file mode 100644 index 00000000000..5763d115d9e --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderLaunchMetadataTest.java @@ -0,0 +1,311 @@ +/* + * 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 java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Apple rejects an app linked with the iOS 27 SDK that declares no launch screen, and an + * app linked with it that does not adopt the scene lifecycle does not launch at all. Both + * of those are decided by the finished Info.plist, and both used to be reachable from a + * build hint that succeeded. These are the two refusals that close them, plus the version + * parse both are conditional on -- because getting that wrong either applies the rules to + * an SDK Apple exempts or applies them to none. + */ +class IPhoneBuilderLaunchMetadataTest { + + private static final String LAUNCH_SCREEN = + " UILaunchScreen\n" + + " \n" + + " UIImageName\n" + + " Launch.Foreground\n" + + " \n"; + + private static final String SCENE_MANIFEST = + " UIApplicationSceneManifest\n" + + " \n" + + " UIApplicationSupportsMultipleScenes\n" + + " \n" + + " UISceneConfigurations\n" + + " \n" + + IPhoneBuilder.WINDOW_SCENE_ROLE + + " \n" + + " \n"; + + /// A whole Info.plist document, which is what the validator is handed: it reads the + /// root dictionary, and a bare fragment has none. + private static String document(String body) { + return "\n" + + "\n\n" + + " CFBundleName\n Demo\n" + + body + + "\n\n"; + } + + @Test + void sdkMajorIsTheMajorAloneAndNeverTheDigitsGathered() { + assertEquals(27, IPhoneBuilder.iosSdkMajorVersion("iphoneos27.2")); + assertEquals(27, IPhoneBuilder.iosSdkMajorVersion("iphoneos27.0")); + assertEquals(27, IPhoneBuilder.iosSdkMajorVersion("iphoneos27")); + assertEquals(26, IPhoneBuilder.iosSdkMajorVersion("iphoneos26.0")); + assertEquals(18, IPhoneBuilder.iosSdkMajorVersion("iphonesimulator18.4")); + } + + @Test + void anUnversionedSdkNameIsUnknownRatherThanAnyParticularVersion() { + // activeIosSdkName answers the bare platform off a machine that cannot be asked, and + // there it deliberately means "every version". Reading it as a number here would have + // to pick one, and either choice is wrong: -1 hands the decision back to the caller. + assertEquals(-1, IPhoneBuilder.iosSdkMajorVersion("iphoneos")); + assertEquals(-1, IPhoneBuilder.iosSdkMajorVersion("")); + assertEquals(-1, IPhoneBuilder.iosSdkMajorVersion(null)); + } + + @Test + void everyRemovedHintIsRefusedByName() { + for (String removed : new String[] { + "ios.uiscene", "ios.generateSplashScreens", "ios.launchStoryboardName"}) { + Set supplied = new HashSet(Arrays.asList("ios.multitasking", removed)); + String rejection = IPhoneBuilder.removedHintRejection(supplied); + assertNotNull(rejection, removed); + assertTrue(rejection.contains(removed), rejection); + } + } + + @Test + void aRequestThatSuppliesNoneOfThemIsNotRefused() { + // The whole point of removing them by name: a build that never mentioned one is + // untouched, and there is no hint left whose value has to be interpreted. + assertNull(IPhoneBuilder.removedHintRejection(new HashSet( + Arrays.asList("ios.multitasking", "ios.plistInject", "ios.deployment_target")))); + assertNull(IPhoneBuilder.removedHintRejection(new HashSet())); + assertNull(IPhoneBuilder.removedHintRejection(null)); + } + + @Test + void refusingIsAboutTheNameAndNotTheValue() { + // ios.uiscene=true asked for what the build now always does, and is still refused: + // leaving it accepted means leaving a name in the catalog nothing reads, which is the + // failure the catalog exists to prevent. The message says the hint is gone, not that + // its value was wrong. + String rejection = IPhoneBuilder.removedHintRejection( + new HashSet(Arrays.asList("ios.uiscene"))); + assertNotNull(rejection); + assertTrue(rejection.contains("has been removed"), rejection); + assertTrue(rejection.contains("TN3187"), rejection); + } + + @Test + void everyRemovedHintIsNamedInOneMessage() { + // A build that carries all three gets told about all three. Failing on the first one + // makes the developer re-run the build to discover the next. + String rejection = IPhoneBuilder.removedHintRejection(new HashSet(Arrays.asList( + "ios.uiscene", "ios.generateSplashScreens", "ios.launchStoryboardName"))); + assertNotNull(rejection); + assertTrue(rejection.contains("ios.uiscene"), rejection); + assertTrue(rejection.contains("ios.generateSplashScreens"), rejection); + assertTrue(rejection.contains("ios.launchStoryboardName"), rejection); + } + + @Test + void anInjectionThatNamesAnyLaunchKeyIsLeftAlone() { + // All four, not just the two this builder can emit. A project that supplies + // UILaunchStoryboards or UILaunchScreens has declared its launch experience, and + // appending ours beside it leaves iOS to choose between two. + for (String key : IPhoneBuilder.ACCEPTED_LAUNCH_KEYS) { + assertTrue(IPhoneBuilder.plistNamesAnyLaunchKey( + "" + key + "\n"), key); + } + } + + @Test + void anInjectionThatNamesNoLaunchKeyGetsTheGeneratedOne() { + assertFalse(IPhoneBuilder.plistNamesAnyLaunchKey( + "UIRequiresFullScreen")); + assertFalse(IPhoneBuilder.plistNamesAnyLaunchKey("")); + assertFalse(IPhoneBuilder.plistNamesAnyLaunchKey(null)); + } + + @Test + void theTemplatesOwnLaunchMetadataComesOutBeforeThisBuildWritesItsOwn() { + // The translator template declares both so a project it produces alone can launch. + // This build writes its own pair, and a plist takes the LAST of a duplicated key -- + // so shipping two would make the one UIKit reads depend on where the injection landed. + String template = document(LAUNCH_SCREEN + SCENE_MANIFEST + + " LSRequiresIPhoneOS\n \n"); + String stripped = IPhoneBuilder.plistStrippedOfGeneratedLaunchMetadata(template); + assertFalse(stripped.contains("UIApplicationSceneManifest"), stripped); + assertFalse(stripped.contains("UILaunchScreen"), stripped); + assertTrue(stripped.contains("LSRequiresIPhoneOS"), + "nothing else is disturbed"); + assertTrue(stripped.contains("CFBundleName"), + "nothing else is disturbed"); + // And a document that declares neither is returned untouched, so a build whose + // template predates this is not rewritten for nothing. + String bare = document(" LSRequiresIPhoneOS\n \n"); + assertEquals(bare, IPhoneBuilder.plistStrippedOfGeneratedLaunchMetadata(bare)); + } + + @Test + void everyLaunchKeyKindComesOutNotJustTheOneTheTemplateUses() { + // Whichever of the four is in that file is ours -- ios.plistInject is a separate + // fragment added after the strip -- so leaving a second kind behind is the same + // duplication under another name. + for (String key : IPhoneBuilder.ACCEPTED_LAUNCH_KEYS) { + String template = document(" " + key + "\n \n"); + assertFalse(IPhoneBuilder.plistStrippedOfGeneratedLaunchMetadata(template) + .contains("" + key + ""), key); + } + } + + @Test + void aPortOlderThanThisPluginIsRefusedRatherThanShippedUnlaunchable(@TempDir Path nativeSources) + throws Exception { + // codenameone-ios is a dependency OF the plugin, so a generated project cannot select + // it -- cn1.version governs the compile classpath, not this. A hand-written + // override on the plugin declaration can, and Maven honours it. + // + // 7.0.214's nativeios.jar ships no CodenameOne_GLSceneDelegate.m at all. The window + // comes from that delegate and the plist this build writes names it, so the result is + // an app that builds clean and never shows a window. + String rejection = IPhoneBuilder.sceneLifecyclePortSkewRejection(nativeSources.toFile()); + assertNotNull(rejection); + assertTrue(rejection.contains("no CodenameOne_GLSceneDelegate.m"), rejection); + assertTrue(rejection.contains("codenameone-ios"), rejection); + // The message has to say what does NOT select it, because that is where someone will + // look first. + assertTrue(rejection.contains("cn1.version"), rejection); + } + + @Test + void aPortWhoseSceneDelegateIsStillCompiledOutIsRefused(@TempDir Path nativeSources) + throws Exception { + // The 8.0 bundle from before the legacy lifecycle was deleted: the class is in the + // sources but behind #ifdef CN1_USE_UI_SCENE, and this plugin no longer sets that + // define. Measured against the iOS 27 SDK on those exact sources: 19432 bytes of + // object code with the define, 1248 without -- no class, no methods, no warning. + Files.write(nativeSources.resolve("CodenameOne_GLSceneDelegate.m"), + ("#ifdef CN1_USE_UI_SCENE\n@implementation CodenameOne_GLSceneDelegate\n" + + "@end\n#endif\n").getBytes(StandardCharsets.UTF_8)); + String rejection = IPhoneBuilder.sceneLifecyclePortSkewRejection(nativeSources.toFile()); + assertNotNull(rejection); + assertTrue(rejection.contains("CN1_USE_UI_SCENE"), rejection); + } + + @Test + void aPortThatMatchesThisPluginIsNotRefused(@TempDir Path nativeSources) throws Exception { + Files.write(nativeSources.resolve("CodenameOne_GLSceneDelegate.m"), + "@implementation CodenameOne_GLSceneDelegate\n@end\n" + .getBytes(StandardCharsets.UTF_8)); + assertNull(IPhoneBuilder.sceneLifecyclePortSkewRejection(nativeSources.toFile())); + } + + @Test + void aBundleWithLaunchScreenAndSceneManifestPasses() { + assertNull(IPhoneBuilder.launchMetadataRejection( + document(LAUNCH_SCREEN + SCENE_MANIFEST), 27)); + } + + @Test + void allFourOfApplesLaunchKeysAreAccepted() { + // The check is that a launch experience exists, never a preference for the one this + // builder generates: an app is entitled to declare whichever of the four describes it. + String[] declarations = { + " UILaunchStoryboardName\n LaunchScreen\n", + " UILaunchStoryboards\n \n", + LAUNCH_SCREEN, + " UILaunchScreens\n \n", + }; + for (String declaration : declarations) { + assertNull(IPhoneBuilder.launchMetadataRejection( + document(declaration + SCENE_MANIFEST), 27), declaration); + } + } + + @Test + void aBundleWithNoLaunchKeyIsRefused() { + String rejection = IPhoneBuilder.launchMetadataRejection( + document(" UIRequiresFullScreen\n \n" + SCENE_MANIFEST), 27); + assertNotNull(rejection); + assertTrue(rejection.contains("launch screen"), rejection); + // The exact confusion this exists to catch: UIRequiresFullScreen is present, and it is + // not one of the four. + assertTrue(rejection.contains("UIRequiresFullScreen"), rejection); + } + + @Test + void aBundleWithNoSceneManifestIsRefused() { + String rejection = IPhoneBuilder.launchMetadataRejection(document(LAUNCH_SCREEN), 27); + assertNotNull(rejection); + assertTrue(rejection.contains("UIApplicationSceneManifest"), rejection); + } + + @Test + void aNestedNamesakeIsNotADeclaration() { + // UIKit reads these off the root of the bundle's Info.plist. A key of the same name + // buried in some other dictionary is invisible to it, and accepting one would let a + // plistInject satisfy the check with something the device never sees. + String buried = + " SomeVendorConfiguration\n" + + " \n" + + LAUNCH_SCREEN + + " \n"; + assertNotNull(IPhoneBuilder.launchMetadataRejection(document(buried + SCENE_MANIFEST), 27)); + } + + @Test + void aKeyNamedOnlyInACommentIsNotADeclaration() { + String commented = " \n"; + assertNotNull(IPhoneBuilder.launchMetadataRejection( + document(commented + SCENE_MANIFEST), 27)); + } + + @Test + void nothingIsRefusedOnTheSdksApplesRuleDoesNotReach() { + String bare = document(" UIRequiresFullScreen\n \n"); + assertNull(IPhoneBuilder.launchMetadataRejection(bare, 26)); + assertNull(IPhoneBuilder.launchMetadataRejection(bare, -1)); + } + + @Test + void anUnreadableDocumentIsNotDiagnosedAsALaunchScreenFailure() { + // Xcode has its own opinion about a malformed plist and states it clearly. Answering + // "no launch screen" for one would send the developer after the wrong problem. + assertNull(IPhoneBuilder.launchMetadataRejection("not a plist at all", 27)); + assertNull(IPhoneBuilder.launchMetadataRejection("", 27)); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderSceneManifestValidationTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderSceneManifestValidationTest.java index 8c782d4d3e7..332bc941fc0 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderSceneManifestValidationTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderSceneManifestValidationTest.java @@ -264,11 +264,9 @@ void theFlipIsIdempotent() { @Test void aPlistWithNoManifestGetsAWholeOneForTheMacSlice() { - // The default Catalyst build: ios.uiscene is off and there is no CarPlay, so - // the shared plist carries no manifest at all -- declaring one there would - // activate the UIScene lifecycle for the iPhone/iPad artifact while it still - // carries its main NIB, which FrontBoard terminates at launch. The manifest - // has to appear only in the Mac slice's copy. + // A shared plist with no manifest is what an ios.plistInject that replaced the + // whole thing can leave behind -- the generator steps aside for an injected + // manifest, and an injection can declare none. The Mac slice needs one either way. String shared = "\n" + "\n\n" + " CFBundleName\n Demo\n" @@ -358,10 +356,9 @@ void anArraysMembersAreItsOwnElements() { @Test void aCarPlayOnlyManifestGainsTheWindowRoleOnTheMacSlice() { - // macNative with CarPlay and ios.uiscene off: the build emits a manifest for - // CarPlay's sake, and it carries only the CarPlay role. Flipping the support - // key is not enough -- the Catalyst bundle would say multiple scenes are - // supported and describe no configuration to create a window from. + // A manifest carrying only the CarPlay role, which ios.plistInject can supply. + // Flipping the support key is not enough -- the Catalyst bundle would say multiple + // scenes are supported and describe no configuration to create a window from. String carPlayRole = " CPTemplateApplicationSceneSessionRoleApplication\n" + " \n" @@ -626,15 +623,14 @@ void twoSceneDelegatesInOneConfigurationAreRejected() { @Test void theMacSliceDropsTheMainNibItWouldOtherwisePairWithAScene() { - // The default Catalyst build: ios.uiscene is off, so the shared plist keeps - // NSMainNibFile -- its removal there is gated on that hint. The Mac copy always - // gains a scene manifest, and a scene lifecycle beside a legacy main NIB is the - // orphan window FrontBoard terminates at launch. It is also excluded from the - // Mac slice's compilation, so the key names a NIB that is not in that bundle. + // Nothing generates NSMainNibFile any more, so what this defends against is an + // ios.plistInject that declares one. The Mac copy always gains a scene manifest, + // and a scene lifecycle beside a legacy main NIB is the orphan window FrontBoard + // terminates at launch. String shared = document( " NSMainNibFile\n MainWindow\n"); assertTrue(shared.contains("NSMainNibFile"), - "the shared plist keeps it, which is what the iOS slice needs"); + "the injected key is not removed from the shared plist by this transform"); String mac = IPhoneBuilder.plistForMacSlice(shared); assertFalse(mac.contains("NSMainNibFile"), diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/CommandLineBuildHintTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/CommandLineBuildHintTest.java index 1420fa0ea97..211bd9a558c 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/CommandLineBuildHintTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/CommandLineBuildHintTest.java @@ -89,12 +89,12 @@ void unrelatedCommandLinePropertiesAreNotBuildHints() throws Exception { @Test void hintsNotGivenOnTheCommandLineAreUntouched() throws Exception { Properties settings = new Properties(); - settings.setProperty("codename1.arg.ios.uiscene", "true"); + settings.setProperty("codename1.arg.ios.multitasking", "true"); settings.setProperty("codename1.mainName", "MyApp"); overlay(settings, new Properties()); - assertEquals("true", settings.getProperty("codename1.arg.ios.uiscene")); + assertEquals("true", settings.getProperty("codename1.arg.ios.multitasking")); assertEquals("MyApp", settings.getProperty("codename1.mainName")); } diff --git a/scripts/build-ios-app.sh b/scripts/build-ios-app.sh index 0c58120be2b..2c338dd4884 100755 --- a/scripts/build-ios-app.sh +++ b/scripts/build-ios-app.sh @@ -66,8 +66,6 @@ bia_log "Java version for baseline toolchain:" "$JAVA_HOME/bin/java" -version bia_log "Using JAVAC from JAVA17_HOME for demo compilation:" "$JAVA17_HOME/bin/javac" -version -IOS_UISCENE="${IOS_UISCENE:-true}" -bia_log "Building sample app with ios.uiscene=${IOS_UISCENE}" EXTRA_IOS_ARGS=() if [ -n "${IOS_DEPENDENCY_ARGS:-}" ]; then # shellcheck disable=SC2206 @@ -208,7 +206,6 @@ bia_log "Running $APP_MAIN_NAME Maven build with JAVA_HOME=$JAVA17_HOME" -Dcodename1.buildTarget=ios-source -Dmaven.compiler.fork=true -Dmaven.compiler.executable="$JAVA17_HOME/bin/javac" - -Dcodename1.arg.ios.uiscene="${IOS_UISCENE}" -Dopen=false ) if [ ${#EXTRA_IOS_ARGS[@]} -gt 0 ]; then diff --git a/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/FidelityApp.java b/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/FidelityApp.java index f6328994ec1..3465b36893a 100644 --- a/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/FidelityApp.java +++ b/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/FidelityApp.java @@ -33,7 +33,7 @@ * CN1SS:SUITE:FINISHED and exits. */ @Android(gradleDep = {"implementation 'com.google.android.material:material:1.12.0'"}, useAndroidX = Toggle.ON) -@Ios(newStorageLocation = Toggle.ON, uiscene = Toggle.ON) +@Ios(newStorageLocation = Toggle.ON) public class FidelityApp extends Lifecycle { @Override public void runApp() { diff --git a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/HelloCodenameOne.kt b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/HelloCodenameOne.kt index 3c319149d3b..539684f7040 100644 --- a/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/HelloCodenameOne.kt +++ b/scripts/hellocodenameone/common/src/main/kotlin/com/codenameone/examples/hellocodenameone/HelloCodenameOne.kt @@ -41,7 +41,7 @@ import com.codenameone.examples.hellocodenameone.tests.KotlinUiTest import com.codename1.annotations.buildhints.* @Android(useAndroidX = Toggle.ON) -@Ios(applicationQueriesSchemes = ["cydia"], newStorageLocation = Toggle.ON, uiscene = Toggle.ON) +@Ios(applicationQueriesSchemes = ["cydia"], newStorageLocation = Toggle.ON) @IosPrivacy(cameraUsageDescription = "Used by the CI smoke test to verify the com.codename1.camera native bridge compiles. The app never opens a camera session.", healthShareUsageDescription = "Used by the CI smoke test to verify the com.codename1.health native bridge compiles. The app never reads real health data.", healthUpdateUsageDescription = "Used by the CI smoke test to verify the com.codename1.health write path compiles. The app never writes real health data.") open class HelloCodenameOne : Lifecycle() { override fun init(context: Any?) { diff --git a/scripts/input-validation-app/common/codenameone_settings.properties b/scripts/input-validation-app/common/codenameone_settings.properties index 4973a52e4a0..3aa25ca305f 100644 --- a/scripts/input-validation-app/common/codenameone_settings.properties +++ b/scripts/input-validation-app/common/codenameone_settings.properties @@ -3,7 +3,6 @@ codename1.android.keystoreAlias= codename1.android.keystorePassword= codename1.arg.android.useAndroidX=true codename1.arg.ios.newStorageLocation=true -codename1.arg.ios.uiscene=true codename1.arg.java.version=17 codename1.cssTheme=false codename1.displayName=CN1InputValidation diff --git a/scripts/purchase-test-app/app/common/src/main/java/com/codenameone/examples/purchasetest/PurchaseTestApp.java b/scripts/purchase-test-app/app/common/src/main/java/com/codenameone/examples/purchasetest/PurchaseTestApp.java index 15eb3078df6..372ecb37524 100644 --- a/scripts/purchase-test-app/app/common/src/main/java/com/codenameone/examples/purchasetest/PurchaseTestApp.java +++ b/scripts/purchase-test-app/app/common/src/main/java/com/codenameone/examples/purchasetest/PurchaseTestApp.java @@ -40,7 +40,7 @@ * IAP wiring never ripples into the screenshot/notification CI workflows. */ @Android(licenseKey = "CN1TESTPLACEHOLDERKEYNOTFORPRODUCTIONxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxIDAQAB", useAndroidX = Toggle.ON) -@Ios(applicationQueriesSchemes = {"cydia"}, newStorageLocation = Toggle.ON, uiscene = Toggle.ON) +@Ios(applicationQueriesSchemes = {"cydia"}, newStorageLocation = Toggle.ON) @IosPrivacy(cameraUsageDescription = "Used by the CI smoke test to verify the com.codename1.camera native bridge compiles. The app never opens a camera session.") public class PurchaseTestApp extends Lifecycle { @Override diff --git a/vm/ByteCodeTranslator/src/template/template/template-Info.plist b/vm/ByteCodeTranslator/src/template/template/template-Info.plist index 1613f013158..6af419d069b 100644 --- a/vm/ByteCodeTranslator/src/template/template/template-Info.plist +++ b/vm/ByteCodeTranslator/src/template/template/template-Info.plist @@ -53,8 +53,28 @@ VERSION_BUNDLE_VALUE LSRequiresIPhoneOS - NSMainNibFile - MainWindow + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + CodenameOne_GLSceneDelegate + + + + + UILaunchScreen + + UIImageName + Launch.Foreground + UISupportedInterfaceOrientations UIInterfaceOrientationPortrait diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/BytecodeInstructionIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/BytecodeInstructionIntegrationTest.java index d770ca2763e..86f38d7f929 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/BytecodeInstructionIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/BytecodeInstructionIntegrationTest.java @@ -1070,6 +1070,27 @@ void handleIosOutputGeneratesProjectStructure(CompilerHelper.CompilerConfig conf assertTrue(Files.exists(srcRoot.resolve("Images.xcassets"))); assertTrue(Files.exists(dist.resolve("MyAppIOS.xcodeproj"))); assertTrue(Files.exists(srcRoot.resolve("MyAppIOS-Info.plist"))); + + // The plist this writes has to stand on its own, because nothing downstream of + // the translator is guaranteed to run: IPhoneBuilder rewrites it on a Codename One + // build, and a project translated directly gets exactly what is written here. + // + // Both keys are load-bearing since the legacy lifecycle was deleted from the port's + // natives. Without the scene manifest UIKit never creates CodenameOne_GLSceneDelegate, + // so no window is ever installed and the app launches to nothing; without a launch + // key Apple rejects any app linked with the iOS 27 SDK. Neither failure is visible + // at build time. + String iosPlist = new String(Files.readAllBytes( + srcRoot.resolve("MyAppIOS-Info.plist")), StandardCharsets.UTF_8); + assertTrue(iosPlist.contains("UIApplicationSceneManifest"), + "a translated project with no scene manifest launches without a window"); + assertTrue(iosPlist.contains("CodenameOne_GLSceneDelegate"), + "the window scene role has to name the delegate that installs the window"); + assertTrue(iosPlist.contains("UILaunchScreen"), + "apps linked with the iOS 27 SDK are rejected without a launch screen"); + assertFalse(iosPlist.contains("NSMainNibFile"), + "the main nib is a window with no scene, which FrontBoard terminates"); + String pbxproj = new String(Files.readAllBytes( dist.resolve("MyAppIOS.xcodeproj/project.pbxproj")), StandardCharsets.UTF_8); assertTrue(pbxproj.contains("CoreText.framework"),