From 1a2d6a6e122a7412defb301bd59c332104b80d66 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 18 Sep 2026 06:35:53 +0300 Subject: [PATCH 1/4] Enforce Apple's iOS 27 launch-screen and scene-lifecycle rules (#5839) Apps linked with the iOS 27 SDK are rejected unless the bundle declares a launch screen, and fail to launch unless they adopt the UIScene lifecycle (iOS & iPadOS 27 release notes, UIKit 168247372 and 141837548). The default Maven build already satisfies both. Two opt-out paths did not, and neither said so: - ios.generateSplashScreens=true skipped the launch-key injection outright. The hint named the legacy Default*.png generator, and that generator is gone -- generateLaunchScreen() runs unconditionally now -- so all the suppression still did was produce a bundle with no launch key in it at all, which nothing else here would fill in. UIRequiresFullScreen, which the hint does still inject, is not a substitute (TN3192). The hint keeps its iPad multitasking effect and logs a deprecation notice. - ios.uiscene=false skipped UIApplicationSceneManifest. Refused outright against SDK 27 and later, and refused at the top of build() rather than in injectToPlist: the same hint selects CN1_USE_UI_SCENE in the generated Objective-C, so by the time the plist is written an hour has been spent on an archive Apple documents as not launching. Forcing the lifecycle back on instead would be a silent override of an explicit hint. Both conditional on the SDK major, read from the SDK this build actually links against and only falling back to the Xcode version when xcrun cannot answer -- sound in the direction it is used, since every Xcode predating the 26 renumbering reports far below the floor. An SDK 26 build is unchanged. The backstop is launchMetadataRejection, which reads the finished Info.plist rather than the generator strings. Every producer here decides whether to ADD a key and steps aside when the injection already names one, so an ios.plistInject that mentions a launch key in a comment, or declares it below the root dictionary, silences the generator and leaves UIKit nothing to read. Only the finished document can tell those apart. All four of Apple's launch keys are accepted; the check is that a launch experience exists, not that it is ours. Also declares desktop.themeMode in the build-hint catalog. It went in undeclared with the native desktop themes and fails check-build-hint-catalog on master, which would have failed this PR's CI too. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/annotations/buildhints/Ios.java | 9 +- .../developer-guide/Working-With-iOS.asciidoc | 6 +- .../build/shared/BuildHintsDesktop.java | 10 + .../codename1/build/shared/BuildHintsIos.java | 8 +- .../com/codename1/builders/IPhoneBuilder.java | 241 +++++++++++++++--- .../IPhoneBuilderLaunchMetadataTest.java | 191 ++++++++++++++ 6 files changed, 429 insertions(+), 36 deletions(-) create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderLaunchMetadataTest.java diff --git a/CodenameOne/src/com/codename1/annotations/buildhints/Ios.java b/CodenameOne/src/com/codename1/annotations/buildhints/Ios.java index 73a05db6a88..18f03f51507 100644 --- a/CodenameOne/src/com/codename1/annotations/buildhints/Ios.java +++ b/CodenameOne/src/com/codename1/annotations/buildhints/Ios.java @@ -175,10 +175,11 @@ /// 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. + /// improving lifecycle handling in modern iOS versions. Apple requires the + /// scene lifecycle of every app linked with the iOS 27 SDK or later, and an + /// app built without it fails to launch, so the opt-out is refused at build + /// time against those SDKs: setting the flag to `false` only has an effect + /// when building against an older SDK. Toggle uiscene() default Toggle.DEFAULT; /// Allows intercepting a URL call using the syntax `urlPrefix` diff --git a/docs/developer-guide/Working-With-iOS.asciidoc b/docs/developer-guide/Working-With-iOS.asciidoc index ec9872b3fd0..5018e4f5cf4 100644 --- a/docs/developer-guide/Working-With-iOS.asciidoc +++ b/docs/developer-guide/Working-With-iOS.asciidoc @@ -23,7 +23,11 @@ If you've access to a Mac, connect the device, open Xcode, and use the device ex [[ios-launch-storyboard]] === Launch screen storyboard 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. +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. + +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 a `ios.plistInject` that removes the generated key stops the build rather than producing an archive the App Store refuses. + +The `ios.generateSplashScreens=true` build hint is deprecated. The generator it named is gone, so it no longer opts back into anything; all it still does is disable iPad multitasking. It no longer suppresses the generated launch screen either, because that only ever produced a bundle with no launch metadata at all. To supply a launch experience of your own, customize the files below or declare your own launch key through `ios.plistInject`. ==== Key files diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDesktop.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDesktop.java index 49e9274bfdd..63a25f5102f 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDesktop.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDesktop.java @@ -45,6 +45,16 @@ private BuildHintsDesktop() { static void register(List h) { + h.add(new Hint("desktop.themeMode") + .group(HintGroup.DESKTOP) + .type(HintType.STRING) + .def("legacy") + .platform("desktop") + .doc("`legacy` (default) or `native`. Selects the theme the packaged desktop app " + + "starts with: `native` loads the generated native desktop theme for the " + + "host platform, `legacy` keeps the existing cross-platform look. Unset, " + + "it follows the shared `nativeTheme` hint.")); + h.add(new Hint("desktop.title") .group(HintGroup.DESKTOP) .type(HintType.STRING) 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..beebeb04eb3 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 @@ -521,8 +521,12 @@ static void register(List h) { .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.")); + .doc("Deprecated, boolean true/false defaults to false. The legacy splash-image " + + "generator this hint named has been removed, and the hint no longer " + + "suppresses the generated launch screen -- an app linked with the iOS 27 " + + "SDK is rejected without one. All it still does is disable iPad " + + "multitasking (UIRequiresFullScreen). Use ios.plistInject to declare a " + + "launch screen of your own.")); h.add(new Hint("ios.glAppDelegateBody") .group(HintGroup.IOS) 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..8702d1a21ee 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,26 @@ public boolean build(File sourceZip, BuildRequest request) throws BuildException xcodeVersion = 10; } + // The SDK, not the Xcode, is what Apple's launch-screen and scene-lifecycle rules are + // 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 rules ON for a build the SDK exempts. + iosSdkMajor = iosSdkMajorVersion(activeIosSdkName(request)); + if (iosSdkMajor < 0) { + iosSdkMajor = xcodeVersion; + } + // Fail here rather than after the natives, the project and the archive have been + // generated: ios.uiscene also selects CN1_USE_UI_SCENE in the generated Objective-C, so + // by the time injectToPlist could notice, an hour of build has already been spent on a + // bundle Apple documents as failing to launch. + String lifecycleRejection = sceneLifecycleOptOutRejection( + request.getArg("ios.uiscene", "true"), iosSdkMajor); + if (lifecycleRejection != null) { + throw new BuildException(lifecycleRejection); + } + String facebookAppId = request.getArg("facebook.appId", null); boolean usePodsForFacebook = !request.getArg("ios.facebook.usePods", "true").equals("false") && facebookAppId != null && facebookAppId.length() > 0; if (usePodsForFacebook) { @@ -10324,6 +10354,137 @@ 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; + } + } + + /// Why a build must be refused for turning the scene lifecycle off, or null to proceed. + /// + /// An app linked with SDK 27 or later that does not adopt the UIScene lifecycle does not + /// launch -- Apple documents that as the outcome, not as a warning, so a build that + /// honoured the opt-out here would hand the developer an archive that cannot run. The + /// alternative to refusing is to force the lifecycle back on, and that is worse: it is a + /// silent override of an explicit hint, and the hint exists precisely because an app can + /// have a reason to want the old lifecycle. Refusing says which of the two it is. + /// + /// Scoped to an explicit `false`. The hint defaults to true, so an absent hint has + /// nothing to reject and every ordinary build is untouched. + /// + /// #### Parameters + /// + /// - `uisceneArg`: the ios.uiscene hint as the request carries it, defaulted to "true" + /// + /// - `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 there is nothing to refuse + static String sceneLifecycleOptOutRejection(String uisceneArg, int sdkMajor) { + if (sdkMajor < FIRST_SDK_REQUIRING_LAUNCH_METADATA) { + return null; + } + if (uisceneArg == null || !"false".equalsIgnoreCase(uisceneArg.trim())) { + return null; + } + return "ios.uiscene=false cannot be honored against the iOS " + sdkMajor + + " SDK. Apple requires the UIScene lifecycle of every app linked with SDK 27 " + + "or later; an app built without it fails to launch (iOS & iPadOS 27 release " + + "notes, UIKit 141837548). Remove the ios.uiscene build hint to use the " + + "supported scene lifecycle; the legacy UIApplicationDelegate lifecycle is " + + "only reachable by building against an older SDK. Migration guidance is in " + + "Apple TN3187. If your app works under the legacy lifecycle and not under " + + "scenes, please report it -- that is a bug in Codename One, and the opt-out " + + "is no longer a way around it."; + } + + /// 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; + } + // All four, because Apple accepts all four and an app is entitled to supply whichever + // one describes its launch experience. This is a check that a launch experience exists, + // never a preference for the one this builder happens to generate. + String[] accepted = { + "UILaunchStoryboardName", "UILaunchStoryboards", "UILaunchScreen", "UILaunchScreens" + }; + boolean declared = false; + for (String key : accepted) { + 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. @@ -16239,6 +16400,11 @@ public boolean accept(File file, String string) { if(request.getArg("ios.generateSplashScreens", "false").equals( "true")) { multitasking = false; + log("ios.generateSplashScreens is deprecated and no longer suppresses the generated " + + "launch screen: the legacy splash-image generator it named was removed, and " + + "an app linked with the iOS 27 SDK is rejected without a launch screen. The " + + "hint still disables iPad multitasking. Declare your own launch screen via " + + "ios.plistInject if you need a different one."); } if (multitasking && useMetal && getDeploymentTargetInt(request) < 14) { // An explicit ios.deployment_target below 14 cannot satisfy the @@ -16259,35 +16425,41 @@ 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")+""; + // ios.generateSplashScreens is deliberately NOT read here any more. The hint named the + // legacy generator that produced a Default*.png for every screen size, and that + // generator is gone -- generateLaunchScreen() runs unconditionally and writes both + // Launch.Foreground.png and LaunchScreen.storyboard whatever the hint says. So the + // only surviving effect of suppressing this block was a bundle with no launch key in + // it at all, which Apple rejects outright for an app linked with SDK 27 (release + // notes, UIKit 168247372) and which no other code path here would have filled in. + // The hint keeps its other effect, the iPad multitasking opt-out above. + 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")); // com.codename1.ui.Window needs multiple scenes, and a Window only exists on @@ -16917,10 +17089,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/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..99569a3938b --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderLaunchMetadataTest.java @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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 sceneLifecycleOptOutIsRefusedAgainstSdk27() { + String rejection = IPhoneBuilder.sceneLifecycleOptOutRejection("false", 27); + assertNotNull(rejection); + assertTrue(rejection.contains("ios.uiscene"), rejection); + assertTrue(rejection.contains("TN3187"), rejection); + assertNotNull(IPhoneBuilder.sceneLifecycleOptOutRejection("false", 28)); + // Case and surrounding space come straight from a properties file, so neither can be + // the difference between refusing the build and shipping one that will not launch. + assertNotNull(IPhoneBuilder.sceneLifecycleOptOutRejection("FALSE", 27)); + assertNotNull(IPhoneBuilder.sceneLifecycleOptOutRejection(" false ", 27)); + } + + @Test + void sceneLifecycleOptOutStillWorksOnTheSdksApplePermitsItOn() { + assertNull(IPhoneBuilder.sceneLifecycleOptOutRejection("false", 26)); + assertNull(IPhoneBuilder.sceneLifecycleOptOutRejection("false", 16)); + // Unknown SDK: nothing is known to require the lifecycle, so nothing is refused. The + // build() fallback resolves -1 before this is reached; this is the belt to that brace. + assertNull(IPhoneBuilder.sceneLifecycleOptOutRejection("false", -1)); + } + + @Test + void theDefaultAndAnExplicitTrueAreNeverRefused() { + assertNull(IPhoneBuilder.sceneLifecycleOptOutRejection("true", 27)); + assertNull(IPhoneBuilder.sceneLifecycleOptOutRejection(null, 27)); + } + + @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)); + } +} From 5718acb4766b935a08c1d7092dcb4d7590d1799d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:16:56 +0300 Subject: [PATCH 2/4] Remove the iOS legacy-lifecycle and splash-screen hints outright (#5839) Follow-up on review: the first pass added enforcement around the opt-outs instead of deleting them. There is nothing left for either hint to select, so they are gone rather than deprecated, and so is the code each one guarded. ios.uiscene is removed from the @Ios annotation, so an app that still sets it fails to COMPILE, and from the build-hint catalog, so a build that sets it in codenameone_settings.properties is refused by name. What it selected is gone with it: CN1_USE_UI_SCENE no longer exists, the scene delegate and the app delegate's scene hooks are compiled unconditionally, the scene manifest is written on every build, and the legacy didFinishLaunching path that installed a root view controller into a window UIKit had not created yet is deleted. NSMainNibFile went with it. It was already stripped from every plist this builder produced -- MainWindow.xib auto-instantiates a UIWindow with visibleAtLaunch=YES, which under the scene lifecycle is a window with no scene and a launch FrontBoard terminates -- so the template no longer declares it, the strip is gone, and MainWindow.xib and MainWindowMETAL.xib are deleted along with the four builders' exclusion lists that named them. The IBOutlet qualifiers on the app delegate went too; nothing loads a nib into that class. ios.generateSplashScreens is removed. It named the pre-storyboard Default*.png generator, which this builder has not contained for years; all it still did was suppress the launch screen and disable iPad multitasking. Every build now declares UILaunchScreen. ios.launchStoryboardName is removed as a consequence: it named the storyboard for UILaunchStoryboardName, which only the legacy lifecycle emitted. Keeping a hint nothing reads is the failure the build-hint catalog exists to prevent. All three are refused by name, with a message that says what replaced them, and the refusal reads the request's supplied keys rather than calling getArg -- getArg is how a hint is READ, and a removed hint has no reader. Several in-tree users had to go first: our own build-ios-app.sh passed ios.uiscene on every CI iOS build, and three sample apps set it through @Ios. Also from review: - The generated launch key is skipped when the injection names ANY of Apple's four launch keys, not the two this builder can emit. A project supplying UILaunchStoryboards through ios.plistInject was getting a second launch experience appended beside its own. - A BuildException raised inside injectToPlist is re-thrown instead of being replaced by a cause-less "Failed to inject into plist", which threw away the diagnosis the refusal exists to give. The generic wrap now keeps its cause. - desktop.themeMode is NOT added to the catalog. It is already declared by the DesktopBuild annotation; check-build-hint-catalog reported it missing locally because it reads the first cn1-build-hints.json it finds and maven/javase/target held a stale one from before that hint existed. The developer guide's launch-screen section described a storyboard the launch has not used since UIScene became the default, and a Launch.Background.png only that storyboard reads. It now says what the build actually declares and how to opt into the storyboard through ios.plistInject. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/annotations/buildhints/Ios.java | 9 - .../nativeSources/CodenameOne_GLAppDelegate.h | 15 +- .../nativeSources/CodenameOne_GLAppDelegate.m | 20 +- .../CodenameOne_GLSceneDelegate.h | 2 - .../CodenameOne_GLSceneDelegate.m | 2 - Ports/iOSPort/nativeSources/MainWindow.xib | 33 -- .../iOSPort/nativeSources/MainWindowMETAL.xib | 33 -- .../developer-guide/Working-With-iOS.asciidoc | 30 +- .../build/shared/BuildHintsDesktop.java | 10 - .../codename1/build/shared/BuildHintsIos.java | 18 - .../com/codename1/builders/IPhoneBuilder.java | 309 +++++++++--------- .../codename1/builders/MacNativeBuilder.java | 4 +- .../codename1/builders/TvNativeBuilder.java | 6 +- .../builders/WatchNativeBuilder.java | 6 +- .../IPhoneBuilderLaunchMetadataTest.java | 76 ++++- ...oneBuilderSceneManifestValidationTest.java | 26 +- .../maven/CommandLineBuildHintTest.java | 4 +- scripts/build-ios-app.sh | 3 - .../com/codenameone/fidelity/FidelityApp.java | 2 +- .../hellocodenameone/HelloCodenameOne.kt | 2 +- .../common/codenameone_settings.properties | 1 - .../purchasetest/PurchaseTestApp.java | 2 +- .../src/template/template/template-Info.plist | 2 - 23 files changed, 279 insertions(+), 336 deletions(-) delete mode 100644 Ports/iOSPort/nativeSources/MainWindow.xib delete mode 100644 Ports/iOSPort/nativeSources/MainWindowMETAL.xib diff --git a/CodenameOne/src/com/codename1/annotations/buildhints/Ios.java b/CodenameOne/src/com/codename1/annotations/buildhints/Ios.java index 18f03f51507..f51ca64117a 100644 --- a/CodenameOne/src/com/codename1/annotations/buildhints/Ios.java +++ b/CodenameOne/src/com/codename1/annotations/buildhints/Ios.java @@ -173,15 +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 requires the - /// scene lifecycle of every app linked with the iOS 27 SDK or later, and an - /// app built without it fails to launch, so the opt-out is refused at build - /// time against those SDKs: setting the flag to `false` only has an effect - /// when building against an older SDK. - 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 5018e4f5cf4..801bd7a5d9e 100644 --- a/docs/developer-guide/Working-With-iOS.asciidoc +++ b/docs/developer-guide/Working-With-iOS.asciidoc @@ -21,23 +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. +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. -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 a `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. -The `ios.generateSplashScreens=true` build hint is deprecated. The generator it named is gone, so it no longer opts back into anything; all it still does is disable iPad multitasking. It no longer suppresses the generated launch screen either, because that only ever produced a bundle with no launch metadata at all. To supply a launch experience of your own, customize the files below or declare your own launch key through `ios.plistInject`. +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 @@ -62,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/BuildHintsDesktop.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDesktop.java index 63a25f5102f..49e9274bfdd 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDesktop.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDesktop.java @@ -45,16 +45,6 @@ private BuildHintsDesktop() { static void register(List h) { - h.add(new Hint("desktop.themeMode") - .group(HintGroup.DESKTOP) - .type(HintType.STRING) - .def("legacy") - .platform("desktop") - .doc("`legacy` (default) or `native`. Selects the theme the packaged desktop app " - + "starts with: `native` loads the generated native desktop theme for the " - + "host platform, `legacy` keeps the existing cross-platform look. Unset, " - + "it follows the shared `nativeTheme` hint.")); - h.add(new Hint("desktop.title") .group(HintGroup.DESKTOP) .type(HintType.STRING) 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 beebeb04eb3..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,18 +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("Deprecated, boolean true/false defaults to false. The legacy splash-image " - + "generator this hint named has been removed, and the hint no longer " - + "suppresses the generated launch screen -- an app linked with the iOS 27 " - + "SDK is rejected without one. All it still does is disable iPad " - + "multitasking (UIRequiresFullScreen). Use ios.plistInject to declare a " - + "launch screen of your own.")); - h.add(new Hint("ios.glAppDelegateBody") .group(HintGroup.IOS) .type(HintType.STRING) @@ -658,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 8702d1a21ee..b7b0bd8bd8a 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 @@ -2167,24 +2167,19 @@ public boolean build(File sourceZip, BuildRequest request) throws BuildException xcodeVersion = 10; } - // The SDK, not the Xcode, is what Apple's launch-screen and scene-lifecycle rules are - // 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 rules ON for a build the SDK exempts. + // 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; } - // Fail here rather than after the natives, the project and the archive have been - // generated: ios.uiscene also selects CN1_USE_UI_SCENE in the generated Objective-C, so - // by the time injectToPlist could notice, an hour of build has already been spent on a - // bundle Apple documents as failing to launch. - String lifecycleRejection = sceneLifecycleOptOutRejection( - request.getArg("ios.uiscene", "true"), iosSdkMajor); - if (lifecycleRejection != null) { - throw new BuildException(lifecycleRejection); + String removedHintRejection = removedHintRejection(request.getArgs()); + if (removedHintRejection != null) { + throw new BuildException(removedHintRejection); } String facebookAppId = request.getArg("facebook.appId", null); @@ -3479,13 +3474,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 @@ -3537,7 +3530,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 = ""; @@ -3715,14 +3707,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) { @@ -8044,8 +8028,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); } @@ -10387,43 +10378,107 @@ static int iosSdkMajorVersion(String sdkName) { } } - /// Why a build must be refused for turning the scene lifecycle off, or null to proceed. + /// 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. /// - /// An app linked with SDK 27 or later that does not adopt the UIScene lifecycle does not - /// launch -- Apple documents that as the outcome, not as a warning, so a build that - /// honoured the opt-out here would hand the developer an archive that cannot run. The - /// alternative to refusing is to force the lifecycle back on, and that is worse: it is a - /// silent override of an explicit hint, and the hint exists precisely because an app can - /// have a reason to want the old lifecycle. Refusing says which of the two it is. + /// #### Parameters /// - /// Scoped to an explicit `false`. The hint defaults to true, so an absent hint has - /// nothing to reject and every ordinary build is untouched. + /// - `suppliedHints`: every build-hint name the request carries /// - /// #### Parameters + /// #### Returns /// - /// - `uisceneArg`: the ios.uiscene hint as the request carries it, defaulted to "true" + /// 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(); + } + + /// The launch-screen keys Apple accepts, in the order its release notes name them. /// - /// - `sdkMajor`: the major version of the SDK being linked against, or -1 if unknown + /// 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" + }; + + /// 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 /// - /// the message to fail the build with, or null when there is nothing to refuse - static String sceneLifecycleOptOutRejection(String uisceneArg, int sdkMajor) { - if (sdkMajor < FIRST_SDK_REQUIRING_LAUNCH_METADATA) { - return null; + /// true when the fragment already names a launch key + static boolean plistNamesAnyLaunchKey(String inject) { + if (inject == null) { + return false; } - if (uisceneArg == null || !"false".equalsIgnoreCase(uisceneArg.trim())) { - return null; + for (String key : ACCEPTED_LAUNCH_KEYS) { + if (inject.contains(key)) { + return true; + } } - return "ios.uiscene=false cannot be honored against the iOS " + sdkMajor - + " SDK. Apple requires the UIScene lifecycle of every app linked with SDK 27 " - + "or later; an app built without it fails to launch (iOS & iPadOS 27 release " - + "notes, UIKit 141837548). Remove the ios.uiscene build hint to use the " - + "supported scene lifecycle; the legacy UIApplicationDelegate lifecycle is " - + "only reachable by building against an older SDK. Migration guidance is in " - + "Apple TN3187. If your app works under the legacy lifecycle and not under " - + "scenes, please report it -- that is a bug in Codename One, and the opt-out " - + "is no longer a way around it."; + return false; } /// Why a finished Info.plist must be refused, or null when it satisfies Apple's rules. @@ -10453,14 +10508,8 @@ static String launchMetadataRejection(String plist, int sdkMajor) { // and will say so; inventing a launch-screen failure for it would be a misdiagnosis. return null; } - // All four, because Apple accepts all four and an app is entitled to supply whichever - // one describes its launch experience. This is a check that a launch experience exists, - // never a preference for the one this builder happens to generate. - String[] accepted = { - "UILaunchStoryboardName", "UILaunchStoryboards", "UILaunchScreen", "UILaunchScreens" - }; boolean declared = false; - for (String key : accepted) { + for (String key : ACCEPTED_LAUNCH_KEYS) { if (plistMemberRange(plist, root[0], root[1], key) != null) { declared = true; break; @@ -15530,16 +15579,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. @@ -15558,14 +15608,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) { @@ -16306,13 +16352,13 @@ 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. // nothing to inject here? move along String inject = request.getArg("ios.plistInject", "CFBundleShortVersionString " + buildVersion +""); @@ -16397,15 +16443,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; - log("ios.generateSplashScreens is deprecated and no longer suppresses the generated " - + "launch screen: the legacy splash-image generator it named was removed, and " - + "an app linked with the iOS 27 SDK is rejected without a launch screen. The " - + "hint still disables iPad multitasking. Declare your own launch screen via " - + "ios.plistInject if you need a different one."); - } 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 @@ -16425,73 +16462,45 @@ public boolean accept(File file, String string) { inject += "\nUIRequiresFullScreen\n"; } } - // ios.generateSplashScreens is deliberately NOT read here any more. The hint named the - // legacy generator that produced a Default*.png for every screen size, and that - // generator is gone -- generateLaunchScreen() runs unconditionally and writes both - // Launch.Foreground.png and LaunchScreen.storyboard whatever the hint says. So the - // only surviving effect of suppressing this block was a bundle with no launch key in - // it at all, which Apple rejects outright for an app linked with SDK 27 (release - // notes, UIKit 168247372) and which no other code path here would have filled in. - // The hint keeps its other effect, the iPad multitasking opt-out above. - 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")+""; + // 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" + + ""; } - boolean useUISceneManifest = "true".equalsIgnoreCase(request.getArg("ios.uiscene", "true")); // 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" @@ -16503,7 +16512,6 @@ public boolean accept(File file, String string) { + " \n" + " \n" : ""; - String windowScene = useUISceneManifest ? WINDOW_SCENE_ROLE : ""; inject += "\nUIApplicationSceneManifest\n" + "\n" + " UIApplicationSupportsMultipleScenes\n" @@ -16526,7 +16534,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" + ""; 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 index 99569a3938b..200c7382857 100644 --- 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 @@ -22,9 +22,14 @@ */ package com.codename1.builders; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + import org.junit.jupiter.api.Test; 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; @@ -87,31 +92,68 @@ void anUnversionedSdkNameIsUnknownRatherThanAnyParticularVersion() { } @Test - void sceneLifecycleOptOutIsRefusedAgainstSdk27() { - String rejection = IPhoneBuilder.sceneLifecycleOptOutRejection("false", 27); + 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("ios.uiscene"), rejection); + assertTrue(rejection.contains("has been removed"), rejection); assertTrue(rejection.contains("TN3187"), rejection); - assertNotNull(IPhoneBuilder.sceneLifecycleOptOutRejection("false", 28)); - // Case and surrounding space come straight from a properties file, so neither can be - // the difference between refusing the build and shipping one that will not launch. - assertNotNull(IPhoneBuilder.sceneLifecycleOptOutRejection("FALSE", 27)); - assertNotNull(IPhoneBuilder.sceneLifecycleOptOutRejection(" false ", 27)); } @Test - void sceneLifecycleOptOutStillWorksOnTheSdksApplePermitsItOn() { - assertNull(IPhoneBuilder.sceneLifecycleOptOutRejection("false", 26)); - assertNull(IPhoneBuilder.sceneLifecycleOptOutRejection("false", 16)); - // Unknown SDK: nothing is known to require the lifecycle, so nothing is refused. The - // build() fallback resolves -1 before this is reached; this is the belt to that brace. - assertNull(IPhoneBuilder.sceneLifecycleOptOutRejection("false", -1)); + 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 theDefaultAndAnExplicitTrueAreNeverRefused() { - assertNull(IPhoneBuilder.sceneLifecycleOptOutRejection("true", 27)); - assertNull(IPhoneBuilder.sceneLifecycleOptOutRejection(null, 27)); + void anInjectionThatNamesNoLaunchKeyGetsTheGeneratedOne() { + assertFalse(IPhoneBuilder.plistNamesAnyLaunchKey( + "UIRequiresFullScreen")); + assertFalse(IPhoneBuilder.plistNamesAnyLaunchKey("")); + assertFalse(IPhoneBuilder.plistNamesAnyLaunchKey(null)); } @Test 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..c62e4fa7f2b 100644 --- a/vm/ByteCodeTranslator/src/template/template/template-Info.plist +++ b/vm/ByteCodeTranslator/src/template/template/template-Info.plist @@ -53,8 +53,6 @@ VERSION_BUNDLE_VALUE LSRequiresIPhoneOS - NSMainNibFile - MainWindow UISupportedInterfaceOrientations UIInterfaceOrientationPortrait From 7dcc84c71c4079206d62d4dd2ca1033a41e6e32b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:44:21 +0300 Subject: [PATCH 3/4] Declare the scene manifest and launch screen in the translator template (#5855 review) ByteCodeTranslator copies template-Info.plist into the generated project and nothing else writes to it: injectToPlist is IPhoneBuilder's, so a project translated directly gets exactly what the template says. That was survivable while the port's natives could still run the legacy lifecycle -- the template's NSMainNibFile and the absent scene manifest agreed with each other, because the CN1_USE_UI_SCENE define was IPhoneBuilder's too and was absent along with them. Deleting the legacy lifecycle from the natives broke that agreement in one direction only. The natives now require a scene manifest to get a window at all, and the only producer that writes one is the builder. A native that depends on a key one of its two producers writes is a key it can lose. So the template declares both, where NSMainNibFile used to be, and the builder strips them back out before writing its own -- it has to write its own, because CarPlay adds a second role and ios.plistInject can replace either outright, and a property list takes the LAST of a duplicated key, which would make the one UIKit reads a function of where the injection was spliced. All four launch keys come out, not just the one the template uses: whichever is in that file is ours, since ios.plistInject is a separate fragment added after the strip. The launch-key half of this was already true before this branch -- the template has never carried one -- so this also closes the standalone path against the iOS 27 rejection the rest of the PR is about. handleIosOutputGeneratesProjectStructure now asserts the generated plist wires CodenameOne_GLSceneDelegate and declares a launch key. Verified it bites: removing the two keys from the template again fails it on the scene manifest. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 46 +++++++++++++++++++ .../IPhoneBuilderLaunchMetadataTest.java | 32 +++++++++++++ .../src/template/template/template-Info.plist | 22 +++++++++ .../BytecodeInstructionIntegrationTest.java | 21 +++++++++ 4 files changed, 121 insertions(+) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index b7b0bd8bd8a..78db71f910c 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 @@ -10454,6 +10454,33 @@ static String removedHintRejection(Set suppliedHints) { "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 @@ -16360,6 +16387,25 @@ public boolean accept(File file, String string) { // 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 String inject = request.getArg("ios.plistInject", "CFBundleShortVersionString " + buildVersion +""); 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 index 200c7382857..dabb16e6369 100644 --- 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 @@ -156,6 +156,38 @@ void anInjectionThatNamesNoLaunchKeyGetsTheGeneratedOne() { 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 aBundleWithLaunchScreenAndSceneManifestPasses() { assertNull(IPhoneBuilder.launchMetadataRejection( diff --git a/vm/ByteCodeTranslator/src/template/template/template-Info.plist b/vm/ByteCodeTranslator/src/template/template/template-Info.plist index c62e4fa7f2b..6af419d069b 100644 --- a/vm/ByteCodeTranslator/src/template/template/template-Info.plist +++ b/vm/ByteCodeTranslator/src/template/template/template-Info.plist @@ -53,6 +53,28 @@ VERSION_BUNDLE_VALUE LSRequiresIPhoneOS + 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"), From 631d19d08dc06a52aee57fb356e52afab017abd5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 18 Sep 2026 09:05:08 +0300 Subject: [PATCH 4/4] Refuse an iOS port bundle older than the plugin running it (#5855 review) Raised against the BuildDaemon PR: if a project can pin an older iOS port while running a newer builder, it has the same exposure. Checked the Maven plugin. Through supported configuration it cannot. codenameone-ios:bundle is a dependency OF codenameone-maven-plugin, version-managed to the plugin's own version, and Executor.getResourceAsStream is Executor.class.getResourceAsStream -- the plugin realm. The generated app pom declares the plugin with no block (the only plugin-level one it has is on surefire, pinning the CN1 plugin itself), and no mojo resolves codenameone-ios from the project. So cn1.version governs codenameone-core on the compile classpath and has no say in which iOSPort.jar/nativeios.jar is unzipped. A hand-written override on the plugin declaration does reach it, and Maven honours it. That combination used to be merely odd; deleting the legacy lifecycle from the port's natives made it fatal and silent, so it is refused here instead. Measured on the real artifacts rather than argued: - codenameone-ios 7.0.214's nativeios.jar carries no CodenameOne_GLSceneDelegate at all. - The pre-branch 8.0 bundle carries it behind #ifdef CN1_USE_UI_SCENE, a define this plugin no longer injects. Compiling those exact sources against the iOS 27 SDK: 19432 bytes of object code with the define, 1248 without -- no class, no methods, no diagnostic. Either way the Info.plist this build writes names CodenameOne_GLSceneDelegate as the scene delegate, so UIKit looks up a class the binary does not contain. The build stays green and the app fails at launch. Worth recording because it corrects the obvious guess: the main NIB is not the fallback it looks like. An older bundle still ships MainWindow.xib -- 7.0.214 does -- so deleting it from this tree is not what removes the legacy path for a pinned port. NSMainNibFile comes from the translator template, which is codenameone-parparvm at the PLUGIN's version, so the key that would load that nib is gone whatever the port carries. The reverse skew needs nothing: a newer port under an older plugin fails loudly already, because that plugin's Metal branch copies MainWindowMETAL.xib and Executor.copy opens the source with new FileInputStream. Verified the guard against all three real inputs: 7.0.214 refused, stale 8.0 refused, this branch's nativeSources accepted. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 64 +++++++++++++++++++ .../IPhoneBuilderLaunchMetadataTest.java | 46 +++++++++++++ 2 files changed, 110 insertions(+) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 78db71f910c..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 @@ -3386,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")) { @@ -10443,6 +10452,61 @@ static String removedHintRejection(Set suppliedHints) { 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 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 index dabb16e6369..5763d115d9e 100644 --- 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 @@ -22,11 +22,15 @@ */ 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; @@ -188,6 +192,48 @@ void everyLaunchKeyKindComesOutNotJustTheOneTheTemplateUses() { } } + @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(