e : hints.entrySet()) {
+ sb.append(e.getKey()).append('=').append(e.getValue()).append('\n');
+ }
+ return sha256Hex(sb.toString());
+ }
+
+ /** SHA-256 of {@code s} as lowercase hex (falls back to the string hash if SHA-256 is somehow absent). */
+ private static String sha256Hex(String s) {
+ try {
+ java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256");
+ byte[] d = md.digest(s.getBytes(java.nio.charset.Charset.forName("UTF-8")));
+ StringBuilder sb = new StringBuilder(d.length * 2);
+ for (byte b : d) {
+ sb.append(Character.forDigit((b >> 4) & 0xf, 16));
+ sb.append(Character.forDigit(b & 0xf, 16));
+ }
+ return sb.toString();
+ } catch (java.security.NoSuchAlgorithmException ex) {
+ return Integer.toHexString(s.hashCode());
+ }
+ }
+
+ /** Reads a small text file's trimmed content, or {@code null} if it is absent or unreadable. */
+ private static String readTextFileOrNull(File f) {
+ if (f == null || !f.isFile()) {
+ return null;
+ }
+ try {
+ return new String(java.nio.file.Files.readAllBytes(f.toPath()),
+ java.nio.charset.Charset.forName("UTF-8")).trim();
+ } catch (IOException ex) {
+ return null;
+ }
+ }
+
+ /** Writes {@code content} to {@code f} (UTF-8), creating parent directories as needed. */
+ private static void writeStringToFile(File f, String content) throws IOException {
+ if (f.getParentFile() != null) {
+ f.getParentFile().mkdirs();
+ }
+ java.nio.file.Files.write(f.toPath(),
+ content.getBytes(java.nio.charset.Charset.forName("UTF-8")));
+ }
+
+ /**
+ * App-hardening pre-flight (Check 1). Validates {@code harden.level} and refuses targets that
+ * cannot be hardened before a build is spent. Runs for every target: for cloud targets it fails
+ * fast client-side before submission; for local/source targets it stops (or, with the escape
+ * hatch, forces hardening off) because a locally built binary never reaches the server and its
+ * mapping would be orphaned from the crash-symbolication service.
+ */
+ /**
+ * The hardening pre-flight decisions, carried on the Mojo INSTANCE (not JVM-global state) so a
+ * concurrent module build under {@code mvn -T} cannot clobber them. Each reactor module executes its
+ * own CN1BuildMojo instance, so instance fields are naturally per-build; a shared System property was
+ * racy -- another platform's build could clear {@code cn1.harden.forceOff} between this build setting
+ * it and the Executor reading it, running hardening locally despite the escape-hatch decision. These
+ * are injected into this build's BuildRequest ({@link #applyHardeningRequestArgs}) so the Executor
+ * reads them from the request it was handed rather than from process-wide state.
+ */
+ private boolean hardeningForceOff;
+ private String hardeningLibraryJars;
+ /** True once the pre-flight has resolved that hardening will ACTUALLY run for this build (non-off,
+ * not force-off). Used to publish the library classpath the engine needs. */
+ private boolean hardeningWillRun;
+ /** A fingerprint of the hardening OUTCOME for this build ("unhardened", or "hardened:<level>"),
+ * recorded next to the Android APK so the timestamp-only up-to-date cache -- which cannot see a
+ * hardening change made through a build hint -- is invalidated when the outcome changes in EITHER
+ * direction (enabling or disabling hardening), not just off->on. */
+ private String hardeningCacheKey = "unhardened";
+
+ /** Injects the pre-flight hardening decisions into this build's request (per-build, not global). */
+ private void applyHardeningRequestArgs(BuildRequest r) {
+ if (hardeningForceOff) {
+ r.putArgument("cn1.harden.forceOff", "true");
+ }
+ if (hardeningLibraryJars != null && hardeningLibraryJars.length() > 0) {
+ r.putArgument("cn1.hardening.libraryJars", hardeningLibraryJars);
+ }
+ }
+
+ private void applyHardeningPreflight() throws BuildFailureException {
+ Properties settings = new Properties();
+ File settingsFile = new File(getCN1ProjectDir(), "codenameone_settings.properties");
+ if (settingsFile.isFile()) {
+ try (FileInputStream fis = new FileInputStream(settingsFile)) {
+ settings.load(fis);
+ } catch (IOException ex) {
+ getLog().debug("Could not read codenameone_settings.properties for hardening pre-flight", ex);
+ }
+ }
+ // A hint set by @Hardening reaches the settings only in createAntProject, which runs after
+ // the Android up-to-date short-circuit below and after hardeningCacheKey is read from it.
+ // Without this the early pass computes "unhardened" from the file while the completed build
+ // records "hardened:...", the two never match, and an up-to-date APK is rebuilt on every
+ // invocation -- and, worse, an unsupported hardening request made through an annotation
+ // escapes the refusal this early pass exists to perform.
+ try {
+ mergeAnnotationBuildHints(settings, compileClasspathElements());
+ } catch (Exception ex) {
+ getLog().debug("Could not read annotation build hints for the hardening pre-flight", ex);
+ }
+ // Overlay -D command-line hints (e.g. -Dcodename1.arg.harden.level=standard) so an explicit
+ // hardening request made only on the command line is seen by this early check -- and, because this
+ // runs before the Android up-to-date cache short-circuit, is not silently dropped when a prior APK
+ // is newer than the sources (getSourcesModificationTime does not account for build hints).
+ // After the annotations, so -D still wins over one.
+ overlayCommandLineBuildHints(settings);
+ applyHardeningPreflight(settings);
+ }
+
+ /**
+ * Refuses an iOS device build whose provisioning profile makes signing impossible, before
+ * the build is packaged and sent.
+ *
+ * These failures used to be discoverable only on a build server: an unreadable profile
+ * surfaced as an XML parser stack trace that never named the profile, and a profile of the
+ * wrong kind surfaced as an {@code exportArchive} error minutes into the build, after the
+ * whole app had already been compiled and archived. Both are decided by a file sitting in
+ * the project.
+ *
+ *
Only cloud iOS device builds are checked. A local Xcode-project generation signs
+ * later (or not at all), and the native-Mac targets ride the same platform with a different
+ * signing identity, so neither is this check's business.
+ */
+ private void applyIOSProvisioningPreflight() throws BuildFailureException {
+ if (!isIOSDeviceBuild()) {
+ return;
+ }
+ Properties settings = new Properties();
+ File settingsFile = new File(getCN1ProjectDir(), "codenameone_settings.properties");
+ if (settingsFile.isFile()) {
+ try (FileInputStream fis = new FileInputStream(settingsFile)) {
+ settings.load(fis);
+ } catch (IOException ex) {
+ getLog().debug("Could not read codenameone_settings.properties for the iOS provisioning pre-flight", ex);
+ return;
+ }
+ } else {
+ return;
+ }
+ overlayCommandLineBuildHints(settings);
+
+ // Only what the file itself decides. Whether the profile's KIND matches the export
+ // method cannot be judged yet: a CN1Lib can supply ios.*.distributionMethod through
+ // its appended/required properties, which createAntProject merges further down, so
+ // that comparison waits for applyIOSProvisioningPreflight(Properties) below.
+ report(IOSProvisioningPreflight.checkProfileFile(settings,
+ IOSProvisioningPreflight.isReleaseTarget(buildTarget), new Date()));
+ }
+
+ private boolean isIOSDeviceBuild() {
+ return IOSProvisioningPreflight.appliesTo(platform, buildTarget);
+ }
+
+ /**
+ * The full pre-flight, run against the settings the build is actually submitted with --
+ * the project's own, plus the command-line overlay, plus every CN1Lib-contributed
+ * property. This is where a profile-kind/distribution-method mismatch is decided, since
+ * a library can still change the method after the early pass has run.
+ */
+ private void applyIOSProvisioningPreflight(Properties mergedSettings) throws BuildFailureException {
+ if (!isIOSDeviceBuild()) {
+ return;
+ }
+ boolean release = IOSProvisioningPreflight.isReleaseTarget(buildTarget);
+ report(IOSProvisioningPreflight.check(mergedSettings, release, new Date()));
+ // Every embedded app extension needs a profile of its own, and the app's profile says
+ // whether it can stand in for one. Run after check() so an unreadable or expired
+ // profile is reported as itself rather than as an extension problem.
+ // The directory holding app_extensions, where packaging takes them from:
+ // the ios module under Maven, src/ios under Gradle, native/ios under Ant.
+ report(IOSProvisioningPreflight.checkAppExtensions(mergedSettings, release,
+ host.layout().iosAppExtensionsDir().getParentFile()));
+ report(IOSProvisioningPreflight.checkGeneratedExtensions(mergedSettings, release));
+ report(IOSProvisioningPreflight.checkContinuitySync(mergedSettings, release));
+ }
+
+ private void report(List problems) throws BuildFailureException {
+ String fatal = null;
+ for (IOSProvisioningPreflight.Problem problem : problems) {
+ if (problem.fatal) {
+ getLog().error(problem.message);
+ if (fatal == null) {
+ fatal = problem.message;
+ }
+ } else {
+ getLog().warn(problem.message);
+ }
+ }
+ if (fatal != null) {
+ throw new BuildFailureException(fatal);
+ }
+ }
+
+ /**
+ * Runs the hardening pre-flight against a given set of effective settings. Called first with the
+ * project's own {@code codenameone_settings.properties} (fail-fast before the merged jar is built),
+ * and again after {@code createAntProject} merges the CN1Lib-contributed
+ * {@code codenameone_library_appended/required.properties} -- a library can turn hardening on via
+ * {@code codename1.arg.harden.level}, which the early call cannot see, and without this second pass
+ * such a build would slip past the local-build refusal / force-off and produce a locally hardened
+ * artifact whose mapping is never uploaded.
+ */
+ private void applyHardeningPreflight(Properties settings) throws BuildFailureException {
+ String level = settings.getProperty("codename1.arg.harden.level", "off");
+ // A per-platform opt-out (harden..enabled=false) means hardening won't run for
+ // this target, so the pre-flight must not reject it -- treat the level as off. The native-Mac
+ // targets ride the iOS pipeline with platform=ios, so derive their opt-out key from the
+ // build target instead (matching IPhoneBuilder, which reports "mac" for them).
+ String hardenPlatform = hardenPlatformForBuildTarget(buildTarget);
+ if (hardenPlatform == null) {
+ hardenPlatform = normalizeHardenPlatform(platform);
+ }
+ // A combined Apple build (iOS app + native-Mac/watch/tvOS slice) hardens ONE shared jar, so the
+ // builder hardens it unless EVERY shipped slice is opted out (Executor.anySliceHardeningEnabled).
+ // The preflight must use that same all-slice decision: keying off only the selected slice would,
+ // e.g. with harden.mac.enabled=false but iOS still on, treat the level as off and skip the
+ // local-build/on-device-debug refusal while the engine goes on to harden the shared jar locally
+ // and orphan its mapping. A non-Apple target has a single slice, so its own opt-out still applies.
+ boolean platformOptedOut;
+ if (isNativeMacOsTarget(buildTarget)) {
+ // A native macOS target is not a slice of an iOS build. It runs its
+ // own builder against the macosx SDK, and that builder's hardening
+ // platform list is "mac" alone -- there is no shared jar and no iOS
+ // app. The all-slice rule below starts by reading harden.ios.enabled,
+ // which defaults to true and is answering about a slice this build
+ // does not ship, so harden.mac.enabled=false was overruled by it and
+ // the build was refused as a hardened local/source one that the
+ // builder would in fact have hardened nothing for.
+ //
+ // Mac Catalyst is deliberately NOT here: it really is an iOS build
+ // with a hint set, it does ship the shared jar, and the all-slice
+ // rule is right for it. hardenPlatformForBuildTarget draws the same
+ // line for the same reason.
+ platformOptedOut = isHardenFalse(
+ settings.getProperty("codename1.arg.harden.mac.enabled", "true"));
+ } else if (isAppleHardenPlatform(hardenPlatform)) {
+ platformOptedOut = allAppleHardeningSlicesOptedOut(settings);
+ } else {
+ platformOptedOut = hardenPlatform != null && isHardenFalse(
+ settings.getProperty("codename1.arg.harden." + hardenPlatform + ".enabled", "true"));
+ }
+ if (platformOptedOut) {
+ level = "off";
+ }
+ // Even at a non-off level, a build that has overridden every individual transform off
+ // (e.g. harden.rename=false, harden.strings=off, harden.controlFlow=false) requests nothing:
+ // the engine treats that as SKIPPED_NOT_REQUESTED, which is equivalent to off. Resolve the
+ // overrides to the effective transform set so such a build is not rejected on a local/source
+ // or on-device-debug target for a "hardening" it isn't actually asking for. An unknown level
+ // is NOT reduced here -- it must reach the preflight so the invalid-level check rejects it.
+ if (hardeningReducesToOff(settings, level, hardenPlatform)) {
+ level = "off";
+ }
+ boolean allowLocal = "true".equalsIgnoreCase(
+ settings.getProperty("codename1.arg.harden.allowUnhardenedLocalBuild", "false").trim());
+ boolean onDeviceDebug = "true".equalsIgnoreCase(
+ settings.getProperty("codename1.arg.android.onDeviceDebug", "false").trim())
+ || (buildTarget != null && buildTarget.contains("on-device-debug"));
+
+ HardeningPreflight.Result r = HardeningPreflight.check(level, buildTarget, allowLocal, onDeviceDebug);
+ if (r.isFailed()) {
+ throw new BuildFailureException(r.getMessage());
+ }
+ if (r.isForceOff()) {
+ getLog().warn(r.getMessage());
+ hardeningForceOff = true;
+ } else {
+ hardeningForceOff = false;
+ }
+ hardeningWillRun = !"off".equalsIgnoreCase(level.trim()) && !r.isForceOff();
+ // The APK's hardening OUTCOME. An unhardened build has one key regardless of the nominal level, so
+ // two off/force-off invocations still hit the cache. A hardened build fingerprints EVERY effective
+ // harden.* setting -- not just the level -- because harden.strings/rename/controlFlow/keep/seed all
+ // change the produced transforms and mapping, so two hardened:aggressive builds with different seeds
+ // or keep rules must still invalidate the cache. Compared against the marker recorded beside the APK.
+ hardeningCacheKey = hardeningWillRun
+ ? "hardened:" + hardeningSettingsFingerprint(settings)
+ : "unhardened";
+ // Publish the compile classpath so the hardening engine can hand it to ProGuard as library
+ // jars (so an application method that overrides a framework method is not renamed apart from
+ // its superclass). Only needed when hardening will actually run.
+ if (hardeningWillRun) {
+ try {
+ List cp = compileClasspathElements();
+ StringBuilder sb = new StringBuilder();
+ for (String element : cp) {
+ File f = new File(element);
+ if (f.isFile() && element.endsWith(".jar")) {
+ if (sb.length() > 0) {
+ sb.append(File.pathSeparator);
+ }
+ sb.append(f.getAbsolutePath());
+ }
+ }
+ hardeningLibraryJars = sb.toString();
+ } catch (Exception ex) {
+ getLog().debug("Could not resolve compile classpath for hardening library jars", ex);
+ }
+ } else {
+ hardeningLibraryJars = null;
+ }
+ }
+
+ /**
+ * True for the targets that run the standalone macOS builder rather than a
+ * slice of an iOS build.
+ *
+ * The same three {@link #hardenPlatformForBuildTarget(String)} maps to
+ * "mac", kept beside it so the two cannot come to disagree about which
+ * targets are natively macOS.
+ */
+ // Package-visible so a test can pin which targets are natively macOS; the
+ // hardening preflight's answer turns on it.
+ static boolean isNativeMacOsTarget(String buildTarget) {
+ return BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget)
+ || BUILD_TARGET_MAC_NATIVE.equals(buildTarget)
+ || BUILD_TARGET_MAC_NATIVE_LOCAL.equals(buildTarget);
+ }
+
+ /**
+ * The {@code harden..enabled} opt-out key implied by the build target, for targets
+ * whose {@code codename1.platform} does not name their real hardening platform. The macOS
+ * targets run with {@code platform=ios} but harden as "mac", so their opt-out is
+ * {@code harden.mac.enabled}. Mac Catalyst needs no entry: it has no target of its own and
+ * really is an iOS build, so {@code harden.ios.enabled} is the correct key for it.
+ * Returns {@code null} when the target carries no such override and the platform value
+ * should be used.
+ */
+ static String hardenPlatformForBuildTarget(String buildTarget) {
+ if (BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget)
+ || BUILD_TARGET_MAC_NATIVE.equals(buildTarget)
+ || BUILD_TARGET_MAC_NATIVE_LOCAL.equals(buildTarget)) {
+ return "mac";
+ }
+ return null;
+ }
+
+ /**
+ * True when a {@code harden.*} boolean setting reads as disabled, using the same tri-state rules
+ * as the engine's {@code HardeningConfig.boolTri}: {@code false}, {@code 0} and {@code off} all
+ * mean off. Recognizing only the literal {@code false} here would preflight-reject a
+ * local/source build that {@code harden..enabled=off} had actually turned off.
+ */
+ private static boolean isHardenFalse(String value) {
+ if (value == null) {
+ return false;
+ }
+ String t = value.trim().toLowerCase();
+ return "false".equals(t) || "0".equals(t) || "off".equals(t);
+ }
+
+ /** True for the Apple hardening tags whose build ships several slices from one shared hardened jar. */
+ private static boolean isAppleHardenPlatform(String hardenPlatform) {
+ return "ios".equals(hardenPlatform) || "mac".equals(hardenPlatform);
+ }
+
+ /**
+ * True only when EVERY Apple slice this build ships (the iOS app plus any native-Mac/watch/tvOS
+ * target) has opted out via {@code harden..enabled}. Mirrors IPhoneBuilder.appleHardeningSlices
+ * / Executor.anySliceHardeningEnabled from the settings so the preflight's "reduced to off" decision
+ * matches the builder's "harden unless every slice opted out". A slice is present only when its target
+ * is enabled, so an unrelated tvOS opt-out never affects a plain iOS build.
+ */
+ static boolean allAppleHardeningSlicesOptedOut(Properties settings) {
+ if (!isHardenFalse(settings.getProperty("codename1.arg.harden.ios.enabled", "true"))) {
+ return false;
+ }
+ if ("true".equals(settings.getProperty("codename1.arg.macNative.enabled", "false"))
+ && !isHardenFalse(settings.getProperty("codename1.arg.harden.mac.enabled", "true"))) {
+ return false;
+ }
+ if (appleSliceTargetEnabled(settings, "watch")
+ && !isHardenFalse(settings.getProperty("codename1.arg.harden.watch.enabled", "true"))) {
+ return false;
+ }
+ if (appleSliceTargetEnabled(settings, "tv")
+ && !isHardenFalse(settings.getProperty("codename1.arg.harden.tv.enabled", "true"))) {
+ return false;
+ }
+ return true;
+ }
+
+ /** True when the watch/tv slice is shipped: its {@code Native.enabled} or a {@code Main}. */
+ private static boolean appleSliceTargetEnabled(Properties settings, String slice) {
+ return "true".equals(settings.getProperty("codename1.arg." + slice + "Native.enabled", "false"))
+ || settings.getProperty("codename1.arg." + slice + "Main",
+ settings.getProperty("codename1.arg." + slice + "Native.mainClass", "")).trim()
+ .length() > 0;
+ }
+
+ /**
+ * True when, at this level, at least one hardening transform is still requested once the
+ * individual {@code harden.*} overrides are applied -- mirroring the engine's
+ * {@code HardeningConfig}/{@code willApplyAnyTransform} "is anything requested" decision (the
+ * platform-safety refinement is the server's, and only ever narrows this). A level whose every
+ * transform is overridden off requests nothing and is equivalent to {@code off}, so the preflight
+ * must not reject it.
+ */
+ /**
+ * True when a valid non-off level requests no transform once the {@code harden.*}
+ * overrides are applied, so it is equivalent to {@code off} and must not be rejected. An unknown
+ * or misspelled level (rank 0) returns {@code false} so it is left untouched and reaches
+ * {@link HardeningPreflight#check} -- which rejects it fast, client-side, rather than letting a
+ * cloud build be submitted for the forked engine to reject later.
+ */
+ static boolean hardeningReducesToOff(Properties settings, String level) {
+ return hardeningReducesToOff(settings, level, null);
+ }
+
+ /**
+ * As {@link #hardeningReducesToOff(Properties, String)}, but taking the resolved hardening
+ * {@code platform} so a transform the engine SKIPS as unsafe on that target does not keep the level
+ * from reducing to off. Without this, a local iOS build with only control-flow left on (which the
+ * engine skips on the ParparVM native ports), or a JavaScript build with only string encryption left
+ * on (skipped on JS), would be rejected for a hardening it would never actually apply.
+ */
+ static boolean hardeningReducesToOff(Properties settings, String level, String platform) {
+ return hardenLevelRank(level) >= 1 && !hardeningRequestsAnyTransform(settings, level, platform);
+ }
+
+ static boolean hardeningRequestsAnyTransform(Properties settings, String level) {
+ return hardeningRequestsAnyTransform(settings, level, null);
+ }
+
+ static boolean hardeningRequestsAnyTransform(Properties settings, String level, String platform) {
+ int rank = hardenLevelRank(level);
+ if (rank <= 0) {
+ return false;
+ }
+ // rank is 1..3 here (standard/aggressive/paranoid), so the standard-level defaults -- renaming
+ // and constant-string encryption -- are on unless explicitly overridden off. Control-flow is a
+ // default only from aggressive up.
+ boolean atLeastAggressive = rank >= 2;
+ // Rename is delivered on every platform (by the engine, or by R8 on Android), so it always counts.
+ boolean rename = hardenBoolTri(
+ settings.getProperty("codename1.arg.harden.rename"), true);
+ // String encryption and control-flow are subject to the engine's platform-safety rules: a
+ // transform the engine would skip on this target must not, on its own, keep the level from
+ // reducing to off. When the platform is unknown the safety checks pass (conservative -- the
+ // build is still preflighted rather than silently allowed).
+ boolean stringsOn = hardenStringsRequested(
+ settings.getProperty("codename1.arg.harden.strings"), true)
+ && stringEncryptionAppliesOn(platform);
+ boolean controlFlow = hardenBoolTri(
+ settings.getProperty("codename1.arg.harden.controlFlow"), atLeastAggressive)
+ && controlFlowAppliesOn(platform);
+ return rename || stringsOn || controlFlow;
+ }
+
+ /** Engine rule: string encryption is skipped only on JavaScript (it would break the JS bridge). */
+ private static boolean stringEncryptionAppliesOn(String platform) {
+ return !"javascript".equals(platform);
+ }
+
+ /**
+ * Engine rule: control-flow obfuscation runs only on the JVM-bytecode ports (Android, JavaSE/
+ * desktop); it is skipped on the ParparVM native ports and JavaScript. An unknown platform is
+ * treated as applicable so an ambiguous build is preflighted rather than silently allowed.
+ */
+ private static boolean controlFlowAppliesOn(String platform) {
+ if (platform == null) {
+ return true;
+ }
+ return "and".equals(platform) || "android".equals(platform)
+ || "javase".equals(platform) || "desktop".equals(platform);
+ }
+
+ /** off/empty/unknown = 0, standard = 1, aggressive = 2, paranoid = 3. */
+ private static int hardenLevelRank(String level) {
+ if (level == null) {
+ return 0;
+ }
+ String v = level.trim().toLowerCase();
+ if ("standard".equals(v)) {
+ return 1;
+ }
+ if ("aggressive".equals(v)) {
+ return 2;
+ }
+ if ("paranoid".equals(v)) {
+ return 3;
+ }
+ return 0;
+ }
+
+ /** Tri-state boolean matching the engine's {@code HardeningConfig.boolTri}. */
+ private static boolean hardenBoolTri(String value, boolean def) {
+ if (value == null) {
+ return def;
+ }
+ String t = value.trim().toLowerCase();
+ if (t.isEmpty()) {
+ return def;
+ }
+ if ("true".equals(t) || "1".equals(t) || "2".equals(t) || "3".equals(t) || "on".equals(t)) {
+ return true;
+ }
+ if ("false".equals(t) || "0".equals(t) || "off".equals(t)) {
+ return false;
+ }
+ return def;
+ }
+
+ /**
+ * Whether string encryption is requested, matching {@code HardeningConfig}'s {@code harden.strings}
+ * parsing: {@code off} disables; {@code constants}/{@code all} enable; anything else (including an
+ * unset value) falls back to the level default, which the CLI validates up front.
+ */
+ private static boolean hardenStringsRequested(String strings, boolean def) {
+ if (strings == null) {
+ return def;
+ }
+ String v = strings.trim().toLowerCase();
+ if (v.isEmpty()) {
+ return def;
+ }
+ if ("off".equals(v)) {
+ return false;
+ }
+ if ("constants".equals(v) || "all".equals(v)) {
+ return true;
+ }
+ return def;
+ }
+
+ /** Maps {@code codename1.platform} to the {@code harden..enabled} opt-out key. */
+ private static String normalizeHardenPlatform(String platform) {
+ if (platform == null) {
+ return null;
+ }
+ String p = platform.trim().toLowerCase();
+ if (p.startsWith("android")) {
+ return "and";
+ }
+ if (p.startsWith("ios")) {
+ return "ios";
+ }
+ if (p.contains("javascript")) {
+ return "javascript";
+ }
+ if (p.contains("win")) {
+ return "win";
+ }
+ if (p.contains("mac")) {
+ return "mac";
+ }
+ if (p.contains("linux")) {
+ return "linux";
+ }
+ if (p.contains("javase") || p.contains("desktop")) {
+ return "javase";
+ }
+ return null;
+ }
+
+ /**
+ * Merge a set of jars into a single jar file.
+ * @param dest The destination jar file. Also the first source if it already exists.
+ * @param src The source jar files to be merged into the destination.
+ */
+ private void mergeJars(File dest, File... src) {
+ Zip task = (Zip)antProject.createTask("zip");
+ task.setDestFile(dest);
+ task.setUpdate(true);
+ for (File srcFile : src) {
+
+ if (srcFile.isDirectory()) {
+ FileSet fs = new FileSet();
+ // Multiversioned jars trip out the ASM class parsing.
+ // Specifically module-info.class files
+ fs.setExcludes("**/META-INF/versions/**");
+ fs.setProject(this.antProject);
+ fs.setDir(srcFile);
+ task.addFileset(fs);
+
+ } else {
+ ZipFileSet fileset = new ZipFileSet();
+ // Multiversioned jars trip out the ASM class parsing.
+ // Specifically module-info.class files
+ fileset.setExcludes("**/META-INF/versions/**");
+ fileset.setProject(antProject);
+ fileset.setSrc(srcFile);
+ task.addZipfileset(fileset);
+ }
+ }
+ task.execute();
+ }
+
+ /**
+ * Local JavaScript builds run ParparVM's translator on this machine, so
+ * they keep the classes a server build would have re-supplied.
+ */
+ public static boolean isLocalJavascriptBuild(String buildTarget) {
+ return buildTarget != null && buildTarget.contains("javascript") && isLocalBuildTarget(buildTarget);
+ }
+
+ // A null build target never reaches these from the mojo -- the parameter is
+ // required -- but they are static and package private, so treat an absent
+ // target as "not local": the conservative answer, since it keeps the server
+ // supplied artifacts out of the staged jar rather than throwing.
+
+ /**
+ * Whether the build itself re-supplies the artifact's classes after they
+ * are left out of the staged jar: codenameone-core and java-runtime come
+ * from the build server (or, for a local JavaScript build, stay in the jar
+ * so ParparVM's translator can see them), and kotlin-stdlib comes from the
+ * build server too. This is deliberately narrower than
+ * {@link #isStrippedFromStagedJar}: a reference into anything else that is
+ * left out really is a reference to a class nobody will supply.
+ */
+ public static boolean isSuppliedByBuildServer(String groupId, String artifactId, String buildTarget) {
+ if (GROUP_ID.equals(groupId) && contains(artifactId, BUNDLE_ARTIFACT_ID_BLACKLIST)) {
+ return !isLocalJavascriptBuild(buildTarget);
+ }
+ return !isLocalBuildTarget(buildTarget)
+ && "org.jetbrains.kotlin".equals(groupId)
+ && "kotlin-stdlib".equals(artifactId);
+ }
+
+ /**
+ * Whether the given artifact is left out of the jar-with-dependencies that
+ * is staged for the build, either because the build re-supplies it or
+ * because its scope says it is not part of the application. Shared by the
+ * jar assembly and the class closure check so the two can never disagree
+ * about what is in the jar.
+ */
+ public static boolean isStrippedFromStagedJar(String groupId, String artifactId, String scope, String buildTarget) {
+ if (GROUP_ID.equals(groupId) && contains(artifactId, BUNDLE_ARTIFACT_ID_BLACKLIST)) {
+ // For local JavaScript builds we need codenameone-core and java-runtime classes
+ // in the staged jar - the build server normally re-supplies those, but ParparVM's
+ // ByteCodeTranslator runs locally here and resolves everything from the staged class
+ // directory.
+ return !isLocalJavascriptBuild(buildTarget);
+ }
+ if (isSuppliedByBuildServer(groupId, artifactId, buildTarget)) {
+ // When sending to the build server, we'll strip the kotlin-stdlib and the server will
+ // provide it. For local builds, it's easier to just include it.
+ return true;
+ }
+ // An artifact with no scope was never scoped out of the application, so
+ // keep its classes rather than dropping them from the jar.
+ return scope != null && !"compile".equals(scope);
+ }
+
+ private boolean isStrippedFromStagedJar(BuildArtifact artifact) {
+ return isStrippedAsDesktopRuntime(artifact)
+ || isStrippedFromStagedJar(artifact.getGroupId(), artifact.getArtifactId(), artifact.getScope(), buildTarget);
+ }
+
+ private boolean isStrippedAsDesktopRuntime(BuildArtifact artifact) {
+ if (!isDesktopRuntimeBinary(artifact.getGroupId(), artifact.getArtifactId(), artifact.getDependencyTrail())) {
+ return false;
+ }
+ if (isDesktopRuntimeAggregator(artifact.getGroupId(), artifact.getArtifactId())) {
+ return true;
+ }
+ return isStrippedAsDesktopRuntime(dependencyKey(artifact.getGroupId(), artifact.getArtifactId(),
+ artifact.getClassifier()), neededWithoutDesktopRuntime());
+ }
+
+ /**
+ * Whether an artifact reached through the desktop runtime aggregator is left out of the
+ * staged jar: only when nothing else the application ships needs it.
+ *
+ * The dependency trail cannot answer that on its own. Maven keeps one resolved
+ * artifact and one winning trail per coordinate, so when an application library also
+ * needs an ffmpeg artifact but the aggregator's path is the nearer one, the trail names
+ * only the aggregator. Stripping on the trail alone would then leave the library without
+ * classes it needs.
+ *
+ * @param neededElsewhere the dependency keys the application's own compile dependencies
+ * need with the aggregator removed, or {@code null} when that could
+ * not be determined, in which case nothing is stripped
+ */
+ public static boolean isStrippedAsDesktopRuntime(String dependencyKey, Set neededElsewhere) {
+ return neededElsewhere != null && !neededElsewhere.contains(dependencyKey);
+ }
+
+ /** groupId:artifactId:classifier, the identity {@link #isStrippedAsDesktopRuntime} compares. */
+ public static String dependencyKey(String groupId, String artifactId, String classifier) {
+ return groupId + ":" + artifactId + ":" + (classifier == null ? "" : classifier);
+ }
+
+ public static boolean isDesktopRuntimeAggregator(String groupId, String artifactId) {
+ return GROUP_ID.equals(groupId) && DESKTOP_RUNTIME_BINARIES_ARTIFACT_ID.equals(artifactId);
+ }
+
+ /**
+ * Whether an existing staged jar may be used instead of merging a new one (before the
+ * timestamp check, which still applies).
+ *
+ * With a record, only when it names exactly the current inputs. Without one, the jar
+ * was not written by this mojo -- or was written by a plugin older than the record, which
+ * is indistinguishable from a stale one. A project may deliberately produce this jar from
+ * its own pom to control what is uploaded, and that is still honoured: such a jar is
+ * written during the current Maven run, while a stale one predates it.
+ *
+ * Known limit, left deliberately: a record wins over the current-run exception. A
+ * project that once let this mojo stage the jar, then starts producing it from its own
+ * pom without cleaning target, and whose dependencies changed in between, has its jar
+ * replaced by a merged one. That customization is undocumented and used by nothing in
+ * the tree or the archetypes; the fix is to clean target once.
+ *
+ * @param recorded the recorded inputs, trimmed, or {@code null} if there is no record
+ * @param current the inputs this build would merge
+ * @param jarModified the staged jar's modification time
+ * @param sessionStart when the current Maven run started
+ */
+ static boolean mayReuseStagedJar(String recorded, String current, long jarModified, long sessionStart) {
+ if (recorded == null) {
+ return jarModified >= sessionStart;
+ }
+ return recorded.equals(current.trim());
+ }
+
+ /**
+ * Deletes a staged jar that must not be reused, and fails if it is still there. Ignoring
+ * a failed delete (a jar held open on Windows, a read-only target) would skip the merge
+ * and upload the very jar that was just found stale.
+ */
+ static void discardStagedJar(File jar) throws BuildExecutionException {
+ if (!jar.delete() && jar.exists()) {
+ throw new BuildExecutionException("Could not delete the out of date staged jar " + jar
+ + ", so it cannot be rebuilt and would be uploaded as it is. Close anything that"
+ + " has it open (an IDE, an antivirus scanner) or delete it by hand, then build again.");
+ }
+ }
+
+ /**
+ * The record of what a staged jar was merged from, one absolute path per line in merge
+ * order. Compared, not parsed: any difference means the cached jar is not this build's.
+ */
+ static String describeStagedInputs(List jarsToMerge) {
+ StringBuilder sb = new StringBuilder();
+ for (File f : jarsToMerge) {
+ sb.append(f.getAbsolutePath()).append('\n');
+ }
+ return sb.toString();
+ }
+
+ /**
+ * The aggregator that puts the desktop media runtime -- org.bytedeco's ffmpeg,
+ * with natives for Android, iOS, Linux, macOS and Windows -- on the simulator
+ * and desktop run classpaths.
+ */
+ public static final String DESKTOP_RUNTIME_BINARIES_ARTIFACT_ID = "cn1-binaries-javase";
+
+ /**
+ * Whether the artifact is the desktop runtime aggregator or was resolved
+ * through it: the candidates for leaving out of the staged jar, whatever
+ * their scope. The aggregator itself is always left out; anything it pulled
+ * in is left out only when nothing else needs it
+ * ({@link #isStrippedAsDesktopRuntime(String, Set)}).
+ *
+ * Projects generated from the archetype between #5380 and the fix for it
+ * declare the aggregator at compile scope, and no profile re-scopes it, so
+ * by scope alone it lands in the staged jar: about 300 MB of ffmpeg natives
+ * that the build client refuses to upload, failing every desktop build of
+ * every such project. Deciding by the dependency graph rather than the scope
+ * repairs those projects without asking anyone to edit a pom.
+ */
+ public static boolean isDesktopRuntimeBinary(String groupId, String artifactId, List dependencyTrail) {
+ if (isDesktopRuntimeAggregator(groupId, artifactId)) {
+ return true;
+ }
+ if (dependencyTrail == null) {
+ return false;
+ }
+ // Trail entries are Artifact.getId(): groupId:artifactId:type:version.
+ String aggregator = GROUP_ID + ":" + DESKTOP_RUNTIME_BINARIES_ARTIFACT_ID + ":";
+ for (String node : dependencyTrail) {
+ if (node != null && node.startsWith(aggregator)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Fails the build when the staged jar-with-dependencies contains an
+ * application class that references another application class which is not
+ * in the jar. Stale build output causes exactly this -- e.g. an IDE deletes
+ * the class files of removed/renamed sources while a dependent class
+ * compiled against them survives in target/classes, and an incremental
+ * Maven compile then ships it. The server-side VM translators only warn
+ * about the dangling reference and the build later fails with an obscure
+ * native compiler error (a missing generated header on iOS), so catch it
+ * here with an actionable message before uploading anything.
+ */
+ private void verifyApplicationClassClosure(File jarWithDependencies, List cpElements) throws BuildFailureException {
+ if ("true".equals(System.getProperty("codename1.skipClassClosureCheck", "false"))
+ || "true".equals(projectProperties().getProperty("codename1.skipClassClosureCheck", "false"))) {
+ getLog().info("Skipping application class closure check because codename1.skipClassClosureCheck=true");
+ return;
+ }
+ // The project package space: compiled output directories plus module
+ // jars owned by this build (the app's common jar reaches the platform
+ // modules as a jar dependency, identified by the shared groupId).
+ List projectClassRoots = new ArrayList();
+ for (String element : cpElements) {
+ File dir = new File(element);
+ if (dir.isDirectory()) {
+ projectClassRoots.add(dir);
+ }
+ }
+ for (BuildArtifact artifact : artifacts()) {
+ if (projectGroupId().equals(artifact.getGroupId())) {
+ File jar = getJar(artifact);
+ if (jar != null && jar.isFile()) {
+ projectClassRoots.add(jar);
+ }
+ }
+ }
+ // Classes left out of the staged jar that the build re-supplies: references
+ // into them are not missing. Anything else that was left out -- a `provided`
+ // scope third party dependency, say -- stays reportable, because nothing
+ // puts those classes back before the translators run.
+ boolean localJavascriptBuild = isLocalJavascriptBuild(buildTarget);
+ Set providedJars = new LinkedHashSet();
+ for (BuildArtifact artifact : artifacts()) {
+ if (isSuppliedByBuildServer(artifact.getGroupId(), artifact.getArtifactId(), buildTarget)) {
+ File jar = getJar(artifact);
+ if (jar != null && jar.isFile()) {
+ providedJars.add(jar);
+ }
+ }
+ }
+ if (!localJavascriptBuild) {
+ // codenameone-core and java-runtime are `provided` scope in the app's common
+ // module and `provided` is not transitive, so a platform module (ios, android,
+ // ...) that only depends on common does not see them among its own artifacts.
+ // They still have to count as provided: without them every reference into a
+ // package the app shares with the framework -- a patched framework class, or a
+ // helper deliberately placed in a com.codename1 package to reach package
+ // private API -- makes the whole framework package look like it is missing
+ // from the build. Resolve them from the plugin's own dependencies instead.
+ boolean coreResolved = false;
+ for (String bundled : BUNDLE_ARTIFACT_ID_BLACKLIST) {
+ File jar = getJar(GROUP_ID, bundled);
+ if (jar != null && jar.isFile()) {
+ providedJars.add(jar);
+ if ("codenameone-core".equals(bundled)) {
+ coreResolved = true;
+ }
+ }
+ }
+ if (!coreResolved) {
+ // Every application class references the framework, so without its class
+ // list the check cannot tell a stale class from a framework class and would
+ // report false positives. Skipping is the only safe answer, but say so
+ // loudly: a stale class will now reach the build unreported.
+ getLog().warn("Skipping the application class closure check for build target " + buildTarget
+ + " because " + GROUP_ID + ":codenameone-core could not be resolved."
+ + " Stale application classes will not be detected before the build runs.");
+ return;
+ }
+ }
+ Map> missing;
+ try {
+ missing = ClassClosureVerifier.findMissingProjectReferences(jarWithDependencies, projectClassRoots, providedJars);
+ } catch (IOException ex) {
+ getLog().warn("Could not verify the consistency of the application classes: " + ex.getMessage());
+ return;
+ }
+ if (missing.isEmpty()) {
+ return;
+ }
+ StringBuilder sb = new StringBuilder();
+ sb.append("The compiled application classes are inconsistent. The following class");
+ sb.append(missing.size() == 1 ? " is" : "es are");
+ sb.append(" referenced by your code but missing from the build:\n");
+ for (Map.Entry> e : missing.entrySet()) {
+ sb.append(" - ").append(e.getKey().replace('/', '.'))
+ .append(" (referenced from ");
+ boolean first = true;
+ for (String referencing : e.getValue()) {
+ if (!first) {
+ sb.append(", ");
+ }
+ first = false;
+ sb.append(referencing.replace('/', '.'));
+ }
+ sb.append(")\n");
+ }
+ sb.append("This usually means the build output contains stale classes from a previous build,\n");
+ sb.append("e.g. after renaming, moving or deleting a class, or switching branches, without a\n");
+ sb.append("clean rebuild. Run 'mvn clean' and rebuild. If a listed class was removed\n");
+ sb.append("intentionally, also remove or update the code that still references it.\n");
+ sb.append("To bypass this check build with -Dcodename1.skipClassClosureCheck=true");
+ throw new BuildFailureException(sb.toString());
+ }
+
+
+ /**
+ * Localized launcher icons (cn1_icon_<lang>[_<country>].png) are scaled up to the
+ * largest launcher density by the build server, so a low-resolution source produces a
+ * blurry icon. Maven copies these into the build output (target/classes) with its
+ * incremental resource plugin, which also leaves stale copies behind when a source icon
+ * is removed or replaced (only {@code mvn clean} clears them). Scan the compiled output
+ * directories that will be bundled and sent to the build server and warn about any
+ * localized icon that is too small to render sharply -- this catches both an undersized
+ * new icon and an outdated low-resolution one lingering in target/classes.
+ *
+ * @param classpathElements the compile classpath; directory entries are the project /
+ * module {@code target/classes} folders that get bundled.
+ * @param codenameOneSettings the project's codenameone_settings.properties, used to detect
+ * whether adaptive icons are enabled (which raises the target size).
+ */
+ private void warnAboutSmallLocalizedIcons(List classpathElements, File codenameOneSettings)
+ throws BuildFailureException {
+ int largestTarget = 192;
+ try {
+ Properties settings = new Properties();
+ try (FileInputStream fis = new FileInputStream(codenameOneSettings)) {
+ settings.load(fis);
+ }
+ if ("true".equals(settings.getProperty("codename1.arg.android.enableAdaptiveIcons", "false").trim())) {
+ largestTarget = 432;
+ }
+ } catch (IOException ex) {
+ getLog().debug("Could not read " + codenameOneSettings + " to determine adaptive icon setting", ex);
+ }
+ List undersizedIcons = new ArrayList();
+ for (String element : classpathElements) {
+ File dir = new File(element);
+ if (dir.isDirectory()) {
+ collectSmallLocalizedIcons(dir, largestTarget, undersizedIcons);
+ }
+ }
+ if (!undersizedIcons.isEmpty()) {
+ throw new BuildFailureException("The following localized launcher icon(s) are smaller than "
+ + largestTarget + "x" + largestTarget + "px and would be upscaled to a blurry icon in the "
+ + "production build:\n " + String.join("\n ", undersizedIcons)
+ + "\nSupply each localized icon at no less than " + largestTarget + "x" + largestTarget
+ + "px (1024x1024 recommended, matching the main app icon). NOTE: if you recently replaced an icon, "
+ + "an offending copy may be a stale resource left in target/classes -- run 'mvn clean' to clear it.");
+ }
+ }
+
+ private void collectSmallLocalizedIcons(File dir, int largestTarget, List undersizedIcons) {
+ File[] children = dir.listFiles();
+ if (children == null) {
+ return;
+ }
+ for (File child : children) {
+ if (child.isDirectory()) {
+ collectSmallLocalizedIcons(child, largestTarget, undersizedIcons);
+ continue;
+ }
+ String lower = child.getName().toLowerCase();
+ if (!lower.startsWith("cn1_icon_") || !lower.endsWith(".png")) {
+ continue;
+ }
+ try {
+ BufferedImage img = ImageIO.read(child);
+ if (img == null) {
+ undersizedIcons.add(child + " (not a valid PNG image)");
+ continue;
+ }
+ if (img.getWidth() < largestTarget || img.getHeight() < largestTarget) {
+ undersizedIcons.add(child + " (" + img.getWidth() + "x" + img.getHeight() + "px)");
+ }
+ } catch (IOException ex) {
+ getLog().debug("Could not read localized icon " + child + " to check its resolution", ex);
+ }
+ }
+ }
+
+ /**
+ * The dependency scopes to include in the jar file that is sent to the build server.
+ */
+ private static String[] BUNDLE_ARTIFACT_SCOPES = new String[] { "compile" };
+
+ /**
+ * Artifact IDs that should not be sent to the build server.
+ */
+ private static String[] BUNDLE_ARTIFACT_ID_BLACKLIST = new String[] {"codenameone-core", "java-runtime"};
+
+
+ /**
+ * Gets the app extensions jar file that should be included in any iOS builds.
+ * @return The app extensions jar file if it exists. null otherwise.
+ * @throws IOException
+ */
+ private File getAppExtensionsJar() throws IOException {
+
+ if (!"ios".equalsIgnoreCase(platform)) {
+ // App extensions are only for iOS
+ return null;
+ }
+
+ File appExtensionsDir = host.layout().iosAppExtensionsDir();
+
+ if (!appExtensionsDir.isDirectory()) return null;
+
+ File appExtensionsJar = new File(workDirectory(), "app_extensions.jar");
+ if (appExtensionsJar.exists() && appExtensionsJar.lastModified() < lastModifiedRecursive(appExtensionsDir)) {
+ // The app extensions jar is out of date.
+ appExtensionsJar.delete();
+ }
+
+ if (!appExtensionsJar.exists()) {
+ File tmpDir = new File(appExtensionsJar.getParentFile(), "app_extensions");
+ if (tmpDir.exists()) {
+ FileUtils.deleteDirectory(tmpDir);
+ }
+ tmpDir.mkdirs();
+ for (File appExtension : appExtensionsDir.listFiles()) {
+ Zip task = (Zip)antProject.createTask("zip");
+ File dest = new File(tmpDir, appExtension.getName()+".ios.appext");
+ task.setDestFile(dest);
+ task.setUpdate(false);
+ if (appExtension.isDirectory()) {
+ FileSet fs = new FileSet();
+ fs.setProject(this.antProject);
+ fs.setDir(appExtension);
+ task.addFileset(fs);
+ task.execute();
+ } else if (appExtension.getName().endsWith(".zip")) {
+ ZipFileSet fileset = new ZipFileSet();
+ fileset.setProject(antProject);
+ fileset.setSrc(appExtension);
+ task.addZipfileset(fileset);
+ task.execute();
+ }
+
+
+ }
+ Zip task = (Zip)antProject.createTask("zip");
+ task.setDestFile(appExtensionsJar);
+ task.setUpdate(false);
+ FileSet fs = new FileSet();
+ fs.setProject(this.antProject);
+ fs.setDir(tmpDir);
+ task.addFileset(fs);
+ task.execute();
+
+ }
+
+ if (appExtensionsJar.exists()) {
+ return appExtensionsJar;
+ }
+
+ return null;
+ }
+
+ /**
+ * Gets the localizations jar file that should be included in any iOS builds.
+ * @return The localizations jar file if it exists. null otherwise.
+ * @throws IOException
+ */
+ private File getStringsJar() throws IOException {
+
+ if (!"ios".equalsIgnoreCase(platform)) {
+ // Localized strings are only for iOS
+ return null;
+ }
+
+ File stringsDir = host.layout().iosStringsDir();
+
+ if (!stringsDir.isDirectory()) return null;
+
+ File stringsJar = new File(workDirectory(), "strings.jar");
+ if (stringsJar.exists() && stringsJar.lastModified() < lastModifiedRecursive(stringsDir)) {
+ // The app extensions jar is out of date.
+ stringsJar.delete();
+ }
+
+ if (!stringsJar.exists()) {
+ File tmpDir = new File(stringsJar.getParentFile(), "strings");
+ if (tmpDir.exists()) {
+ FileUtils.deleteDirectory(tmpDir);
+ }
+ tmpDir.mkdirs();
+ for (File lproj : stringsDir.listFiles()) {
+ Zip task = (Zip)antProject.createTask("zip");
+ File dest = new File(tmpDir, lproj.getName()+".zip");
+ task.setDestFile(dest);
+ task.setUpdate(false);
+ if (lproj.isDirectory()) {
+ FileSet fs = new FileSet();
+ fs.setProject(this.antProject);
+ fs.setDir(lproj);
+ task.addFileset(fs);
+ task.execute();
+ } else if (lproj.getName().endsWith(".zip")) {
+ ZipFileSet fileset = new ZipFileSet();
+ fileset.setProject(antProject);
+ fileset.setSrc(lproj);
+ task.addZipfileset(fileset);
+ task.execute();
+ }
+
+
+ }
+ Zip task = (Zip)antProject.createTask("zip");
+ task.setDestFile(stringsJar);
+ task.setUpdate(false);
+ FileSet fs = new FileSet();
+ fs.setProject(this.antProject);
+ fs.setDir(tmpDir);
+ task.addFileset(fs);
+ task.execute();
+
+ }
+
+ if (stringsJar.exists()) {
+ return stringsJar;
+ }
+
+ return null;
+ }
+
+ public static final String BUILD_TARGET_MAC_NATIVE_PROJECT = Executor.BUILD_TARGET_MAC_NATIVE_PROJECT;
+ public static final String BUILD_TARGET_MAC_NATIVE = Executor.BUILD_TARGET_MAC_NATIVE;
+ public static final String BUILD_TARGET_MAC_NATIVE_LOCAL = Executor.BUILD_TARGET_MAC_NATIVE_LOCAL;
+ public static final String BUILD_TARGET_LINUX_NATIVE = Executor.BUILD_TARGET_LINUX_NATIVE;
+
+ /**
+ * The entry points a project can declare besides {@code codename1.mainName},
+ * mapped to the build argument each one becomes. A project with a
+ * {@code codename1.watchMain} gets an Apple Watch and a Wear OS app built
+ * from that root; {@code codename1.tvMain} does the same for tvOS. The
+ * accompanying {@code codename1.watchStandalone} says the watch app ships on
+ * its own rather than alongside the phone app.
+ *
+ * These ride the extensible build-argument map rather than the
+ * {@link BuildRequest} wire format, so adding an entry point needs no
+ * protocol change.
+ */
+ private static final Map SECONDARY_ENTRY_POINTS;
+ static {
+ Map m = new LinkedHashMap();
+ m.put("codename1.watchMain", "watchMain");
+ m.put("codename1.watchStandalone", "watchStandalone");
+ m.put("codename1.tvMain", "tvMain");
+ SECONDARY_ENTRY_POINTS = Collections.unmodifiableMap(m);
+ }
+
+ /**
+ * Copies the secondary entry points declared in the project settings onto a
+ * local {@link BuildRequest}. The cloud path does the equivalent by mirroring
+ * them into the {@code codename1.arg.} namespace of the uploaded settings
+ * file, so both paths hand the builders the same arguments.
+ *
+ * @param r the request being assembled
+ * @param props the project's codenameone_settings.properties
+ */
+ static void putSecondaryEntryPointArguments(BuildRequest r, Properties props) {
+ for (Map.Entry entry : SECONDARY_ENTRY_POINTS.entrySet()) {
+ // The overlaid build argument first, exactly as the cloud mirror resolves it.
+ //
+ // Reading only the unprefixed project setting made the two paths disagree about the
+ // same invocation: -Dcodename1.arg.watchMain=... reached the daemon, because the
+ // mirror leaves an existing value alone, and was ignored locally -- so the standard
+ // override built one product in the cloud and a different one, or none, on the
+ // developer's machine. An override that works in one place and silently does nothing
+ // in the other is worse than one that works nowhere.
+ String value = props.getProperty("codename1.arg." + entry.getValue());
+ if (value == null || value.trim().length() == 0) {
+ value = props.getProperty(entry.getKey());
+ }
+ if (value != null && value.trim().length() > 0) {
+ r.putArgument(entry.getValue(), value.trim());
+ }
+ }
+ }
+
+ /**
+ * Copies the secondary entry points into the {@code codename1.arg.} namespace
+ * of the settings file that is uploaded to the build server.
+ *
+ * They are declared without that prefix because they sit next to
+ * {@code codename1.mainName} and that is the shape developers expect. The
+ * server, however, only lifts {@code codename1.arg.*} keys out of the
+ * uploaded file, so without this mirror a cloud build never learns that the
+ * project has a watch or TV app and silently produces neither.
+ *
+ * @param props the settings being prepared for upload, mutated in place
+ */
+ static void mirrorSecondaryEntryPointsToBuildArgs(Properties props) {
+ for (Map.Entry entry : SECONDARY_ENTRY_POINTS.entrySet()) {
+ String value = props.getProperty(entry.getKey());
+ if (value == null || value.trim().length() == 0) {
+ continue;
+ }
+ String argKey = "codename1.arg." + entry.getValue();
+ // An existing value WINS. This runs after overlayCommandLineBuildHints, so a
+ // -Dcodename1.arg.watchMain=... passed on the command line is already sitting here --
+ // and overwriting it with the project file's codename1.watchMain made the standard
+ // build-argument override silently do nothing, handing the cloud the wrong lifecycle or
+ // a companion where a standalone Wear build was asked for.
+ //
+ // The mirror exists to carry a project-file setting into the args channel the daemon
+ // reads, which is only needed when nothing has put it there already.
+ String existing = props.getProperty(argKey);
+ if (existing != null && existing.trim().length() > 0) {
+ continue;
+ }
+ props.setProperty(argKey, value.trim());
+ }
+ }
+
+ public static boolean isLocalBuildTarget(String buildTarget) {
+ if (buildTarget == null) {
+ return false;
+ }
+ // windows-device (BUILD_TARGET_WINDOWS_NATIVE) is a *cloud* build: it sends
+ // a "win32" build to the server (see the windows-device target in
+ // buildxml-template.xml), mirroring linux-device. Only the explicit
+ // local-windows-device cross-compile and the windows-source project
+ // generation are local.
+ return (buildTarget.startsWith("local-") || BUILD_TARGET_XCODE_PROJECT.equals(buildTarget)
+ || BUILD_TARGET_ANDROID_PROJECT.equals(buildTarget)
+ || BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget)
+ || BUILD_TARGET_WINDOWS_NATIVE_PROJECT.equals(buildTarget));
+ }
+
+ /**
+ * Collapses build-type-qualified app-extension keys into the unqualified keys the rest
+ * of the build pipeline understands. Like the app's own signing assets
+ * ({@code codename1.ios.debug.provision} vs {@code codename1.ios.release.provision}),
+ * an extension's provisioning profile differs between development and distribution
+ * builds, so both plain settings ({@code codename1.ios.debug.appext..provision})
+ * and build hints ({@code codename1.arg.ios.release.appext..provisioningURL})
+ * accept a {@code debug}/{@code release} qualifier after the {@code ios.} segment.
+ * The variant matching the build target overrides the unqualified key; both variants
+ * are removed afterwards so the build server only ever sees the resolved value.
+ */
+ static void resolveAppExtensionBuildTypeQualifiers(Properties props, String buildTarget) {
+ String matching = buildTarget != null && buildTarget.contains("release") ? "release" : "debug";
+ for (String key : new ArrayList(props.stringPropertyNames())) {
+ for (String prefix : new String[] {"codename1.arg.ios.", "codename1.ios."}) {
+ String qualifier;
+ if (key.startsWith(prefix + "debug.appext.")) {
+ qualifier = "debug";
+ } else if (key.startsWith(prefix + "release.appext.")) {
+ qualifier = "release";
+ } else {
+ continue;
+ }
+ String value = props.getProperty(key);
+ props.remove(key);
+ if (qualifier.equals(matching) && value != null && value.trim().length() > 0) {
+ props.setProperty(prefix + "appext."
+ + key.substring((prefix + qualifier + ".appext.").length()), value);
+ }
+ break;
+ }
+ }
+ }
+
+ /// One property of a settings file, or null.
+ private static String settingsProperty(File settings, String key) throws IOException {
+ java.util.Properties p = new java.util.Properties();
+ if (settings.isFile()) {
+ try (java.io.InputStream in = new java.io.FileInputStream(settings)) {
+ p.load(in);
+ }
+ }
+ return p.getProperty(key);
+ }
+
+ private void createAntProject() throws IOException, LibraryPropertiesException, BuildExecutionException, BuildFailureException {
+ File cn1dir = workDirectory();
+ File antProject = new File(cn1dir, "antProject");
+
+ antProject.mkdirs();
+ File codenameOneSettings = new File(getCN1ProjectDir(), "codenameone_settings.properties");
+ File icon = new File(getCN1ProjectDir(), "icon.png");
+ if (icon.exists()) {
+ FileUtils.copyFile(icon, new File(antProject, "icon.png"));
+ } else {
+ FileUtils.copyInputStreamToFile(AppBuilder.class.getResourceAsStream("codenameone-icon.png"), new File(antProject, "icon.png"));
+ }
+
+ File codenameOneSettingsCopy = new File(antProject, codenameOneSettings.getName());
+ FileUtils.copyFile(codenameOneSettings, codenameOneSettingsCopy);
+ // build.xml hands every target icon="${codename1.icon}", resolved against
+ // this staging project: an icon the settings name at another relative path
+ // (branding/app.png) has to be staged there too, or the build cannot read it.
+ String customIcon = settingsProperty(codenameOneSettings, "codename1.icon");
+ if (customIcon != null && customIcon.trim().length() > 0 && !new File(customIcon.trim()).isAbsolute()
+ && !"icon.png".equals(customIcon.trim())) {
+ File source = new File(getCN1ProjectDir(), customIcon.trim());
+ if (source.isFile()) {
+ FileUtils.copyFile(source, new File(antProject, customIcon.trim()));
+ }
+ }
+ FileUtils.copyInputStreamToFile(AppBuilder.class.getResourceAsStream("buildxml-template.xml"), new File(antProject, "build.xml"));
+ File distDir = new File(antProject, "dist");
+ distDir.mkdirs();
+ // The UpdateCodenameOne run inside the ant build writes lib/CLDC11.jar without
+ // creating parent dirs; make sure the dir exists (same latent hole as the
+ // template tmpProject -- only surfaces when the server publishes new versions).
+ new File(antProject, "lib").mkdirs();
+
+
+ // Build a jar with all dependencies that we will send to the build server.
+ File jarWithDependencies = new File(path(buildDirectory(), finalName() + "-"+buildTarget+"-jar-with-dependencies.jar"));
+ List cpElements;
+ try {
+ //getLog().info("Classpath Elements: "+ compileClasspathElements());
+ cpElements = compileClasspathElements();
+ } catch (Exception ex) {
+ throw new BuildExecutionException("Failed to get classpath elements", ex);
+
+ }
+
+ warnAboutSmallLocalizedIcons(cpElements, codenameOneSettings);
+
+ File appExtensionsJar = getAppExtensionsJar();
+ if (appExtensionsJar != null) {
+ cpElements.add(appExtensionsJar.getAbsolutePath());
+ }
+ File stringsJar = getStringsJar();
+ if (stringsJar != null) {
+ cpElements.add(stringsJar.getAbsolutePath());
+ }
+ getLog().debug("Classpath Elements: "+cpElements);
+ // Decide what goes into the staged jar BEFORE deciding whether a cached one can be
+ // reused. The decision has side effects the build needs either way (the kotlin-stdlib
+ // version the server must supply), and the cached jar is only reusable when it was
+ // built from exactly this set.
+ List blackListJars = new ArrayList();
+ boolean localJsBuild = isLocalJavascriptBuild(buildTarget);
+ for (BuildArtifact artifact : artifacts()) {
+ boolean addToBlacklist = isStrippedFromStagedJar(artifact);
+ if (addToBlacklist && !isLocalBuildTarget(buildTarget)
+ && "org.jetbrains.kotlin".equals(artifact.getGroupId())
+ && "kotlin-stdlib".equals(artifact.getArtifactId())) {
+ serverMustProvideKotlinVersion = artifact.getVersion();
+ getLog().debug("Adding kotlin-stdlib to blacklist. Server will provide this:" + artifact);
+ }
+ if (addToBlacklist) {
+ File jar = getJar(artifact);
+ if (jar != null) {
+ blackListJars.add(jar.getAbsolutePath());
+ blackListJars.add(jar.getPath());
+ try {
+ blackListJars.add(jar.getCanonicalPath());
+ getLog().debug("Added "+jar+" to blacklist");
+ } catch (Exception ex){
+ getLog().debug("Failed to add " + jar + " to blacklist. This is not a fatal error: " + ex);
+ }
+ }
+ }
+ }
+ List jarsToMerge = new ArrayList();
+ for (String element : cpElements) {
+
+ String canonicalEl = element;
+ try {
+ canonicalEl = new File(canonicalEl).getCanonicalPath();
+ } catch (Exception ex){
+ if (getLog().isDebugEnabled()) {
+ getLog().warn("Failed to resolve canonical path for " + element, ex);
+ }
+ }
+
+ if (blackListJars.contains(element) || blackListJars.contains(canonicalEl)) {
+ getLog().debug("NOT adding jar "+element+" because it is on the blacklist");
+ continue;
+ }
+ if (!new File(element).exists()) {
+ continue;
+ }
+ jarsToMerge.add(new File(element));
+ }
+ if (localJsBuild) {
+ // For local JavaScript builds we need codenameone-core and java-runtime classes
+ // in the staged jar -- the build server normally re-supplies those, but ParparVM's
+ // ByteCodeTranslator runs locally here and resolves everything from the staged class
+ // directory. `provided`-scope deps are not transitive, so a child module that only
+ // depends on a `common` library never sees the project's codenameone-core /
+ // java-runtime jars on its compile classpath. Pull them in explicitly here.
+ for (String bundled : BUNDLE_ARTIFACT_ID_BLACKLIST) {
+ File jar = getJar("com.codenameone", bundled);
+ if (jar != null && jar.isFile() && !jarsToMerge.contains(jar)) {
+ getLog().info("Adding local-javascript dependency to jar-with-dependencies: " + jar);
+ jarsToMerge.add(jar);
+ }
+ }
+ }
+
+ // Each staged jar this mojo writes records the inputs it was merged from. Timestamps
+ // alone cannot see a change in what the plugin excludes, so a jar staged before an
+ // exclusion was added (the ~157 MB of ffmpeg natives from #5380) would otherwise be
+ // reused, and uploaded, for as long as nothing on the classpath was rebuilt.
+ File stagedInputsFile = new File(jarWithDependencies.getPath() + ".inputs");
+ String stagedInputs = describeStagedInputs(jarsToMerge);
+ if (jarWithDependencies.exists()) {
+ getLog().debug("Found jar file with dependencies at "+jarWithDependencies+". Will use that one unless it is out of date.");
+ long sessionStart = sessionStartTime();
+ if (!mayReuseStagedJar(readTextFileOrNull(stagedInputsFile), stagedInputs,
+ jarWithDependencies.lastModified(), sessionStart)) {
+ getLog().debug("Jar file was not staged from these inputs. "+jarWithDependencies+". Deleting");
+ discardStagedJar(jarWithDependencies);
+ } else {
+ for (String artifact : cpElements) {
+ File jar = new File(artifact);
+ if (jar.isDirectory()) {
+ if (jarWithDependencies.lastModified() < lastModifiedRecursive(jar)) {
+ getLog().debug("Jar file out of date. Dependencies have changed. "+jarWithDependencies+". Deleting");
+ discardStagedJar(jarWithDependencies);
+ break;
+ }
+ } else if (jar.exists() && jar.lastModified() > jarWithDependencies.lastModified()) {
+ // One of the dependency jar files is newer... so we delete the dependencies jar file
+ // and will generate a new one.
+ getLog().debug("Jar file out of date. Dependencies have changed. "+jarWithDependencies+". Deleting");
+ discardStagedJar(jarWithDependencies);
+ break;
+ }
+ }
+ }
+ }
+
+ if (!jarWithDependencies.exists()) {
+ getLog().info(jarWithDependencies + " not found. Generating jar with dependencies now");
+ getLog().debug("Merging into jar with dependencies: "+jarsToMerge);
+ stagedInputsFile.delete();
+ mergeJars(jarWithDependencies, jarsToMerge.toArray(new File[jarsToMerge.size()]));
+ writeStringToFile(stagedInputsFile, stagedInputs);
+ }
+
+ verifyApplicationClassClosure(jarWithDependencies, cpElements);
+
+ if (stageOnly) {
+ getLog().info("codename1.stageOnly is set: staged " + jarWithDependencies + " ("
+ + jarWithDependencies.length() + " bytes) for " + buildTarget + " and stopped before building");
+ return;
+ }
+
+ try {
+ updateCodenameOne(false);
+ } catch (BuildExecutionException ex) {
+ getLog().error("Failed to update Codename One");
+ throw new IOException("Failed to update Codename One", ex);
+ }
+ File antDistDir = new File(antProject, "dist");
+ File antDistJar = new File(antDistDir, finalName() + "-"+buildTarget+"-jar-with-dependencies.jar");
+ antDistDir.mkdirs();
+ FileUtils.copyFile(jarWithDependencies, antDistJar);
+ Properties p = new Properties();
+ p.setProperty("codenameone_settings.properties", codenameOneSettingsCopy.getAbsolutePath());
+ p.setProperty("CodeNameOneBuildClient.jar", path(System.getProperty("user.home"), ".codenameone", "CodeNameOneBuildClient.jar"));
+ p.setProperty("dist.jar", antDistJar.getAbsolutePath());
+ if (automated) {
+ p.setProperty("automated", "true");
+ }
+ getLog().info("Running ANT build target " + buildTarget);
+ String logPasskey = UUID.randomUUID().toString();
+ Properties cn1SettingsProps = new Properties();
+ try (FileInputStream fis = new FileInputStream(codenameOneSettingsCopy)) {
+ cn1SettingsProps.load(fis);
+ }
+ // Build hints declared as annotations on the main class. Merged here, before
+ // everything that consumes the effective configuration: the command-line
+ // overlay below (so -D still wins), the CN1Lib appended/required merges (so a
+ // library appends onto an annotation-supplied value exactly as it would onto a
+ // file-supplied one), the gradle sanity check, both preflights, and the copy
+ // that is written back out and uploaded.
+ mergeAnnotationBuildHints(cn1SettingsProps, cpElements);
+ // The build request is assembled from this copy, not from the mojo's
+ // own properties, so the command-line overlay has to be applied here
+ // too -- otherwise a hint passed with -D is read by the mojo and still
+ // absent from what the builder actually sees.
+ overlayCommandLineBuildHints(cn1SettingsProps);
+ if (serverMustProvideKotlinVersion != null) {
+ cn1SettingsProps.setProperty("codename1.arg.requireKotlinStdlib", serverMustProvideKotlinVersion);
+ }
+ FileSystemManager fsManager = VFS.getManager();
+ FileObject jarFile = fsManager.resolveFile( "jar:"+jarWithDependencies.getAbsolutePath() + "!/META-INF/codenameone" );
+ if (jarFile != null) {
+ FileObject[] appendedPropsFiles = jarFile.findFiles(new PatternFileSelector(".*\\/codenameone_library_appended.properties"));
+ if (appendedPropsFiles != null) {
+ for (FileObject appendedPropsFile : appendedPropsFiles) {
+ SortedProperties appendedProps = new SortedProperties();
+ try (InputStream appendedPropsIn = appendedPropsFile.getContent().getInputStream()) {
+ appendedProps.load(appendedPropsIn);
+ }
+
+ for (String propName : appendedProps.stringPropertyNames()) {
+ String propVal = appendedProps.getProperty(propName);
+ if (!cn1SettingsProps.containsKey(propName)) {
+ cn1SettingsProps.put(propName, propVal);
+ } else {
+ String existing = cn1SettingsProps.getProperty(propName);
+ // Separator decided by the hint rather than by whatever the library
+ // baked into its own value -- see LibraryHintMerger for why a bare
+ // concatenation welds two Gradle statements into one.
+ if (!LibraryHintMerger.alreadyContains(propName, existing, propVal)) {
+ cn1SettingsProps.setProperty(propName,
+ LibraryHintMerger.append(propName, existing, propVal));
+ }
+ }
+ }
+ }
+ }
+ FileObject[] requiredPropsFiles = jarFile.findFiles(new PatternFileSelector(".*\\/codenameone_library_required.properties"));
+ if (requiredPropsFiles != null) {
+ for (FileObject requiredPropsFile : requiredPropsFiles) {
+ SortedProperties requiredProps = new SortedProperties();
+ try (InputStream appendedPropsIn = requiredPropsFile.getContent().getInputStream()) {
+ requiredProps.load(appendedPropsIn);
+ }
+
+ String artifactId = requiredPropsFile.getParent().getName().getBaseName();
+ String groupId = requiredPropsFile.getParent().getParent().getName().getBaseName();
+ String libraryName = groupId + ":" + artifactId;
+ cn1SettingsProps = mergeRequiredProperties(libraryName, requiredProps, cn1SettingsProps);
+ }
+ }
+
+ }
+
+ // Fail here rather than in Gradle. A dependency hint that ran two statements together
+ // surfaces on the build server as a Groovy MissingMethodException against a generated
+ // build.gradle line, which says nothing about which hint or which library produced it --
+ // and on a cloud build that answer costs a queue slot and a round trip to discover.
+ for (String gradleHint : new String[] {"codename1.arg.android.gradleDep",
+ "codename1.arg.gradleDependencies"}) {
+ String problem = LibraryHintMerger.findUnseparatedStatement(
+ gradleHint, cn1SettingsProps.getProperty(gradleHint));
+ if (problem != null) {
+ throw new BuildExecutionException(problem);
+ }
+ }
+
+ // Re-run the hardening pre-flight against the MERGED effective settings: a CN1Lib can supply
+ // codename1.arg.harden.level (or a per-platform opt-out) via the appended/required properties
+ // just merged above, which the early pre-flight -- run before this jar existed -- could not see.
+ // Without this, a library that turns hardening on would slip a local/source build past the
+ // local-build refusal / force-off and produce a locally hardened artifact whose mapping is never
+ // uploaded (so its crashes could never be retraced).
+ applyHardeningPreflight(cn1SettingsProps);
+
+ // Same reason, for the same reason: a CN1Lib can supply ios.*.distributionMethod, so the
+ // profile's kind is compared against the export method only now that those properties
+ // have been merged -- an early refusal would reject a build the merge was about to fix.
+ applyIOSProvisioningPreflight(cn1SettingsProps);
+
+
+ cn1SettingsProps.setProperty("codename1.arg.hyp.beamId", logPasskey);
+ cn1SettingsProps.setProperty("codename1.arg.maven.codenameone-core.version", cn1MavenVersion);
+ cn1SettingsProps.setProperty("codename1.arg.maven.codenameone-maven-plugin", cn1MavenPluginVersion);
+
+ mirrorSecondaryEntryPointsToBuildArgs(cn1SettingsProps);
+
+ // App-extension provisioning profiles (e.g. the generated CN1Widgets WidgetKit
+ // extension) are named by the codename1.ios.appext..provision setting, which
+ // points at a local .mobileprovision file. Cloud builds have no folder to drop the
+ // file into, so base64-encode its bytes into the ios.appext..provisioningData
+ // build arg; the daemon decodes it back to .mobileprovision before signing.
+ // Done generically over , mirroring the daemon's per-extension plumbing.
+ // Extension profiles differ between build types just like the app's own
+ // (development for device/debug builds, distribution for release), so debug/release
+ // qualified keys are collapsed into the unqualified ones first.
+ resolveAppExtensionBuildTypeQualifiers(cn1SettingsProps, buildTarget);
+ String appExtPrefix = "codename1.ios.appext.";
+ String appExtSuffix = ".provision";
+ for (String settingKey : new ArrayList(cn1SettingsProps.stringPropertyNames())) {
+ if (!settingKey.startsWith(appExtPrefix) || !settingKey.endsWith(appExtSuffix)) {
+ continue;
+ }
+ String extName = settingKey.substring(appExtPrefix.length(), settingKey.length() - appExtSuffix.length());
+ if (extName.isEmpty()) {
+ continue;
+ }
+ String dataKey = "codename1.arg.ios.appext." + extName + ".provisioningData";
+ if (cn1SettingsProps.containsKey(dataKey)) {
+ continue;
+ }
+ String profilePath = cn1SettingsProps.getProperty(settingKey);
+ if (profilePath == null || profilePath.trim().isEmpty()) {
+ continue;
+ }
+ File profileFile = new File(profilePath.trim());
+ if (!profileFile.exists() || !profileFile.isFile()) {
+ getLog().warn("The app extension provisioning profile referenced by " + settingKey
+ + " was not found at " + profileFile.getAbsolutePath() + ". Skipping it; the "
+ + extName + " extension will not receive a provisioning profile for this build.");
+ continue;
+ }
+ try {
+ byte[] profileBytes = FileUtils.readFileToByteArray(profileFile);
+ cn1SettingsProps.setProperty(dataKey, java.util.Base64.getEncoder().encodeToString(profileBytes));
+ } catch (IOException ex) {
+ getLog().warn("Failed to read the app extension provisioning profile referenced by " + settingKey
+ + " at " + profileFile.getAbsolutePath() + ". Skipping it: " + ex.getMessage());
+ }
+ }
+
+ try (FileOutputStream fos = new FileOutputStream(codenameOneSettingsCopy)) {
+ cn1SettingsProps.store(fos,"");
+
+ }
+ final Process[] proc = new Process[1];
+ final boolean[] closingHypLog = new boolean[1];
+ Thread hyperBeamThread = new Thread(()->{
+
+ ProcessBuilder pb = new ProcessBuilder("hyp", "beam", logPasskey);
+ pb.redirectErrorStream(true);
+ try {
+ proc[0] = pb.start();
+
+
+ InputStream out = proc[0].getInputStream();
+
+
+ byte[] buffer = new byte[4000];
+ while (isAlive(proc[0])) {
+ int no = out.available();
+ if (no > 0) {
+ int n = out.read(buffer, 0, Math.min(no, buffer.length));
+ getLog().info(new String(buffer, 0, n, StandardCharsets.UTF_8));
+ }
+
+
+ try {
+ Thread.sleep(10);
+ }
+ catch (InterruptedException e) {
+ }
+ }
+
+ } catch (Exception ex) {
+ if (!closingHypLog[0]) {
+ getLog().warn("Failed to start hyperlog. The build log will not stream to your console. If the build fails, you can download the error log at https://cloud.codenameone.com/secure/index.html");
+ getLog().debug(ex);
+ }
+
+ }
+
+ });
+
+
+ try {
+
+ if (isLocalBuildTarget(buildTarget)) {
+ automated = false;
+ if (BUILD_TARGET_WINDOWS_NATIVE_PROJECT.equals(buildTarget)
+ || "local-windows-device".equals(buildTarget)) {
+ // Local native ParparVM Windows cross-compile (clang-cl) and the
+ // windows-source project generation. The cloud win32 build
+ // (windows-device) is NOT local -- it falls through to the
+ // server submission below. Distinct from the JVM-bundled
+ // "windows-desktop" (javase) target.
+ doWindowsNativeLocalBuild(antProject, cn1SettingsProps, antDistJar);
+ } else if ("local-linux-device".equals(buildTarget)) {
+ // Native ParparVM Linux build (GTK3/Cairo, CMake/Ninja). Distinct
+ // from the JVM-bundled "linux-desktop" (javase) target.
+ doLinuxNativeLocalBuild(antProject, cn1SettingsProps, antDistJar);
+ } else if (buildTarget.contains("android") || BUILD_TARGET_ANDROID_PROJECT.equals(buildTarget)) {
+ doAndroidLocalBuild(antProject, cn1SettingsProps, antDistJar);
+ } else if (BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget)
+ || BUILD_TARGET_MAC_NATIVE_LOCAL.equals(buildTarget)) {
+ // The native AppKit build. mac-source stops once the Xcode
+ // project exists; local-mac-device goes on to build it, and
+ // both run the same builder, so what a developer opens in
+ // Xcode is what the device target compiles. mac-os-x-native
+ // is the cloud target and is not handled here -- it has its
+ // own queue rather than riding the iOS one.
+ if (BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget)) {
+ cn1SettingsProps.setProperty("codename1.arg.macos.sourceOnly", "true");
+ }
+ doMacOSNativeLocalBuild(antProject, cn1SettingsProps, antDistJar);
+ } else if (buildTarget.contains("ios") || BUILD_TARGET_XCODE_PROJECT.equals(buildTarget)) {
+ doIOSLocalBuild(antProject, cn1SettingsProps, antDistJar);
+ } else if (buildTarget.contains("javascript")) {
+ doJavaScriptLocalBuild(antProject, cn1SettingsProps, antDistJar);
+ } else {
+ throw new BuildExecutionException("Build target not supported "+buildTarget);
+ }
+ } else {
+ // Cloud builds route through a remote build server. Nothing is
+ // injected for Mac Catalyst here: it is an iOS build that the
+ // user turns on with macNative.enabled, and the server-side
+ // IPhoneBuilder reads that hint directly.
+ if (BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget)) {
+ cn1SettingsProps.setProperty("codename1.arg.macos.sourceOnly", "true");
+ }
+ if (automated) {
+ getLog().debug("Attempting to start hyper beam stream the build log to the console");
+ hyperBeamThread.start();
+ }
+ AntExecutor.executeAntTask(new File(antProject, "build.xml").getAbsolutePath(), buildTarget, p);
+ }
+ } finally {
+ if (automated) {
+ try {
+ closingHypLog[0] = true;
+ proc[0].destroyForcibly();
+ } catch (Exception ex) {
+ if (getLog().isDebugEnabled()) {
+ getLog().warn("Failed to shut down hyperlog process cleanly", ex);
+ }
+ }
+ }
+ }
+
+ if (automated) {
+ getLog().info("Extracting server result");
+ File result = new File(antDistDir, "result.zip");
+ if (!result.exists()) {
+ throw new IOException("Failed to find result.zip after automated build");
+ }
+
+ Expand unzip = (Expand)this.antProject.createTask("unzip");
+ unzip.setSrc(result);
+ File resultDir = new File(antDistDir, "result");
+ resultDir.mkdir();
+ unzip.setDest(resultDir);
+ unzip.execute();
+ File[] resultFiles = resultDir.listFiles();
+ // Every returned base, by extension, collected BEFORE anything is classified: see
+ // roleSuffixFor, which needs to know whether a suffixed entry names an artifact that
+ // is also here.
+ java.util.Map> basesByExtension =
+ new java.util.HashMap>();
+ for (File child : resultFiles) {
+ String name = child.getName();
+ int dot = name.lastIndexOf(".");
+ if (dot < 0) {
+ continue;
+ }
+ String ext = name.substring(dot);
+ java.util.Set bases = basesByExtension.get(ext);
+ if (bases == null) {
+ bases = new java.util.HashSet();
+ basesByExtension.put(ext, bases);
+ }
+ bases.add(name.substring(0, dot));
+ }
+ for (File child : resultFiles) {
+ String name = child.getName();
+ int dotpos = name.lastIndexOf(".");
+ if (dotpos < 0) {
+ continue;
+ }
+ String extension = name.substring(dotpos);
+ String base = name.substring(0, dotpos);
+ // The role suffix has to survive into the copied name. Every entry used to land on
+ // target/, keyed on the extension alone, so a build that
+ // returns two artifacts of the same kind -- a phone APK and its companion Wear APK
+ // -- collapsed both onto one path and the last one written won. That is silent and
+ // it corrupts the primary artifact, not merely the secondary one.
+ String roleSuffix = roleSuffixFor(base, extension, basesByExtension);
+ File copyTo = new File(buildDirectory() + File.separator + finalName() + roleSuffix + extension);
+ FileUtils.copyFile(child, copyTo);
+ if (roleSuffix.length() > 0) {
+ // Attached with a classifier so the companion artifact is installed and
+ // deployed beside the primary one rather than being an orphan in target/.
+ attachArtifact(extension.substring(1),
+ roleSuffix.substring(1), copyTo);
+ } else if (".war".equals(extension)) {
+ attachArtifact("war", copyTo);
+ } else if (".zip".equals(extension) && "javascript".equals(buildTarget)) {
+ attachArtifact("zip", "webapp", copyTo);
+ } else if (".dmg".equals(extension) && "mac-os-x-desktop".equals(buildTarget)) {
+ attachArtifact("dmg", "mac-app", copyTo);
+
+ } else if (".pkg".equals(extension) && "mac-os-x-desktop".equals(buildTarget)) {
+ attachArtifact("pkg", "mac-app-installer", copyTo);
+
+ }
+
+ }
+ FileUtils.deleteDirectory(resultDir);
+ result.delete();
+ afterBuild();
+ }
+
+
+
+
+ }
+
+ private static boolean isAlive(Process proc) {
+ try {
+ proc.exitValue();
+ return false;
+ }
+ catch (IllegalThreadStateException e) {
+ return true;
+ }
+ }
+ private String generateCertificate(String password, String alias, String fullName, String orgName, String company, String city, String state, String twoLetterCountryCode, boolean sha512) throws Exception {
+ File keyTool = new File(System.getProperty("java.home") + File.separator + "bin" + File.separator + "keytool");
+ if (!keyTool.exists()) {
+ keyTool = new File(System.getProperty("java.home") + File.separator + "bin" + File.separator + "keytool.exe");
+ }
+ File keyfileLocation = new File(System.getProperty("user.home") + File.separator + "Keychain.ks");
+ int counter = 1;
+ while (keyfileLocation.exists()) {
+ keyfileLocation = new File(System.getProperty("user.home") + File.separator + "Keychain_" + counter + ".ks");
+ counter++;
+ }
+
+ ProcessBuilder pb = new ProcessBuilder(keyTool.getAbsolutePath(),
+ "-genkey", "-keystore", keyfileLocation.getAbsolutePath(), "-storetype", "jks", "-alias", alias,
+ "-keyalg", "RSA", "-keysize", "2048", "-validity", "15000", "-dname", "CN=" + fullName.replace(",", "\\,")
+ + ", OU=" + orgName.replace(",", "\\,")
+ + ", O=" + company.replace(",", "\\,")
+ + ", L=" + city.replace(",", "\\,")
+ + ", S=" + state.replace(",", "\\,")
+ + ", C=" + twoLetterCountryCode, "-storepass", password, "-keypass", password, "-v");
+
+ if(sha512) {
+ pb.command().add("-sigalg");
+ pb.command().add("SHA512withRSA");
+ }
+
+ Process p = pb.start();
+ int res = p.waitFor();
+ //error occurred
+ if(res > 0){
+ final InputStream input = p.getInputStream();
+ final InputStream stream = p.getErrorStream();
+
+ byte[] buffer = new byte[8192];
+ int i = input.read(buffer);
+ while (i > -1) {
+ String str = new String(buffer, 0, i, StandardCharsets.UTF_8);
+ System.out.print(str);
+ i = stream.read(buffer);
+ }
+ i = stream.read(buffer);
+ while (i > -1) {
+ String str = new String(buffer, 0, i, StandardCharsets.UTF_8);
+ System.out.print(str);
+ i = stream.read(buffer);
+ }
+
+ return null;
+ }
+
+
+
+ return keyfileLocation.getAbsolutePath();
+ }
+
+
+ private File getGeneratedAndroidProjectSourceDirectory() {
+ return new File(buildDirectory(), finalName() + "-android-source");
+ }
+
+ private File getGeneratedIOSProjectSourceDirectory() {
+ return new File(buildDirectory(), finalName() + "-ios-source");
+ }
+
+ private File getGeneratedMacProjectSourceDirectory() {
+ return new File(buildDirectory(), finalName() + "-mac-source");
+ }
+
+ private boolean isMacNativeBuild(Properties props) {
+ return "true".equalsIgnoreCase(props.getProperty("codename1.arg.macNative.enabled", "false"));
+ }
+
+ private void doAndroidLocalBuild(File tmpProjectDir, Properties props, File distJar) throws BuildExecutionException {
+ if (BUILD_TARGET_ANDROID_PROJECT.equals(buildTarget)) {
+
+ File generatedProject = getGeneratedAndroidProjectSourceDirectory();
+ getLog().info("Generating android gradle Project to "+generatedProject+"...");
+ try {
+ if (generatedProject.exists()) {
+ getLog().info("Android gradle project already exists. Checking to see if it needs updating...");
+ if (getSourcesModificationTime() <= lastModifiedRecursive(generatedProject)) {
+ getLog().info("Sources have not changed. Skipping android gradle project generation");
+ if (open) {
+ openAndroidStudioProject(generatedProject);
+ }
+ return;
+
+ }
+ }
+
+ } catch (IOException ex) {
+ throw new BuildExecutionException("Failed to find last modification time of "+generatedProject);
+ }
+ }
+ File codenameOneJar = getJar("com.codenameone", "codenameone-core");
+
+ AndroidGradleBuilder e = new AndroidGradleBuilder();
+ e.setBuildTarget(buildTarget);
+ e.setLogger(getLog());
+ File buildDirectory = new File(tmpProjectDir, "dist" + File.separator + "android-build");
+ e.setBuildDirectory(buildDirectory);
+
+ e.setCodenameOneJar(codenameOneJar);
+
+ BuildRequest r = new BuildRequest();
+ r.setDisplayName(props.getProperty("codename1.displayName"));
+ r.setPackageName(props.getProperty("codename1.packageName"));
+ r.setMainClass(props.getProperty("codename1.mainName"));
+ putSecondaryEntryPointArguments(r, props);
+ r.setVersion(props.getProperty("codename1.version"));
+ String iconPath = props.getProperty("codename1.icon");
+ File iconFile = new File(iconPath);
+ if (!iconFile.isAbsolute()) {
+ iconFile = new File(getCN1ProjectDir(), iconPath);
+ }
+ try {
+ BufferedImage bi = ImageIO.read(iconFile);
+ if(bi.getWidth() != 512 || bi.getHeight() != 512) {
+ throw new BuildExecutionException("The icon must be a 512x512 pixel PNG image. It will be scaled to the proper sizes for devices");
+ }
+ r.setIcon(iconFile.getAbsolutePath());
+ } catch (IOException ex) {
+ throw new BuildExecutionException("Error reading the icon: the icon must be a 512x512 pixel PNG image. It will be scaled to the proper sizes for devices");
+ }
+
+ r.setVendor(props.getProperty("codename1.vendor"));
+ r.setSubTitle(props.getProperty("codename1.secondaryTitle"));
+ r.setType("android");
+
+ r.setKeystoreAlias(props.getProperty("codename1.android.keystoreAlias"));
+ String keystorePath = props.getProperty("codename1.android.keystore");
+ if (keystorePath != null) {
+ File keystoreFile = new File(keystorePath);
+ if (!keystoreFile.isAbsolute()) {
+ keystoreFile = new File(getCN1ProjectDir(), keystorePath);
+ }
+ if (keystoreFile.exists() && keystoreFile.isFile()) {
+ try {
+ r.setCertificate(keystoreFile.getAbsolutePath());
+ } catch (IOException ex) {
+ throw new BuildExecutionException("Failed to load keystore file. ", ex);
+ }
+ } else {
+
+ File androidCerts = new File(getCN1ProjectDir(), "androidCerts");
+ androidCerts.mkdirs();
+ keystoreFile = new File(androidCerts, "KeyChain.ks");
+ if (!keystoreFile.exists()) {
+ try {
+ String alias = r.getKeystoreAlias();
+ if (alias == null || alias.isEmpty()) {
+ alias = "androidKey";
+ r.setKeystoreAlias(alias);
+ props.setProperty("codename1.android.keystoreAlias", alias);
+ }
+ String password = props.getProperty("codename1.android.keystorePassword");
+ if (password == null || password.isEmpty()) {
+ password = "password";
+ props.setProperty("codename1.android.keystorePassword", password);
+
+
+ }
+ getLog().info("No Keystore found. Generating one now");
+ String keyPath = generateCertificate(password, alias, r.getVendor(), "", r.getVendor(), "Vancouver", "BC", "CA", false);
+ FileUtils.copyFile(new File(keyPath), keystoreFile);
+ r.setCertificate(keystoreFile.getAbsolutePath());
+ getLog().info("Generated keystore with password 'password' at "+keystoreFile+". alias=androidKey");
+ new File(keyPath).delete();
+ SortedProperties sp = new SortedProperties();
+ try (FileInputStream fis = new FileInputStream(new File(getCN1ProjectDir(), "codenameone_settings.properties"))) {
+ sp.load(fis);
+ }
+ sp.setProperty("codename1.android.keystore", keystoreFile.getAbsolutePath());
+ sp.setProperty("codename1.android.keystorePassword", password);
+ sp.setProperty("codename1.android.keystoreAlias", alias);
+ try (FileOutputStream fos = new FileOutputStream(new File(getCN1ProjectDir(), "codenameone_settings.properties"))) {
+ sp.store(fos, "Updated keystore");
+ }
+ } catch (Exception ex) {
+ getLog().error("Failed to generate keystore", ex);
+ throw new BuildExecutionException("Failed to generate keystore", ex);
+ }
+ }
+
+
+ }
+ }
+ r.setCertificatePassword(props.getProperty("codename1.android.keystorePassword"));
+
+ for (Object k : props.keySet()) {
+ String key = (String)k;
+ if(key.startsWith("codename1.arg.")) {
+ String value = props.getProperty(key);
+ String currentKey = key.substring(14);
+ if(currentKey.indexOf(' ') > -1) {
+ throw new BuildExecutionException("The build argument contains a space in the key: '" + currentKey + "'");
+ }
+ r.putArgument(currentKey, value);
+ }
+ }
+ applyHardeningRequestArgs(r);
+
+ BuildRequest request = r;
+ request.setIncludeSource(true);
+ String testBuild = request.getArg("build.unitTest", null);
+ if(testBuild != null && testBuild.equals("1")) {
+ e.setUnitTestMode(true);
+ }
+
+ try {
+ getLog().info("Starting android project builder...");
+ boolean result = e.runBuild(distJar, request);
+ getLog().info("Android project builder completed with result "+result);
+ if (!result) {
+ getLog().error("Received false return value from build()");
+ throw new BuildExecutionException("Android build failed. Received false return value for build");
+ }
+
+ if (BUILD_TARGET_ANDROID_PROJECT.equals(buildTarget) && e.getGradleProjectDirectory() != null) {
+ File gradleProject = e.getGradleProjectDirectory();
+ File output = getGeneratedAndroidProjectSourceDirectory();
+ output.getParentFile().mkdirs();
+ try {
+ getLog().info("Copying Gradle Project to "+output);
+ FileUtils.copyDirectory(gradleProject, output);
+ } catch (IOException ex) {
+ throw new BuildExecutionException("Failed to copy gradle project at "+gradleProject+" to "+output, ex);
+ }
+
+ }
+ if (open) {
+ openAndroidStudioProject(getGeneratedAndroidProjectSourceDirectory());
+ }
+
+
+ } catch (BuildException ex) {
+
+ getLog().error("Failed to build Android project with error: "+ex.getMessage(), ex);
+ getLog().error(e.getErrorMessage());
+ throw new BuildExecutionException("Failed to build android app", ex);
+ } finally {
+
+ e.cleanup();
+ }
+
+ }
+
+ private void openAndroidStudioProject(File generatedProject) {
+ if (isMac) {
+ getLog().info("Trying to open project in Android studio");
+ ProcessBuilder pb = new ProcessBuilder("open", "-a", "/Applications/Android Studio.app", generatedProject.getAbsolutePath());
+ try {
+ pb.start();
+ } catch (Exception ex) {
+ getLog().warn("Failed to open project in Android studio", ex);
+ getLog().warn("Please open the project in Android studio manually.");
+ getLog().warn("The project is located at "+generatedProject.getAbsolutePath());
+ }
+ } else if (isWindows) {
+ getLog().info("Trying to open project in Android studio");
+ ProcessBuilder pb = new ProcessBuilder("C:\\Program Files\\Android\\Android Studio\\bin\\studio.bat", generatedProject.getAbsolutePath());
+ try {
+ pb.start();
+ } catch (Exception ex) {
+ getLog().warn("Failed to open project in Android studio", ex);
+ getLog().warn("Please open the project in Android studio manually.");
+ getLog().warn("The project is located at "+generatedProject.getAbsolutePath());
+ }
+ } else {
+ getLog().warn("Opening automatically in Android studio not supported on this platform.");
+ getLog().warn("Please open the project in Android studio manually.");
+ getLog().warn("The project is located at "+generatedProject.getAbsolutePath());
+ }
+ }
+
+ private File getWorkspace(Properties props, File xcprojectRoot) {
+ return new File(xcprojectRoot, props.getProperty("codename1.mainName")+".xcworkspace");
+ }
+
+ private File getXcodeProject(Properties props, File xcprojectRoot) {
+ return new File(xcprojectRoot, props.getProperty("codename1.mainName")+".xcodeproj");
+ }
+
+ private File getWorkspaceOrProject(Properties props, File xcprojectRoot) {
+ File workspace = getWorkspace(props, xcprojectRoot);
+ if (workspace.exists()) {
+ return workspace;
+ }
+ return getXcodeProject(props, xcprojectRoot);
+ }
+
+ private void openWorkspace(File workspace) throws BuildExecutionException {
+ try {
+ ProcessBuilder pb = new ProcessBuilder("open", workspace.getAbsolutePath());
+ Process p = pb.start();
+ int result = p.waitFor();
+ if (result != 0) {
+ throw new BuildExecutionException("Failed to open project at "+workspace+". Result code: "+result);
+ }
+ } catch (Exception ex) {
+ throw new BuildExecutionException("Failed to open project at "+workspace, ex);
+ }
+ }
+
+ private void doIOSLocalBuild(File tmpProjectDir, Properties props, File distJar) throws BuildExecutionException {
+
+ boolean macNativeBuild = isMacNativeBuild(props);
+
+ if (BUILD_TARGET_XCODE_PROJECT.equals(buildTarget) || BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget)) {
+
+ File generatedProject = macNativeBuild
+ ? getGeneratedMacProjectSourceDirectory()
+ : getGeneratedIOSProjectSourceDirectory();
+ getLog().info("Generating Xcode Project to "+generatedProject+"...");
+ try {
+ if (generatedProject.exists()) {
+ getLog().info("Xcode project already exists. Checking to see if it needs updating...");
+ if (getSourcesModificationTime() <= lastModifiedRecursive(generatedProject)) {
+ getLog().info("Sources have not changed. Skipping Xcode project generation");
+ if (open) {
+ File projectToOpen = getWorkspaceOrProject(props, generatedProject);
+ getLog().info("Opening Xcode project "+projectToOpen);
+ openWorkspace(projectToOpen);
+ }
+ return;
+
+ }
+ }
+
+ } catch (IOException ex) {
+ throw new BuildExecutionException("Failed to find last modification time of "+generatedProject);
+ }
+ }
+
+ File codenameOneJar = getJar("com.codenameone", "codenameone-core");
+
+ IPhoneBuilder e = new IPhoneBuilder();
+ e.setLogger(getLog());
+ File buildDirectory = new File(tmpProjectDir,
+ "dist" + File.separator + (macNativeBuild ? "mac-build" : "ios-build"));
+ e.setBuildDirectory(buildDirectory);
+
+ e.setCodenameOneJar(codenameOneJar);
+
+ BuildRequest r = new BuildRequest();
+ r.setAppid(props.getProperty("codename1.ios.appid"));
+ r.setDisplayName(props.getProperty("codename1.displayName"));
+ r.setPackageName(props.getProperty("codename1.packageName"));
+ r.setMainClass(props.getProperty("codename1.mainName"));
+ putSecondaryEntryPointArguments(r, props);
+ r.setVersion(props.getProperty("codename1.version"));
+ String iconPath = props.getProperty("codename1.icon");
+ File iconFile = new File(iconPath);
+ if (!iconFile.isAbsolute()) {
+ iconFile = new File(getCN1ProjectDir(), iconPath);
+ }
+ try {
+ BufferedImage bi = ImageIO.read(iconFile);
+ if(bi.getWidth() != 512 || bi.getHeight() != 512) {
+ throw new BuildExecutionException("The icon must be a 512x512 pixel PNG image. It will be scaled to the proper sizes for devices");
+ }
+ r.setIcon(iconFile.getAbsolutePath());
+ } catch (IOException ex) {
+ throw new BuildExecutionException("Error reading the icon: the icon must be a 512x512 pixel PNG image. It will be scaled to the proper sizes for devices");
+ }
+
+ r.setVendor(props.getProperty("codename1.vendor"));
+ r.setSubTitle(props.getProperty("codename1.secondaryTitle"));
+ r.setType("ios");
+
+
+ for (Object k : props.keySet()) {
+ String key = (String)k;
+ if(key.startsWith("codename1.arg.")) {
+ String value = props.getProperty(key);
+ String currentKey = key.substring(14);
+ if(currentKey.indexOf(' ') > -1) {
+ throw new BuildExecutionException("The build argument contains a space in the key: '" + currentKey + "'");
+ }
+ r.putArgument(currentKey, value);
+ }
+ }
+ applyHardeningRequestArgs(r);
+
+ BuildRequest request = r;
+ String incSources = request.getArg("build.incSources", null);
+ request.setIncludeSource(true);
+
+ String testBuild = request.getArg("build.unitTest", null);
+ if(testBuild != null && testBuild.equals("1")) {
+ e.setUnitTestMode(true);
+ }
+
+ try {
+ boolean result = e.runBuild(distJar, request);
+ if (!result) {
+ String builderLog = e.getErrorMessage();
+ if (builderLog != null && builderLog.trim().length() > 0) {
+ getLog().error("iOS builder log:\n" + builderLog);
+ }
+ throw new BuildExecutionException("iOS build failed");
+ }
+
+ if ((BUILD_TARGET_XCODE_PROJECT.equals(buildTarget) || BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget)) && e.getXcodeProjectDir() != null) {
+ File xcodeProject = e.getXcodeProjectDir();
+ File output = macNativeBuild
+ ? getGeneratedMacProjectSourceDirectory()
+ : getGeneratedIOSProjectSourceDirectory();
+ output.getParentFile().mkdirs();
+ try {
+ // This directory is a generated target. Replacing it is
+ // required when class scanning removes a dependency:
+ // copyDirectory() alone leaves stale Podfiles, workspaces,
+ // Pods and optional native sources from the prior build.
+ if (output.exists()) {
+ FileUtils.deleteDirectory(output);
+ }
+ getLog().info("Copying Xcode Project to "+output);
+ FileUtils.copyDirectory(xcodeProject, output);
+ } catch (IOException ex) {
+ throw new BuildExecutionException("Failed to copy xcode project at "+xcodeProject+" to "+output, ex);
+ }
+ if (open) {
+
+ File projectToOpen = getWorkspaceOrProject(props, output);
+ getLog().info("Opening Xcode project "+projectToOpen);
+ openWorkspace(projectToOpen);
+
+ }
+ }
+
+
+ } catch (BuildException ex) {
+ String builderLog = e.getErrorMessage();
+ if (builderLog != null && builderLog.trim().length() > 0) {
+ getLog().error("iOS builder log:\n" + builderLog);
+ }
+ throw new BuildExecutionException("Failed to build ios app", ex);
+ } finally {
+
+ e.cleanup();
+ }
+
+ }
+
+ /**
+ * Local native Windows build via {@link WindowsNativeBuilder}: translates the
+ * app with ParparVM's windows target and compiles it with clang-cl for the
+ * selected architecture ({@code windows.arch}). Mirrors the iOS local-build
+ * wiring. The native compile only succeeds on Windows with the MSVC/clang-cl
+ * toolchain present; elsewhere the builder fails fast with a clear message.
+ */
+ /**
+ * Builds the native macOS (AppKit) application locally.
+ *
+ * Unlike Mac Catalyst, which is the iOS build with one hint set, this
+ * runs its own builder against the macosx SDK. Both {@code mac-source} and
+ * {@code mac-os-x-native} come here; the former sets
+ * {@code macos.sourceOnly} so the builder stops once the Xcode project
+ * exists.
+ */
+ private void doMacOSNativeLocalBuild(File tmpProjectDir, Properties props, File distJar) throws BuildExecutionException {
+ File codenameOneJar = getJar("com.codenameone", "codenameone-core");
+ MacOSNativeBuilder e = new MacOSNativeBuilder();
+ e.setLogger(getLog());
+ File buildDirectory = new File(tmpProjectDir, "dist" + File.separator + "macos-build");
+ e.setBuildDirectory(buildDirectory);
+ e.setCodenameOneJar(codenameOneJar);
+
+ BuildRequest r = new BuildRequest();
+ r.setDisplayName(props.getProperty("codename1.displayName"));
+ r.setPackageName(props.getProperty("codename1.packageName"));
+ r.setMainClass(props.getProperty("codename1.mainName"));
+ putSecondaryEntryPointArguments(r, props);
+ r.setVersion(props.getProperty("codename1.version"));
+ r.setVendor(props.getProperty("codename1.vendor"));
+ // The icon rides the request rather than the source archive, exactly as
+ // it does for iOS and Windows, and the builder renders the asset catalog
+ // from it. Omitted here, the generated AppIcon.appiconset references ten
+ // PNGs that are never written: a generic icon in the Dock, and an App
+ // Store validation failure for having none.
+ String iconPath = props.getProperty("codename1.icon");
+ if (iconPath != null && iconPath.length() > 0) {
+ File iconFile = new File(iconPath);
+ if (!iconFile.isAbsolute()) {
+ iconFile = new File(getCN1ProjectDir(), iconPath);
+ }
+ try {
+ r.setIcon(iconFile.getAbsolutePath());
+ } catch (IOException ex) {
+ throw new BuildExecutionException("Error reading the icon at "
+ + iconFile.getAbsolutePath()
+ + ": it must be a 512x512 pixel PNG, which is scaled to the sizes the "
+ + "macOS asset catalog asks for.", ex);
+ }
+ }
+ r.setType("macos");
+ for (Object k : props.keySet()) {
+ String key = (String) k;
+ if (key.startsWith("codename1.arg.")) {
+ String currentKey = key.substring("codename1.arg.".length());
+ if (currentKey.indexOf(' ') > -1) {
+ throw new BuildExecutionException("The build argument contains a space in the key: '" + currentKey + "'");
+ }
+ r.putArgument(currentKey, props.getProperty(key));
+ }
+ }
+ applyHardeningRequestArgs(r);
+ r.setIncludeSource(true);
+
+ try {
+ boolean result = e.runBuild(distJar, r);
+ if (!result) {
+ String builderLog = e.getErrorMessage();
+ if (builderLog != null && builderLog.trim().length() > 0) {
+ getLog().error("macOS builder log:\n" + builderLog);
+ }
+ throw new BuildExecutionException("Native macOS build failed");
+ }
+ if (e.getXcodeProjectDir() != null && BUILD_TARGET_MAC_NATIVE_PROJECT.equals(buildTarget)) {
+ // Collected under the same -mac-source name the
+ // Catalyst path used, so a project that switches between the
+ // two ports finds its Xcode project in the same place.
+ File output = getGeneratedMacProjectSourceDirectory();
+ output.getParentFile().mkdirs();
+ try {
+ // Replaced rather than merged: a stale source file from a
+ // previous build is still compiled by the regenerated
+ // project, and the resulting failure names a file the
+ // developer never wrote.
+ if (output.exists()) {
+ FileUtils.deleteDirectory(output);
+ }
+ getLog().info("Copying macOS Xcode project to " + output);
+ FileUtils.copyDirectory(e.getXcodeProjectDir(), output);
+ } catch (IOException ex) {
+ throw new BuildExecutionException("Failed to collect the generated macOS Xcode project", ex);
+ }
+ // Opened, as the iOS project path beside this one does. The
+ // Mac Native Project IDE shortcut and a plain mac-source build
+ // both default to open=true, and without this they completed
+ // with no Xcode window and no indication that the documented
+ // option had been ignored.
+ //
+ // getWorkspaceOrProject rather than the .xcodeproj directly:
+ // it prefers a workspace when the generated project has one,
+ // which is the thing Xcode should be handed.
+ if (open) {
+ File projectToOpen = getWorkspaceOrProject(props, output);
+ getLog().info("Opening macOS Xcode project " + projectToOpen);
+ openWorkspace(projectToOpen);
+ }
+ }
+ if (e.getAppBundle() != null) {
+ getLog().info("Built native macOS application: " + e.getAppBundle().getAbsolutePath());
+ // Every artifact, not just the first bundle: with
+ // macos.distribution=both there are two, each with its own
+ // container, and a dmg or pkg nobody is told about is a dmg
+ // nobody ships.
+ for (java.io.File artifact : e.getArtifacts()) {
+ if (!artifact.equals(e.getAppBundle())) {
+ getLog().info(" also produced: " + artifact.getAbsolutePath());
+ }
+ }
+ } else if (e.getXcodeProjectDir() != null) {
+ getLog().info("Generated macOS Xcode project: " + e.getXcodeProjectDir().getAbsolutePath());
+ }
+ } catch (com.codename1.builders.BuildException hardeningEx) {
+ throw new BuildExecutionException(hardeningEx.getMessage(), hardeningEx);
+ } catch (org.apache.tools.ant.BuildException ex) {
+ String builderLog = e.getErrorMessage();
+ if (builderLog != null && builderLog.trim().length() > 0) {
+ getLog().error("macOS builder log:\n" + builderLog);
+ }
+ throw new BuildExecutionException("Failed to build the macOS app", ex);
+ } finally {
+ e.cleanup();
+ }
+ }
+
+ private void doWindowsNativeLocalBuild(File tmpProjectDir, Properties props, File distJar) throws BuildExecutionException {
+ File codenameOneJar = getJar("com.codenameone", "codenameone-core");
+ WindowsNativeBuilder e = new WindowsNativeBuilder();
+ e.setLogger(getLog());
+ File buildDirectory = new File(tmpProjectDir, "dist" + File.separator + "windows-build");
+ e.setBuildDirectory(buildDirectory);
+ e.setCodenameOneJar(codenameOneJar);
+
+ BuildRequest r = new BuildRequest();
+ r.setDisplayName(props.getProperty("codename1.displayName"));
+ r.setPackageName(props.getProperty("codename1.packageName"));
+ r.setMainClass(props.getProperty("codename1.mainName"));
+ putSecondaryEntryPointArguments(r, props);
+ r.setVersion(props.getProperty("codename1.version"));
+ r.setVendor(props.getProperty("codename1.vendor"));
+ r.setType("windows");
+ for (Object k : props.keySet()) {
+ String key = (String) k;
+ if (key.startsWith("codename1.arg.")) {
+ String currentKey = key.substring("codename1.arg.".length());
+ if (currentKey.indexOf(' ') > -1) {
+ throw new BuildExecutionException("The build argument contains a space in the key: '" + currentKey + "'");
+ }
+ r.putArgument(currentKey, props.getProperty(key));
+ }
+ }
+ applyHardeningRequestArgs(r);
+ // Authenticode signing certificate. Configured through settings/properties
+ // (codename1.windows.signing.certificate = path to the .p12/.pfx, and
+ // codename1.windows.signing.password). This mirrors the cloud build, whose
+ // codeNameOne task uploads the same certificate into the request, so a
+ // local build and a cloud build sign from the same configuration.
+ String winCert = props.getProperty("codename1.windows.signing.certificate");
+ if (winCert != null && !winCert.isEmpty()) {
+ File certFile = new File(winCert);
+ if (!certFile.isAbsolute()) {
+ certFile = new File(getCN1ProjectDir(), winCert);
+ }
+ if (certFile.isFile()) {
+ try {
+ r.setCertificate(certFile.getAbsolutePath());
+ } catch (IOException ex) {
+ throw new BuildExecutionException("Failed to read the Windows signing certificate: " + certFile, ex);
+ }
+ r.setCertificatePassword(props.getProperty("codename1.windows.signing.password"));
+ } else {
+ getLog().warn("codename1.windows.signing.certificate points at a missing file: " + certFile);
+ }
+ }
+ r.setIncludeSource(true);
+
+ try {
+ boolean result = e.runBuild(distJar, r);
+ if (!result) {
+ String builderLog = e.getErrorMessage();
+ if (builderLog != null && builderLog.trim().length() > 0) {
+ getLog().error("Windows builder log:\n" + builderLog);
+ }
+ throw new BuildExecutionException("Windows native build failed");
+ }
+ if (e.getWindowsExecutable() != null) {
+ getLog().info("Built native Windows executable: " + e.getWindowsExecutable().getAbsolutePath());
+ }
+ } catch (com.codename1.builders.BuildException hardeningEx) {
+ throw new BuildExecutionException(hardeningEx.getMessage(), hardeningEx);
+ } catch (org.apache.tools.ant.BuildException ex) {
+ String builderLog = e.getErrorMessage();
+ if (builderLog != null && builderLog.trim().length() > 0) {
+ getLog().error("Windows builder log:\n" + builderLog);
+ }
+ throw new BuildExecutionException("Failed to build Windows app", ex);
+ } finally {
+ e.cleanup();
+ }
+ }
+
+ /**
+ * Local native Linux build via {@link LinuxNativeBuilder}: translates the app
+ * with ParparVM's linux target and compiles it with CMake/Ninja for the
+ * selected architecture ({@code linux.arch}). Mirrors the Windows local-build
+ * wiring. The native compile only succeeds on Linux with the GTK3 dev stack +
+ * pkg-config present (and, for musl targets, a {@code zig}/musl toolchain);
+ * elsewhere the builder fails fast with a clear message.
+ */
+ private void doLinuxNativeLocalBuild(File tmpProjectDir, Properties props, File distJar) throws BuildExecutionException {
+ File codenameOneJar = getJar("com.codenameone", "codenameone-core");
+ LinuxNativeBuilder e = new LinuxNativeBuilder();
+ e.setLogger(getLog());
+ File buildDirectory = new File(tmpProjectDir, "dist" + File.separator + "linux-build");
+ e.setBuildDirectory(buildDirectory);
+ e.setCodenameOneJar(codenameOneJar);
+
+ BuildRequest r = new BuildRequest();
+ r.setDisplayName(props.getProperty("codename1.displayName"));
+ r.setPackageName(props.getProperty("codename1.packageName"));
+ r.setMainClass(props.getProperty("codename1.mainName"));
+ putSecondaryEntryPointArguments(r, props);
+ r.setVersion(props.getProperty("codename1.version"));
+ r.setVendor(props.getProperty("codename1.vendor"));
+ r.setType("linux");
+ for (Object k : props.keySet()) {
+ String key = (String) k;
+ if (key.startsWith("codename1.arg.")) {
+ String currentKey = key.substring("codename1.arg.".length());
+ if (currentKey.indexOf(' ') > -1) {
+ throw new BuildExecutionException("The build argument contains a space in the key: '" + currentKey + "'");
+ }
+ r.putArgument(currentKey, props.getProperty(key));
+ }
+ }
+ applyHardeningRequestArgs(r);
+ r.setIncludeSource(true);
+
+ try {
+ boolean result = e.runBuild(distJar, r);
+ if (!result) {
+ String builderLog = e.getErrorMessage();
+ if (builderLog != null && builderLog.trim().length() > 0) {
+ getLog().error("Linux builder log:\n" + builderLog);
+ }
+ throw new BuildExecutionException("Linux native build failed");
+ }
+ if (e.getLinuxExecutable() != null) {
+ getLog().info("Built native Linux executable: " + e.getLinuxExecutable().getAbsolutePath());
+ }
+ } catch (com.codename1.builders.BuildException hardeningEx) {
+ throw new BuildExecutionException(hardeningEx.getMessage(), hardeningEx);
+ } catch (org.apache.tools.ant.BuildException ex) {
+ String builderLog = e.getErrorMessage();
+ if (builderLog != null && builderLog.trim().length() > 0) {
+ getLog().error("Linux builder log:\n" + builderLog);
+ }
+ throw new BuildExecutionException("Failed to build Linux app", ex);
+ } finally {
+ e.cleanup();
+ }
+ }
+
+ // Local ParparVM-backed JavaScript build target.
+ private void doJavaScriptLocalBuild(File tmpProjectDir, Properties props, File distJar) throws BuildExecutionException {
+ File codenameOneJar = getJar("com.codenameone", "codenameone-core");
+
+ JavaScriptBuilder e = new JavaScriptBuilder();
+ e.setLogger(getLog());
+ e.setBuildTarget(buildTarget);
+ File buildDirectory = new File(tmpProjectDir, "dist" + File.separator + "javascript-build");
+ e.setBuildDirectory(buildDirectory);
+ e.setCodenameOneJar(codenameOneJar);
+
+ BuildRequest r = new BuildRequest();
+ r.setDisplayName(props.getProperty("codename1.displayName"));
+ r.setPackageName(props.getProperty("codename1.packageName"));
+ r.setMainClass(props.getProperty("codename1.mainName"));
+ putSecondaryEntryPointArguments(r, props);
+ r.setVersion(props.getProperty("codename1.version"));
+ String iconPath = props.getProperty("codename1.icon");
+ if (iconPath != null) {
+ File iconFile = new File(iconPath);
+ if (!iconFile.isAbsolute()) {
+ iconFile = new File(getCN1ProjectDir(), iconPath);
+ }
+ if (iconFile.isFile()) {
+ try {
+ r.setIcon(iconFile.getAbsolutePath());
+ } catch (IOException ex) {
+ throw new BuildExecutionException("Failed to read icon " + iconFile, ex);
+ }
+ }
+ }
+ r.setVendor(props.getProperty("codename1.vendor"));
+ r.setSubTitle(props.getProperty("codename1.secondaryTitle"));
+ r.setType("javascript");
+
+ for (Object k : props.keySet()) {
+ String key = (String) k;
+ if (key.startsWith("codename1.arg.")) {
+ String value = props.getProperty(key);
+ String currentKey = key.substring(14);
+ if (currentKey.indexOf(' ') > -1) {
+ throw new BuildExecutionException("The build argument contains a space in the key: '" + currentKey + "'");
+ }
+ r.putArgument(currentKey, value);
+ }
+ }
+ applyHardeningRequestArgs(r);
+ r.setIncludeSource(true);
+
+ try {
+ boolean result = e.runBuild(distJar, r);
+ if (!result) {
+ String builderLog = e.getErrorMessage();
+ if (builderLog != null && builderLog.trim().length() > 0) {
+ getLog().error("JavaScript builder log:\n" + builderLog);
+ }
+ throw new BuildExecutionException("JavaScript build failed");
+ }
+ File outputZip = e.getJavaScriptOutputZip();
+ if (outputZip != null && outputZip.isFile()) {
+ File copyTo = new File(buildDirectory() + File.separator + finalName() + ".zip");
+ try {
+ FileUtils.copyFile(outputZip, copyTo);
+ } catch (IOException ex) {
+ throw new BuildExecutionException("Failed to copy JavaScript bundle to " + copyTo, ex);
+ }
+ attachArtifact("zip", "webapp", copyTo);
+ getLog().info("JavaScript bundle written to " + copyTo);
+ }
+ File deployable = e.getJavaScriptDeployableArtifact();
+ if (deployable != null && deployable.isFile()) {
+ String name = deployable.getName();
+ int dot = name.lastIndexOf('.');
+ String extension = dot < 0 ? "zip" : name.substring(dot + 1);
+ String classifier = "war".equals(extension) ? "webapp-proxy" : "proxy-"
+ + r.getArg("javascript.proxy.target", "jakarta-servlet");
+ File copyTo = new File(buildDirectory(), name);
+ try {
+ FileUtils.copyFile(deployable, copyTo);
+ } catch (IOException ex) {
+ throw new BuildExecutionException("Failed to copy JavaScript deployable bundle to " + copyTo, ex);
+ }
+ attachArtifact(extension, classifier, copyTo);
+ getLog().info("JavaScript deployable bundle written to " + copyTo);
+ }
+ // The translator writes its deployment configuration and its build report BESIDE
+ // the bundle rather than inside it, so unpacking the zip into a web root cannot
+ // publish them. The zip copied above is all that leaves the build directory, and
+ // the finally below deletes that directory outright -- without this the developer
+ // never sees either artifact at all. They are copied, not attached: host
+ // configuration is not something to publish to a Maven repository.
+ for (File artifact : e.getJavaScriptBuildArtifacts()) {
+ File copyTo = new File(buildDirectory(), artifact.getName());
+ try {
+ if (artifact.isDirectory()) {
+ FileUtils.copyDirectory(artifact, copyTo);
+ } else {
+ FileUtils.copyFile(artifact, copyTo);
+ }
+ } catch (IOException ex) {
+ throw new BuildExecutionException("Failed to copy JavaScript build artifact to "
+ + copyTo, ex);
+ }
+ getLog().info("JavaScript build artifact written to " + copyTo);
+ }
+ } catch (BuildException ex) {
+ String builderLog = e.getErrorMessage();
+ if (builderLog != null && builderLog.trim().length() > 0) {
+ getLog().error("JavaScript builder log:\n" + builderLog);
+ }
+ throw new BuildExecutionException("Failed to build JavaScript app", ex);
+ } finally {
+ e.cleanup();
+ }
+ }
+
+ /// Called once a cloud build's results have been collected.
+ protected void afterBuild() {
+
+ }
+
+ /**
+ * Role suffixes a returned artifact may carry, longest first so a future
+ * "-wear-debug" cannot be shadowed by "-wear".
+ *
+ * Kept deliberately closed. Anything not on this list is the primary artifact and
+ * keeps the plain {@code } name it has always had, so adding a
+ * role here is the only way to change where a file lands.
+ */
+ // Longest first: "-wear-debug" ends with neither "-wear" nor anything else here, but a
+ // future suffix that is a tail of another would match the shorter one if it came first.
+ /**
+ * The role a result entry plays, given what else came back.
+ *
+ * A role suffix is a claim about a SET, not about a name, and the question it answers is
+ * "is there something here that this one is the companion TO". An app called
+ * {@code fitness-wear} returns one APK whose base ends in {@code -wear} and it is the primary
+ * artifact; the same app with a companion returns {@code fitness-wear} and
+ * {@code fitness-wear-wear}, where the second is not. Neither reading the name alone nor
+ * asking whether any unsuffixed entry exists separates those two cases -- in the second, no
+ * entry is unsuffixed at all.
+ *
+ * What does separate them is the artifact the suffix points at: strip it, and a companion
+ * names something else in the set while a primary names nothing.
+ *
+ * @param base the entry's name with its extension removed
+ * @param extension the entry's extension, including the dot
+ * @param basesByExtension every returned base, keyed by extension
+ * @return the role suffix including its leading dash, or an empty string
+ */
+ static String roleSuffixFor(String base, String extension,
+ java.util.Map> basesByExtension) {
+ String suffix = roleSuffixOf(base);
+ if (suffix.length() == 0 || basesByExtension == null) {
+ return "";
+ }
+ java.util.Set siblings = basesByExtension.get(extension);
+ if (siblings == null) {
+ return "";
+ }
+ return siblings.contains(base.substring(0, base.length() - suffix.length()))
+ ? suffix : "";
+ }
+
+ private static final String[] ARTIFACT_ROLE_SUFFIXES = {"-wear-debug", "-wear"};
+
+ /**
+ * The role suffix carried by a result entry's base name, or an empty string when it is
+ * the primary artifact.
+ *
+ * A build may hand back more than one artifact of the same kind -- an Android
+ * companion build returns the phone APK and the Wear APK beside it -- and the two
+ * cannot share a destination path. The builder names the secondary one with a role
+ * suffix; this recovers it so the copy keeps it and the artifact can be attached
+ * under a matching classifier.
+ *
+ * @param base the result file's name with its extension already removed
+ * @return the matching role suffix including its leading dash, or an empty string
+ */
+ static String roleSuffixOf(String base) {
+ if (base == null) {
+ return "";
+ }
+ for (String suffix : ARTIFACT_ROLE_SUFFIXES) {
+ if (base.endsWith(suffix)) {
+ return suffix;
+ }
+ }
+ return "";
+ }
+
+ static class LibraryPropertiesException extends Exception {
+ private String libName;
+ LibraryPropertiesException(String libName, String message) {
+ super(message);
+ this.libName = libName;
+ }
+ }
+
+ private static class VersionMismatchException extends LibraryPropertiesException {
+ VersionMismatchException(String libName, String message) {
+ super(libName, message);
+ }
+ }
+
+ private static class PropertyConflictException extends LibraryPropertiesException {
+ PropertyConflictException(String libName, String message) {
+ super(libName, message);
+ }
+ }
+
+ static SortedProperties mergeRequiredProperties(String libraryName, Properties libProps, Properties projectProps) throws LibraryPropertiesException {
+
+
+ String javaVersion = (String)projectProps.getProperty("codename1.arg.java.version", "8");
+ String javaVersionLib = (String)libProps.get("codename1.arg.java.version");
+ if(javaVersionLib != null){
+ int v1 = JavaVersionUtil.parseJavaVersion(javaVersion, 5);
+ int v2 = JavaVersionUtil.parseJavaVersion(javaVersionLib, 5);
+ //if the lib java version is bigger, this library cannot be used
+ if(v1 < v2){
+ throw new VersionMismatchException(libraryName, "Cannot use a cn1lib with java version "
+ + "greater then the project java version");
+ }
+ }
+ //merge and save
+ SortedProperties merged = new SortedProperties();
+ merged.putAll(projectProps);
+ Enumeration keys = libProps.propertyNames();
+ while(keys.hasMoreElements()){
+ String key = (String) keys.nextElement();
+ if(!merged.containsKey(key)){
+ merged.put(key, libProps.getProperty(key));
+ }else{
+
+ //if this property already exists with a different value the
+ //install will fail
+ if(!merged.get(key).equals(libProps.getProperty(key))){
+ if ("codename1.arg.java.version".equals(key)) {
+ // Preserve the project's java version when it is equal to or greater than
+ // the library requirement. This is validated above and allows using
+ // Java 8 cn1libs in Java 11/17+ projects.
+ continue;
+ }
+ throw new PropertyConflictException(libraryName, "Property " + key + " has a conflict");
+ }
+ }
+ }
+ return merged;
+
+ }
+
+}
diff --git a/maven/build-engine/src/main/java/com/codename1/maven/BackendMainClass.java b/maven/build-engine/src/main/java/com/codename1/maven/BackendMainClass.java
new file mode 100644
index 00000000000..1b0c715a8aa
--- /dev/null
+++ b/maven/build-engine/src/main/java/com/codename1/maven/BackendMainClass.java
@@ -0,0 +1,198 @@
+/*
+ * 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.maven;
+
+import com.codename1.build.BuildFailureException;
+import com.codename1.build.Log;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+/// Finds the class a backend runs: the entry point annotation processing
+/// generated for its `@RestController`s, else the one class that declares a
+/// `main` method. Shared by the Maven `cn1:backend` goal and the Gradle
+/// `runBackend` task.
+public final class BackendMainClass {
+ private final Log log;
+ private final String mainClassOption;
+
+ /// @param mainClassOption how the build tool lets a user name the class, for
+ /// the messages (`-Dcn1.backend.mainClass`, `-Pcn1.backend.mainClass`)
+ public BackendMainClass(Log log, String mainClassOption) {
+ this.log = log;
+ this.mainClassOption = mainClassOption;
+ }
+
+ /// `explicit` when set, else the generated entry point, else the one class
+ /// with a main method.
+ public String resolve(File classesDir, String explicit) throws BuildFailureException {
+ if (explicit != null && explicit.length() > 0) {
+ return explicit;
+ }
+ String main = generatedMainClass(classesDir);
+ return main != null && main.length() > 0 ? main : findMainClass(classesDir);
+ }
+
+ /**
+ * The one class in this module with a main method.
+ *
+ * Deliberately an error when there are several rather than a guess: picking
+ * one and running it is how a developer ends up debugging the wrong process.
+ */
+ /**
+ * The entry point annotation processing generated, or null when this module
+ * has none -- one written by hand, with no @RestController in it, has no
+ * marker and falls through to the scan below.
+ */
+ public String generatedMainClass(File classesDir) {
+ File marker = new File(classesDir,
+ com.codename1.maven.processors.RestControllerAnnotationProcessor
+ .MAIN_CLASS_RESOURCE.replace('/', File.separatorChar));
+ if (!marker.isFile()) {
+ return null;
+ }
+ try {
+ byte[] raw = new byte[(int) marker.length()];
+ InputStream in = new java.io.FileInputStream(marker);
+ try {
+ int at = 0;
+ while (at < raw.length) {
+ int n = in.read(raw, at, raw.length - at);
+ if (n <= 0) {
+ break;
+ }
+ at += n;
+ }
+ } finally {
+ in.close();
+ }
+ String name = new String(raw, "UTF-8").trim();
+ return name.length() == 0 ? null : name;
+ } catch (IOException err) {
+ // Unreadable is not the same as absent, and the scan below still has
+ // a fair chance of being right; refusing outright would be worse.
+ log.warn("cn1: could not read " + marker + ": " + err);
+ return null;
+ }
+ }
+
+ public String findMainClass(File classesDir) throws BuildFailureException {
+ return findMainClass(java.util.Collections.singletonList(classesDir));
+ }
+
+ /// The one class with a main method across `classesDirs` -- Gradle compiles
+ /// Java and Kotlin into separate directories, and a main in each is as
+ /// ambiguous as two in one.
+ public String findMainClass(List classesDirs) throws BuildFailureException {
+ List found = new ArrayList();
+ for (File classesDir : classesDirs) {
+ collectMainClasses(classesDir, classesDir, found);
+ }
+ File classesDir = classesDirs.size() == 1 ? classesDirs.get(0) : null;
+ if (found.size() == 1) {
+ return found.get(0);
+ }
+ if (found.isEmpty()) {
+ throw new BuildFailureException("No class with a main method under "
+ + (classesDir != null ? classesDir : classesDirs) + "; set " + mainClassOption);
+ }
+ throw new BuildFailureException("Several classes have a main method ("
+ + join(found, ", ") + "); choose one with " + mainClassOption);
+ }
+
+ private void collectMainClasses(File root, File dir, List found) {
+ File[] children = dir.listFiles();
+ if (children == null) {
+ return;
+ }
+ for (File child : children) {
+ if (child.isDirectory()) {
+ collectMainClasses(root, child, found);
+ } else if (child.getName().endsWith(".class") && child.getName().indexOf('$') < 0) {
+ String name = child.getAbsolutePath()
+ .substring(root.getAbsolutePath().length() + 1)
+ .replace(File.separatorChar, '.');
+ name = name.substring(0, name.length() - ".class".length());
+ if (hasMainMethod(child)) {
+ found.add(name);
+ }
+ }
+ }
+ }
+
+ /**
+ * Whether the class DECLARES `public static void main(String[])`.
+ *
+ * Read from the class file rather than by loading it: loading runs the static
+ * initialiser, and a backend's initialiser is as likely as not to open a
+ * socket or a database. The method table is read with ASM rather than by
+ * searching the bytes, because the constant pool of a class that merely CALLS
+ * main carries the same two strings.
+ */
+ private boolean hasMainMethod(File classFile) {
+ final boolean[] found = new boolean[1];
+ try {
+ InputStream in = new java.io.FileInputStream(classFile);
+ try {
+ new org.objectweb.asm.ClassReader(in).accept(
+ new org.objectweb.asm.ClassVisitor(org.objectweb.asm.Opcodes.ASM9) {
+ @Override
+ public org.objectweb.asm.MethodVisitor visitMethod(int access,
+ String name, String descriptor, String signature,
+ String[] exceptions) {
+ int wanted = org.objectweb.asm.Opcodes.ACC_PUBLIC
+ | org.objectweb.asm.Opcodes.ACC_STATIC;
+ if ("main".equals(name)
+ && "([Ljava/lang/String;)V".equals(descriptor)
+ && (access & wanted) == wanted) {
+ found[0] = true;
+ }
+ return null;
+ }
+ },
+ org.objectweb.asm.ClassReader.SKIP_CODE
+ | org.objectweb.asm.ClassReader.SKIP_DEBUG
+ | org.objectweb.asm.ClassReader.SKIP_FRAMES);
+ } finally {
+ in.close();
+ }
+ } catch (Exception err) {
+ return false;
+ }
+ return found[0];
+ }
+
+ private static String join(List parts, String separator) {
+ StringBuilder sb = new StringBuilder();
+ for (String p : parts) {
+ if (sb.length() > 0) {
+ sb.append(separator);
+ }
+ sb.append(p);
+ }
+ return sb.toString();
+ }
+}
diff --git a/maven/build-engine/src/main/java/com/codename1/maven/BackendPackager.java b/maven/build-engine/src/main/java/com/codename1/maven/BackendPackager.java
new file mode 100644
index 00000000000..3216536a05f
--- /dev/null
+++ b/maven/build-engine/src/main/java/com/codename1/maven/BackendPackager.java
@@ -0,0 +1,1354 @@
+/*
+ * Copyright (c) 2012, 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.maven;
+
+
+import com.codename1.build.BuildArtifact;
+import com.codename1.build.BuildExecutionException;
+import com.codename1.build.BuildFailureException;
+import com.codename1.build.Log;
+import com.codename1.build.ProjectHost;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import com.codename1.maven.annotations.AnnotatedClass;
+import com.codename1.maven.annotations.ClassScanner;
+import com.codename1.maven.annotations.ProcessingException;
+import com.codename1.maven.annotations.ProcessorContext;
+import com.codename1.maven.processors.OrmAnnotationProcessor;
+import com.codename1.maven.processors.RestControllerAnnotationProcessor;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+
+/**
+ * Translates a backend module to C and compiles it to a native binary:
+ * `mvn cn1:backend-package`.
+ *
+ * The counterpart to {@link BackendRunMojo}. That one runs the module on this JVM
+ * in a couple of seconds and is what a developer uses; this one produces the
+ * artifact that deploys -- a single executable with no runtime to install, which
+ * is what makes a scratch container the size of the binary and a Lambda cold start
+ * a process exec.
+ *
+ * What it does, in order:
+ *
+ * 1. Compiles the module's sources together with the backend runtime's SHARED and
+ * PARPARVM halves against the ParparVM JavaAPI as the BOOTCLASSPATH. That last
+ * part is the important one: the bootclasspath IS the server-safe surface, so a
+ * reference to something the translated runtime does not have fails here, in
+ * the IDE and in the build, rather than at link time or in production.
+ * 2. Runs the translator over the result, with the runtime's C sources already in
+ * the source root -- they have to be there BEFORE it runs, because a native's
+ * Java method is kept alive by its C symbol being present.
+ * 3. Compiles the generated C, either with the host compiler or, for a named
+ * Linux target, in a container.
+ *
+ * The compiler flags are not negotiable and are documented at the call site:
+ * generated C relies on wrapping arithmetic, and clang -O3 miscompiles it without
+ * them.
+ */
+public class BackendPackager {
+
+ /// The build tool's answers about the project.
+ protected final ProjectHost host;
+
+ /// A packager for the backend `host` describes.
+ public BackendPackager(ProjectHost host) {
+ this.host = host;
+ }
+
+ protected Log getLog() {
+ return host.log();
+ }
+
+ /// The entry point, when the module names one.
+ protected String mainClass;
+ /// Where the binary goes; defaults to the build directory.
+ protected File output;
+ /// A Linux cross-compile target; not supported yet.
+ protected String target;
+ /// The JDK to compile and translate with.
+ protected String jdkHome;
+ /// The JDK 8 this used to require; still honoured.
+ protected String jdk8Home;
+ /// Extra C compiler flags.
+ protected String cflags;
+ /// Link the bundled SQLite engine.
+ protected boolean sqlite = true;
+ /// Make a failed cast throw.
+ protected boolean checkedCasts = true;
+ /// Whether the binary carries the development MCP tools; see [#devTools(boolean)].
+ protected boolean devTools;
+
+ /// Sets [mainClass].
+ public BackendPackager mainClass(String v) {
+ this.mainClass = v;
+ return this;
+ }
+
+ /// Sets [output].
+ public BackendPackager output(File v) {
+ this.output = v;
+ return this;
+ }
+
+ /// Sets [target].
+ public BackendPackager target(String v) {
+ this.target = v;
+ return this;
+ }
+
+ /// Sets [jdkHome] and [jdk8Home].
+ public BackendPackager jdk(String jdkHome, String jdk8Home) {
+ this.jdkHome = jdkHome;
+ this.jdk8Home = jdk8Home;
+ return this;
+ }
+
+ /// Sets [cflags].
+ public BackendPackager cflags(String v) {
+ this.cflags = v;
+ return this;
+ }
+
+ /// Sets [sqlite].
+ public BackendPackager sqlite(boolean v) {
+ this.sqlite = v;
+ return this;
+ }
+
+ /// Whether the packaged server carries the development MCP tools. Off by
+ /// default: they read the database and call the server on an agent's
+ /// behalf, and a production binary should not contain them at all -- not
+ /// merely have them switched off. The JVM run has them.
+ public BackendPackager devTools(boolean v) {
+ this.devTools = v;
+ return this;
+ }
+
+ /// Sets [checkedCasts].
+ public BackendPackager checkedCasts(boolean v) {
+ this.checkedCasts = v;
+ return this;
+ }
+
+ /// The name the binary gets by default: the module's artifact id.
+ protected String binaryName() {
+ return host.finalName();
+ }
+
+ /// Where the build tool put the module's processed resources.
+ protected File processedResourcesDirectory() {
+ return host.outputDirectory();
+ }
+
+ /// Whether the module declares resources at all, for the warning when none
+ /// were processed.
+ protected boolean declaresResources() {
+ return true;
+ }
+
+ /// The encoding the module's sources are written in; see the Maven
+ /// plugin's override, which reads the compiler plugin's configuration.
+ protected String sourceEncoding() {
+ String property = host.projectProperties() == null ? null
+ : host.projectProperties().getProperty("project.build.sourceEncoding");
+ if (property != null && property.trim().length() > 0) {
+ return property.trim();
+ }
+ return "UTF-8";
+ }
+
+ /// Resolves `groupId:artifactId:version:classifier`.
+ protected File resolve(String groupId, String artifactId, String version, String classifier)
+ throws BuildExecutionException {
+ File f = host.getJar(groupId, artifactId, classifier);
+ if (f == null) {
+ throw new BuildExecutionException("Could not resolve " + groupId + ":"
+ + artifactId + ":" + version + ":" + classifier);
+ }
+ return f;
+ }
+
+ /// Builds the binary and returns where it went.
+ public File execute() throws BuildExecutionException {
+ File jdk = resolveJdk();
+ File work = new File(host.buildDirectory().getPath(), "cn1-backend");
+ File classes = new File(work, "classes");
+ File javaApi = new File(work, "javaapi-classes");
+ File runtimeSources = new File(work, "runtime-src");
+ File nativeSources = new File(work, "native");
+ File translated = new File(work, "translated");
+ File dependencyClasses = new File(work, "dependency-classes");
+ // Emptied, not just created. Every one of these is derived, and nothing here
+ // removes a file that stopped being produced: a renamed or deleted source
+ // left its old .class behind, the translator still read it, and even
+ // requireMainClass accepted a main class the module no longer had -- so the
+ // package that came out was the previous implementation. Rebuilding from
+ // clean costs nothing, since neither the javac nor the clang pass below was
+ // ever incremental.
+ emptyDirs(classes, javaApi, runtimeSources, nativeSources, translated,
+ dependencyClasses);
+ mkdirs(work, classes, javaApi, runtimeSources, nativeSources, translated,
+ dependencyClasses);
+
+ // The version of the runtime THIS MODULE compiles against, not the
+ // module's own: the sources handed to the translator have to be the same
+ // ones behind the classes the developer just built against, or the local
+ // run and the deployed binary are different programs.
+ String runtimeVersion = backendRuntimeVersion();
+ File runtimeJar = resolve("com.codenameone", "codenameone-backend",
+ runtimeVersion, "parparvm-sources");
+ unzip(runtimeJar, runtimeSources, nativeSources);
+ File parparvmBundle = resolve("com.codenameone", "codenameone-parparvm",
+ runtimeVersion, "bundle");
+ File bundleDir = new File(work, "parparvm");
+ mkdirs(bundleDir);
+ unzip(parparvmBundle, bundleDir, null);
+ File compilerJar = new File(bundleDir, "parparvm-compiler.jar");
+ File javaApiJar = new File(bundleDir, "parparvm-java-api.jar");
+ if (!compilerJar.isFile() || !javaApiJar.isFile()) {
+ throw new BuildExecutionException("The ParparVM bundle is missing its "
+ + "compiler or JavaAPI jar: " + parparvmBundle);
+ }
+ unzip(javaApiJar, javaApi, null);
+
+ compile(jdk, javaApi, runtimeSources, classes);
+ generateControllers(classes, work);
+ requireMainClass(classes);
+ translate(jdk, compilerJar, javaApi, classes, nativeSources, translated,
+ dependencyClasses);
+ File binary = output != null ? output
+ : new File(host.buildDirectory().getPath(), binaryName());
+ link(translated, binary);
+ getLog().info("built " + binary);
+ return binary;
+ }
+
+ /**
+ * Compiles the module's sources and the runtime's against the JavaAPI as the
+ * BOOTCLASSPATH. See the class comment for why that matters.
+ */
+ /**
+ * Generates the routers and the bootstrap for this module's `@RestController`
+ * classes, into the directory this goal has just compiled into.
+ *
+ * Not left to the `process-annotations` goal, which writes into Maven's
+ * target/classes: that is a different build, made against a JDK rather than
+ * against the backend's class library, and the translator never reads it. A
+ * router generated there would be absent from the binary while looking present
+ * in the project. Generating into the tree that is about to be translated is
+ * what makes the wiring real.
+ *
+ * Sets mainClass to the generated bootstrap when the module did not name one.
+ */
+ private void generateControllers(File classes, File work) throws BuildExecutionException {
+ Map index;
+ try {
+ index = ClassScanner.scan(classes);
+ } catch (ProcessingException err) {
+ throw new BuildExecutionException("Could not scan the compiled backend classes: "
+ + err.getMessage(), err);
+ }
+ RestControllerAnnotationProcessor processor = new RestControllerAnnotationProcessor();
+ processor.setDevTools(devTools);
+ ProcessorContext ctx = new ProcessorContext(classes, new File(work, "stubs"), index,
+ getLog(), host.baseDir(), new Properties(), mainClass,
+ java.util.Collections.emptyList(), "UTF-8",
+ compileClasspathWithoutRuntime());
+ // THE ENTITIES FIRST, into this same tree.
+ //
+ // The entry point generated below references cn1app.BackendDaoBootstrap
+ // whenever the module has an @Entity, because that reference is the only
+ // thing that keeps the generated daos in the binary -- the translator
+ // drops a class nothing names. This goal empties and rebuilds its own
+ // class tree and never copies Maven's target/classes into it, so a dao
+ // generated by the process-annotations goal is not here: without this
+ // pass the entry point names a class that does not exist and packaging
+ // fails at its own javac, on any project that has both an entity and a
+ // controller.
+ //
+ // The flavour is forced because the classpath cannot answer for it here;
+ // see OrmAnnotationProcessor#setBackendFlavour.
+ OrmAnnotationProcessor entities = new OrmAnnotationProcessor();
+ entities.setBackendFlavour(true);
+ try {
+ entities.start(ctx);
+ for (AnnotatedClass cls : index.values()) {
+ if (!cls.getClassAnnotations().isEmpty()) {
+ entities.processClass(cls, ctx);
+ }
+ }
+ entities.finish(ctx);
+ } catch (ProcessingException err) {
+ throw new BuildExecutionException("Could not process @Entity: "
+ + err.getMessage(), err);
+ }
+ // Re-scanned, so the controller pass sees the daos and the bootstrap the
+ // pass above just wrote. hasGeneratedDaos() reads this index.
+ try {
+ index = ClassScanner.scan(classes);
+ } catch (ProcessingException err) {
+ throw new BuildExecutionException("Could not re-scan the backend classes after "
+ + "generating the entity daos: " + err.getMessage(), err);
+ }
+ try {
+ processor.start(ctx);
+ for (AnnotatedClass cls : index.values()) {
+ if (!cls.getClassAnnotations().isEmpty()) {
+ processor.processClass(cls, ctx);
+ }
+ }
+ // finish() runs the bean pass -- the wiring, the rewritten classes and
+ // the classes generated beside them -- into this same tree.
+ processor.finish(ctx);
+ entities.enhance(ctx);
+ } catch (ProcessingException err) {
+ throw new BuildExecutionException("Could not process @RestController: "
+ + err.getMessage(), err);
+ }
+ if (ctx.hasErrors()) {
+ StringBuilder sb = new StringBuilder("The backend's annotations could not be processed:");
+ for (ProcessorContext.ProcessingError e : ctx.getErrors()) {
+ sb.append("\n ").append(e);
+ }
+ throw new BuildExecutionException(sb.toString());
+ }
+ byte[] generated = ctx.getEmittedResources()
+ .get(RestControllerAnnotationProcessor.MAIN_CLASS_RESOURCE);
+ if (generated == null) {
+ return;
+ }
+ String name;
+ try {
+ name = new String(generated, "UTF-8").trim();
+ } catch (java.io.UnsupportedEncodingException err) {
+ throw new BuildExecutionException("UTF-8 is required of every JDK", err);
+ }
+ if (mainClass == null || mainClass.length() == 0) {
+ mainClass = name;
+ getLog().info("cn1: entry point " + name + ", generated from @RestController");
+ }
+ }
+
+ /**
+ * Fails here, with the reason, rather than inside the translator.
+ *
+ * The compile below reads .java and only .java, on purpose: recompiling against
+ * the JavaAPI bootclasspath is what turns "this backend uses a class the runtime
+ * does not have" into a compile error instead of a link failure on the device,
+ * and reusing the jar Maven already built would give that up. The cost is that a
+ * main class written in Kotlin -- which `cn1:backend` runs happily, because that
+ * goal is a JVM launch -- never reaches this directory, and the translator's own
+ * complaint about it names neither Kotlin nor the reason. So say it plainly. The
+ * developer guide's "Limits worth knowing" carries the same statement.
+ */
+ /**
+ * The encoding this module's sources are actually written in.
+ *
+ * These are the SAME sources the lifecycle has already compiled, so
+ * reading them differently here is a second, disagreeing compilation of one
+ * tree: a module that declares another encoding either fails packaging on
+ * bytes javac accepted a phase earlier, or -- worse, because nothing says so
+ * -- translates string literals and identifiers that are not the ones the JVM
+ * build produced.
+ *
+ *
Resolved the way the compiler plugin resolves it, most specific first:
+ * an explicit <encoding> on maven-compiler-plugin, then
+ * project.build.sourceEncoding, and UTF-8 only when the module says nothing.
+ * The platform default is deliberately not the last resort -- it makes the
+ * build depend on the machine that runs it, which is the reason Maven warns
+ * about it.
+ */
+ private void requireMainClass(File classes) throws BuildFailureException {
+ if (mainClass == null || mainClass.length() == 0) {
+ throw new BuildFailureException("No entry point: set , or annotate "
+ + "a class with @RestController and let the bootstrap be generated "
+ + "from it");
+ }
+ if (new File(classes, mainClass.replace('.', '/') + ".class").isFile()) {
+ return;
+ }
+ throw new BuildFailureException("The main class " + mainClass + " was not "
+ + "produced by the backend compile. This goal compiles Java sources "
+ + "against the backend class library, so a main class written in "
+ + "Kotlin or generated into the build output is not visible to it "
+ + "yet -- write the entry point in Java, or keep it on the JVM with "
+ + "cn1:backend");
+ }
+
+ private void compile(File jdk, File javaApi, File runtimeSources, File classes)
+ throws BuildExecutionException, BuildFailureException {
+ List sources = new ArrayList();
+ for (Object root : host.compileSourceRoots()) {
+ collectJava(new File(String.valueOf(root)), sources);
+ }
+ collectJava(runtimeSources, sources);
+ if (sources.isEmpty()) {
+ throw new BuildFailureException("No Java sources to compile");
+ }
+
+ List command = new ArrayList(Arrays.asList(
+ new File(jdk, "bin/javac").getAbsolutePath(),
+ "-nowarn", "-encoding", sourceEncoding(),
+ "-bootclasspath", javaApi.getAbsolutePath(),
+ "-source", "1.8", "-target", "1.8",
+ "-d", classes.getAbsolutePath()));
+ // The module's own dependencies, MINUS the backend runtime: its compiled
+ // form was built against a JDK, and the sources unpacked above are the
+ // half that belongs on this bootclasspath.
+ List classpath = new ArrayList();
+ for (Object element : compileClasspathWithoutRuntime()) {
+ classpath.add(String.valueOf(element));
+ }
+ if (!classpath.isEmpty()) {
+ command.add("-classpath");
+ command.add(join(classpath, File.pathSeparator));
+ }
+ command.addAll(sources);
+ // -source/-target 8 is the translator's input format and is not a property
+ // of the compiler running here: every javac from 8 up emits the same class
+ // file version 52 for it. A javac that has dropped the option says so in
+ // its own words and names no remedy, so the remedy is added to it.
+ //
+ // THE REMEDY IS THE JDK RUNNING MAVEN, not -Dcn1.backend.jdk, even though
+ // that property is what selects the compiler on this line. Only the two
+ // FORKED steps read it -- this javac and the translator's java.
+ // generateControllers() compiles the router and the entry point in
+ // process, through ToolProvider.getSystemJavaCompiler(), which is the
+ // Maven JVM's compiler and cannot be pointed anywhere; it asks for
+ // -source 1.8 as well, so it fails next on a compiler that has dropped
+ // it. Naming the property here would send a developer to a setting that
+ // moves the failure by one step and no further.
+ //
+ // Routing that in-process compile through the selected JDK is not the
+ // answer either. A release that removes -source 8 takes it away from the
+ // Maven JVM too, and this build needs a javac that emits class file
+ // version 52 in many more places than this goal -- codenameone-core
+ // compiles at 1.5. The whole toolchain moves then, not one mojo.
+ try {
+ run(command, host.baseDir(), "compile the backend sources");
+ } catch (BuildFailureException err) {
+ throw dropsSourceEight(err)
+ ? new BuildFailureException(err.getMessage() + "\n\n"
+ + SOURCE_EIGHT_REMOVED_HINT, err)
+ : err;
+ }
+ stageResources(classes);
+ }
+
+ /**
+ * Copies the module's resources in beside the classes just compiled.
+ *
+ * This directory is emptied and then filled from .java alone, and the project's
+ * own output directory is excluded from the translator input on purpose -- its
+ * classes were built against a JDK. The consequence was that anything read from
+ * the classpath, a properties or configuration file, was present under
+ * cn1:backend and simply absent from the packaged binary. Nothing failed at
+ * build time; the resource was just not there at runtime.
+ *
+ * Everything EXCEPT .class is taken, which is exactly the resources and none of
+ * the JDK-compiled code.
+ *
+ * ONLY Maven's processed output, never the raw resource directories. Those
+ * directories are what a / selects FROM, so copying them
+ * wholesale packaged the files the build was configured to leave out -- an
+ * environment file or a secret excluded on purpose would have gone into the
+ * executable, and the later overlay could not remove it. The processed copy is
+ * the answer Maven already computed.
+ */
+ private void stageResources(File classes) throws BuildExecutionException {
+ File processed = processedResourcesDirectory();
+ if (!processed.isDirectory()) {
+ // Nothing has processed the resources, so there are none to stage and
+ // nothing to guess at. Said out loud, because a resource silently absent
+ // from the binary is the failure this whole step exists to prevent.
+ if (declaresResources()) {
+ getLog().warn("cn1: this module declares resources but "
+ + processed + " does not exist, so none are packaged. Run "
+ + "process-resources first, or invoke this through the "
+ + "lifecycle rather than as a bare goal.");
+ }
+ return;
+ }
+ try {
+ int staged = copyNonClasses(processed, classes);
+ // Staged is not the same as READABLE, and the difference is silent.
+ // These files reach the translator, so anything that reads them at
+ // BUILD time works -- but the backend translates as app type "clean",
+ // and only the linux and windows types embed classpath resources into
+ // the binary. The clean runtime's Class.getResourceAsStream returns
+ // null unconditionally, so getResourceAsStream finds the file under
+ // cn1:backend, on the JVM, and finds nothing in the packaged
+ // executable. Said out loud rather than left to be discovered in
+ // production; embedding them is a change to the translator and the
+ // shared runtime, not to this goal.
+ if (staged > 0) {
+ getLog().warn("cn1: staged " + staged + " resource file(s) for translation, "
+ + "but a packaged backend cannot READ them: getResourceAsStream "
+ + "answers null in the translated runtime, though it works under "
+ + "cn1:backend. Read configuration from a file path or the "
+ + "environment instead of the classpath.");
+ }
+ } catch (IOException err) {
+ // A resource that cannot be staged is a packaging failure, not a note:
+ // the executable would be reported as built while missing something
+ // cn1:backend has, and the difference would first appear in production.
+ throw new BuildExecutionException("Could not stage the processed resources "
+ + "from " + processed + " into " + classes, err);
+ }
+ }
+
+ /** @return how many non-class files were copied. */
+ private int copyNonClasses(File from, File to) throws IOException {
+ if (from == null || !from.isDirectory()) {
+ return 0;
+ }
+ File[] children = from.listFiles();
+ if (children == null) {
+ return 0;
+ }
+ int copied = 0;
+ for (File child : children) {
+ File target = new File(to, child.getName());
+ if (child.isDirectory()) {
+ target.mkdirs();
+ copied += copyNonClasses(child, target);
+ } else if (!child.getName().endsWith(".class")) {
+ copyFile(child, target);
+ copied++;
+ }
+ }
+ return copied;
+ }
+
+ private static void copyFile(File from, File to) throws IOException {
+ InputStream in = new java.io.FileInputStream(from);
+ try {
+ OutputStream out = new java.io.FileOutputStream(to);
+ try {
+ byte[] chunk = new byte[8192];
+ int n;
+ while ((n = in.read(chunk)) > 0) {
+ out.write(chunk, 0, n);
+ }
+ } finally {
+ out.close();
+ }
+ } finally {
+ in.close();
+ }
+ }
+
+ private List compileClasspathWithoutRuntime() throws BuildExecutionException {
+ List classpath = new ArrayList();
+ try {
+ for (Object element : host.compileClasspathElements()) {
+ classpath.add(String.valueOf(element));
+ }
+ } catch (Exception err) {
+ throw new BuildExecutionException("Could not resolve the compile classpath", err);
+ }
+ return withoutRuntime(classpath, runtimeArtifactFile(),
+ host.outputDirectory().getPath());
+ }
+
+ /**
+ * The classpath without the backend runtime and without this module's own
+ * output.
+ *
+ * IDENTIFIED BY FILE, not by looking for "codenameone-backend" anywhere in
+ * a path. A project checked out under a directory whose name contains that --
+ * /work/codenameone-backend-demo/ is the obvious one -- has EVERY reactor
+ * dependency below it match, so a backend depending on a sibling contract
+ * module lost it from both the compile classpath and the translation, and
+ * failed on classes it plainly depends on. The name of a directory somewhere
+ * above the project is not something a build should read meaning into.
+ *
+ *
The runtime is left out because its sources are compiled into `classes`
+ * already; the module's own output for the same reason.
+ *
+ * @param runtime the resolved runtime artifact, or null when it cannot be
+ * located -- then nothing is dropped for it, which is
+ * duplicate work rather than a missing class
+ */
+ static List withoutRuntime(List classpath, File runtime, String ownOutput) {
+ List out = new ArrayList();
+ File runtimeFile = runtime == null ? null : runtime.getAbsoluteFile();
+ File output = ownOutput == null ? null : new File(ownOutput).getAbsoluteFile();
+ for (int i = 0; i < classpath.size(); i++) {
+ String path = classpath.get(i);
+ File element = new File(path).getAbsoluteFile();
+ if (runtimeFile != null && runtimeFile.equals(element)) {
+ continue;
+ }
+ if (output != null && output.equals(element)) {
+ continue;
+ }
+ out.add(path);
+ }
+ return out;
+ }
+
+ /** The resolved file of com.codenameone:codenameone-backend, or null. */
+ private File runtimeArtifactFile() {
+ java.util.Collection artifacts = host.artifacts();
+ if (artifacts != null) {
+ for (BuildArtifact artifact : artifacts) {
+ if ("com.codenameone".equals(artifact.getGroupId())
+ && "codenameone-backend".equals(artifact.getArtifactId())) {
+ return artifact.getFile();
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * The compile classpath staged as ONE directory the translator can read.
+ *
+ * ByteCodeTranslator walks its inputs with File.listFiles, which answers NULL
+ * for a jar -- and the walk reads null as an empty directory, so a dependency
+ * resolved from the repository as a jar contributed nothing at all, without a
+ * word. The build then failed much later, while linking, on the symbols of
+ * classes the translator had never been shown. A module in the same reactor
+ * resolves to its target/classes and worked, which is why the generated
+ * project's own contract module never showed this.
+ *
+ * ONE TREE, FIRST WINS, IN CLASSPATH ORDER, and not a list of inputs.
+ * Parser.classIndex keeps the first definition of a class it parsed, so the
+ * order of the translator's inputs IS precedence -- and javac resolved the
+ * same classpath the same way, so anything that reorders it compiles against
+ * one definition and translates another. Staging settles it on disk instead:
+ * whatever arrives first is what is there, every later copy is skipped, and
+ * there is no order left to get wrong. It also means a class two dependencies
+ * both carry is PARSED once rather than twice, which is where duplicate
+ * symbols came from.
+ *
+ *
Directories are copied rather than passed through for that reason alone.
+ * They cost a copy of their class files per build, which is a reactor
+ * module's output and small beside the translation that follows.
+ *
+ *
cn1-native goes where the runtime jar's natives go, so a dependency that
+ * ships them is built rather than dropped just as quietly. META-INF is
+ * skipped out of jars by the same unpacking the runtime gets; a directory is
+ * copied as it stands, which is what passing it as an input already did.
+ *
+ * @param classpath compile classpath elements, in classpath order
+ * @param staged directory to stage into; assumed empty
+ * @param nativeSources where a dependency's cn1-native entries belong
+ */
+ List stageDependencyClasses(List classpath, File staged,
+ File nativeSources) throws BuildExecutionException {
+ List out = new ArrayList();
+ boolean any = false;
+ for (int i = 0; i < classpath.size(); i++) {
+ File element = new File(classpath.get(i));
+ if (element.isDirectory()) {
+ copyDirectoryFirstWins(element, staged, nativeSources);
+ any = true;
+ } else if (element.isFile()) {
+ unzip(element, staged, nativeSources, true);
+ any = true;
+ }
+ // An entry that is neither is one javac will complain about; there is
+ // nothing here to stage and nothing to say that it will not say.
+ }
+ if (any) {
+ out.add(staged.getAbsolutePath());
+ }
+ return out;
+ }
+
+ private void translate(File jdk, File compilerJar, File javaApi, File classes,
+ File nativeSources, File translated, File dependencyClasses)
+ throws BuildExecutionException, BuildFailureException {
+ String simpleName = mainClass.substring(mainClass.lastIndexOf('.') + 1);
+ String packageName = mainClass.lastIndexOf('.') < 0 ? ""
+ : mainClass.substring(0, mainClass.lastIndexOf('.'));
+
+ // BEFORE the natives are copied below, because a dependency that ships
+ // cn1-native adds to them.
+ List dependencyInputs = stageDependencyClasses(
+ compileClasspathWithoutRuntime(), dependencyClasses, nativeSources);
+
+ // The C has to be in the source root BEFORE the translator runs: it reads
+ // the directory to decide which native-only Java methods to keep, and the
+ // signature verifier checks every declared native against an actual
+ // implementation.
+ File sourceDir = new File(translated, "dist/" + simpleName + "-src");
+ mkdirs(sourceDir);
+ copyDirectory(nativeSources, sourceDir);
+
+ List command = new ArrayList();
+ command.add(new File(jdk, "bin/java").getAbsolutePath());
+ if (sqlite) {
+ command.add("-Dcn1.sqlite=true");
+ }
+ if (checkedCasts) {
+ command.add("-Dcn1.checkedCasts=true");
+ }
+ command.add("-cp");
+ command.add(compilerJar.getAbsolutePath());
+ command.add("com.codename1.tools.translator.ByteCodeTranslator");
+ command.add("clean");
+ // The module's dependencies belong on the translator's input, not only on
+ // javac's classpath. Without them a backend that uses a type from another
+ // module -- the shared contract or DTO module the generated project
+ // recommends -- compiles here and then fails to translate, because javac
+ // resolved the type from a jar whose bytecode the translator never sees.
+ // The runtime is excluded for the same reason it is excluded from javac's
+ // classpath: its sources are compiled into `classes` already.
+ StringBuilder translatorInput = new StringBuilder();
+ translatorInput.append(javaApi.getAbsolutePath())
+ .append(';').append(classes.getAbsolutePath());
+ for (int i = 0; i < dependencyInputs.size(); i++) {
+ translatorInput.append(';').append(dependencyInputs.get(i));
+ }
+ command.add(translatorInput.toString());
+ command.add(translated.getAbsolutePath());
+ command.add(simpleName);
+ command.add(packageName);
+ command.add(simpleName);
+ command.add("1.0");
+ command.add("clean");
+ command.add("none");
+ run(command, host.baseDir(), "translate the backend to C");
+ }
+
+ private void link(File translated, File binary)
+ throws BuildExecutionException, BuildFailureException {
+ String simpleName = mainClass.substring(mainClass.lastIndexOf('.') + 1);
+ File sourceDir = new File(translated, "dist/" + simpleName + "-src");
+ // Kept as a loud failure rather than dropped: the parameter names a real
+ // capability, and silently ignoring -Dcn1.backend.target would hand back a
+ // host binary labelled as a cross-compiled one. The script named here lives in
+ // the Codename One repository, not in a generated project, which is why the
+ // message says where it is instead of assuming it is on hand.
+ if (target != null && target.length() > 0) {
+ throw new BuildFailureException("cn1.backend.target is not supported from "
+ + "this goal yet: it builds for the machine it runs on. The "
+ + "cross-compiled targets (musl-x86_64, musl-arm64, glibc-x86_64, "
+ + "glibc-arm64) are produced by package.sh in the Codename One "
+ + "repository, which drives one container image per target; run "
+ + "this goal inside a container of the target flavour to get the "
+ + "same artifact here");
+ }
+ List command = new ArrayList(Arrays.asList(
+ "clang", "-O3", "-w",
+ // Mandatory for generated C: Java arithmetic wraps, and clang -O3
+ // provably miscompiles the output without these.
+ "-fwrapv", "-fno-strict-aliasing",
+ "-fno-builtin-fmod", "-fno-builtin-fmodf"));
+ if (!sqlite) {
+ // Turning the engine OFF is two changes, not one. Without
+ // -Dcn1.sqlite=true the translator leaves cn1_sqlite3.h out, but
+ // cn1_backend_db.c is copied and compiled either way -- and its
+ // SQLite branch includes that header unconditionally, so the compile
+ // fails with "cn1_sqlite3.h file not found" and the option advertised
+ // as saving the engine could not produce a binary at all. The macro
+ // is what compiles that file to stubs instead, which answer "could
+ // not open" and become an IOException, rather than dropping the Db
+ // natives and taking their Java methods with them. build.sh has
+ // always set both; this half had only the first.
+ command.add("-DCN1_BACKEND_NO_SQLITE");
+ }
+ if (cflags != null && cflags.trim().length() > 0) {
+ command.addAll(Arrays.asList(cflags.trim().split("\\s+")));
+ }
+ // Before -I on the generated sources, which is where build.sh puts them.
+ command.addAll(hostLibraryFlags(opensslPrefixes(), nghttp2Prefixes()));
+ command.add("-I" + sourceDir.getAbsolutePath());
+ File[] cFiles = sourceDir.listFiles();
+ if (cFiles == null) {
+ throw new BuildExecutionException("The translator produced nothing in " + sourceDir);
+ }
+ for (File file : cFiles) {
+ String name = file.getName();
+ // .S as well as .c, which is what vm/backend/build.sh compiles. The
+ // translator always emits cn1_virtual_thread_asm.S, and
+ // cn1_virtual_thread.c calls cn1VirtualThreadSwitch out of it, so a
+ // command that passed only .c reached the linker with that symbol
+ // undefined and this goal could not produce a binary at all.
+ // Generated resource assembly is in the same position.
+ if (name.endsWith(".c") || name.endsWith(".S") || name.endsWith(".s")) {
+ command.add(file.getAbsolutePath());
+ }
+ }
+ command.addAll(Arrays.asList("-lm", "-lpthread",
+ "-lcurl", "-lssl", "-lcrypto", "-lnghttp2"));
+ command.add("-o");
+ command.add(binary.getAbsolutePath());
+ run(command, host.baseDir(), "compile the generated C");
+ }
+
+ /**
+ * -I and -L for the TLS and HTTP/2 libraries, where this machine keeps them.
+ *
+ * macOS ships libcrypto WITHOUT its headers, so a stock machine with the
+ * usual Homebrew OpenSSL could not compile the generated C at all: the goal
+ * the documentation tells a developer to run failed on openssl/ssl.h, and the
+ * only way out was to work out cn1.backend.cflags for themselves. The same
+ * prefixes and the same probe headers as vm/backend/build.sh, so the two ways
+ * of building a backend look in the same places -- including OPENSSL_PREFIX
+ * and NGHTTP2_PREFIX, which a developer who has already set them for build.sh
+ * should not have to set again under another name.
+ *
+ * On a Linux box the distribution's -dev package puts the headers where
+ * clang already looks, none of these probes match, and this adds nothing.
+ *
+ * @param openssl candidate prefixes for OpenSSL, in order of preference
+ * @param nghttp2 candidate prefixes for nghttp2
+ */
+ static List hostLibraryFlags(List openssl, List nghttp2) {
+ List out = new ArrayList();
+ addPrefix(out, openssl, "include/openssl/sha.h");
+ addPrefix(out, nghttp2, "include/nghttp2/nghttp2.h");
+ return out;
+ }
+
+ /** The first prefix that actually carries `probe`, as -I and -L. */
+ private static void addPrefix(List out, List prefixes, String probe) {
+ if (prefixes == null) {
+ return;
+ }
+ for (int i = 0; i < prefixes.size(); i++) {
+ String prefix = prefixes.get(i);
+ if (prefix == null || prefix.length() == 0) {
+ continue;
+ }
+ if (new File(prefix, probe).isFile()) {
+ out.add("-I" + new File(prefix, "include").getAbsolutePath());
+ out.add("-L" + new File(prefix, "lib").getAbsolutePath());
+ return;
+ }
+ }
+ }
+
+ private List opensslPrefixes() {
+ return prefixesFrom(System.getenv("OPENSSL_PREFIX"),
+ "/opt/homebrew/opt/openssl@3", "/usr/local/opt/openssl@3");
+ }
+
+ private List nghttp2Prefixes() {
+ return prefixesFrom(System.getenv("NGHTTP2_PREFIX"),
+ "/opt/homebrew/opt/libnghttp2", "/opt/homebrew/opt/nghttp2",
+ "/usr/local/opt/libnghttp2");
+ }
+
+ private static List prefixesFrom(String fromEnvironment, String... defaults) {
+ List out = new ArrayList();
+ if (fromEnvironment != null && fromEnvironment.length() > 0) {
+ out.add(fromEnvironment);
+ }
+ out.addAll(Arrays.asList(defaults));
+ return out;
+ }
+
+ /**
+ * The codenameone-backend version this module depends on.
+ *
+ * Deliberately an error rather than a default when the dependency is absent:
+ * guessing a version here would translate a different runtime from the one the
+ * module was compiled and tested against.
+ */
+ private String backendRuntimeVersion() throws BuildFailureException {
+ java.util.Collection artifacts = host.artifacts();
+ if (artifacts != null) {
+ for (BuildArtifact artifact : artifacts) {
+ if ("com.codenameone".equals(artifact.getGroupId())
+ && "codenameone-backend".equals(artifact.getArtifactId())) {
+ return artifact.getVersion();
+ }
+ }
+ }
+ throw new BuildFailureException("This module does not depend on "
+ + "com.codenameone:codenameone-backend, so there is no backend "
+ + "runtime to translate. Add it as a dependency.");
+ }
+
+ /**
+ * The JDK that compiles the sources for translation and runs the translator.
+ *
+ * ANY JDK 8 OR NEWER, which for almost every project means the one already
+ * running Maven and nothing to configure. This goal used to demand a JDK 8
+ * and refuse to run without one, on the premise that a newer javac emits
+ * class files the translator cannot read. The premise was wrong: {@link
+ * #compile} passes -source 1.8 -target 1.8, so the class file version is 52
+ * whichever javac produces it, and the bootclasspath that confines the build
+ * to the server-safe surface is enforced identically -- a reference to
+ * java.nio.file still fails to compile on 8, 17, 21 and 25 alike. The cost of
+ * the premise was paid entirely by the developer, who had to install a JDK
+ * from 2014 to package a server.
+ *
+ * The two properties are tried before the running JDK so an explicit choice
+ * still wins, and cn1.backend.jdk8 is among them so the setups that were
+ * required to set it keep working.
+ *
+ * WHAT THEY SELECT IS THE TWO FORKED STEPS -- {@link #compile}'s javac and
+ * {@link #translate}'s java -- and nothing else. The router and entry point
+ * that {@link #generateControllers} produces are compiled in process by the
+ * JDK running Maven, which JSR 199 gives no way to redirect. That costs
+ * nothing today, because every JDK from 8 up emits the class file version 52
+ * the translator reads; see {@link #compile} for why it is also not worth
+ * forking, and why the error message there names Maven's own JDK.
+ */
+ File resolveJdk() throws BuildFailureException {
+ String[] configured = {jdkHome, jdk8Home};
+ for (int i = 0; i < configured.length; i++) {
+ if (configured[i] != null && configured[i].length() > 0) {
+ File home = new File(configured[i]);
+ if (hasJavac(home)) {
+ return requireEightOrNewer(home);
+ }
+ }
+ }
+ File running = new File(System.getProperty("java.home"));
+ if (hasJavac(running)) {
+ return requireEightOrNewer(running);
+ }
+ // The Java 8 layout points java.home at the jre inside the JDK, where
+ // there is no compiler; it is one level up.
+ File parent = running.getParentFile();
+ if (parent != null && hasJavac(parent)) {
+ return requireEightOrNewer(parent);
+ }
+ throw new BuildFailureException("Packaging a backend needs a JDK, and "
+ + System.getProperty("java.home") + " has no javac -- Maven is "
+ + "running on a JRE. Run it on a JDK, or point "
+ + "-Dcn1.backend.jdk at one.");
+ }
+
+ /**
+ * Refuses a JDK older than 8, whose javac cannot emit the format the
+ * translator reads.
+ *
+ * Only reachable through an explicitly configured JDK: Maven itself needs 8
+ * or newer, so the running one always passes. A javac that cannot be asked
+ * its version is ACCEPTED rather than refused -- the compile that follows
+ * reports what is actually wrong with it, and inventing a failure here would
+ * hide that.
+ */
+ File requireEightOrNewer(File home) throws BuildFailureException {
+ int major = javacMajor(home);
+ if (major > 0 && major < 8) {
+ throw new BuildFailureException("A JDK 8 or newer is required to "
+ + "package a backend; " + home + " is a JDK " + major + ".");
+ }
+ return home;
+ }
+
+ /**
+ * The feature version of a JDK's javac, or -1 when it cannot be determined.
+ *
+ * Read by running it, not by parsing the path: a directory name says nothing
+ * reliable. Java 8 prints "javac 1.8.0_402" on stderr and later releases
+ * print "javac 21.0.2" on stdout, so both streams are read and both spellings
+ * are understood.
+ */
+ private static int javacMajor(File home) {
+ try {
+ ProcessBuilder builder = new ProcessBuilder(
+ new File(home, "bin/javac").isFile()
+ ? new File(home, "bin/javac").getAbsolutePath()
+ : new File(home, "bin/javac.exe").getAbsolutePath(),
+ "-version");
+ builder.redirectErrorStream(true);
+ Process process = builder.start();
+ StringBuilder output = new StringBuilder();
+ InputStream in = process.getInputStream();
+ byte[] chunk = new byte[512];
+ int n;
+ while ((n = in.read(chunk)) > 0) {
+ output.append(new String(chunk, 0, n, "UTF-8"));
+ }
+ process.waitFor();
+ return parseJavacVersion(output.toString());
+ } catch (IOException err) {
+ return -1;
+ } catch (InterruptedException err) {
+ Thread.currentThread().interrupt();
+ return -1;
+ }
+ }
+
+ /** Package private so the parsing is testable without a JDK to run. */
+ static int parseJavacVersion(String output) {
+ if (output == null) {
+ return -1;
+ }
+ int at = output.indexOf("javac ");
+ if (at < 0) {
+ return -1;
+ }
+ String version = output.substring(at + "javac ".length()).trim();
+ // "1.8.0_402" is Java 8; "21.0.2" is Java 21. The leading "1." is the old
+ // spelling and the number after it is the feature version.
+ if (version.startsWith("1.")) {
+ version = version.substring(2);
+ }
+ int end = 0;
+ while (end < version.length() && Character.isDigit(version.charAt(end))) {
+ end++;
+ }
+ if (end == 0) {
+ return -1;
+ }
+ try {
+ return Integer.parseInt(version.substring(0, end));
+ } catch (NumberFormatException err) {
+ return -1;
+ }
+ }
+
+ /**
+ * What to do about a javac that has dropped -source 8.
+ *
+ * Package private so a test can hold the wording to a remedy that WORKS. An
+ * earlier version of this sent the developer to -Dcn1.backend.jdk, which
+ * selects the two forked steps and not the in-process compile of the
+ * generated router and entry point, so following it moved the failure by one
+ * step and no further. See #compile.
+ */
+ static final String SOURCE_EIGHT_REMOVED_HINT =
+ "This javac no longer accepts -source 8, which is the format the "
+ + "translator reads. Run Maven itself on a JDK that still does: "
+ + "-Dcn1.backend.jdk selects the compiler for this step and the "
+ + "translator, but the generated router and entry point are compiled "
+ + "in process by the JDK running Maven.";
+
+ /**
+ * Whether a failed compile is javac refusing -source 8 outright, rather than
+ * anything about the sources.
+ *
+ * A future release will remove the option -- 21 and 25 already warn that it is
+ * obsolete -- and javac's own wording ("Source option 8 is no longer
+ * supported") names no way forward, so the caller adds one.
+ */
+ static boolean dropsSourceEight(BuildFailureException err) {
+ String message = err.getMessage();
+ return message != null
+ && message.indexOf("Source option") >= 0
+ && message.indexOf("no longer supported") >= 0;
+ }
+
+ /** Windows names it javac.exe, and a JRE has neither. */
+ private static boolean hasJavac(File home) {
+ return new File(home, "bin/javac").isFile()
+ || new File(home, "bin/javac.exe").isFile();
+ }
+
+ /**
+ * Unpacks a jar. Entries under cn1-native/ go to `nativeTarget` when one is
+ * given, because the C belongs in the translator's source root rather than on
+ * the Java source path.
+ */
+ private void unzip(File jar, File javaTarget, File nativeTarget)
+ throws BuildExecutionException {
+ unzip(jar, javaTarget, nativeTarget, false);
+ }
+
+ /**
+ * @param firstWins leave an entry alone when something is already at its
+ * destination, which is how a classpath resolves a class two
+ * entries both carry
+ */
+ private void unzip(File jar, File javaTarget, File nativeTarget, boolean firstWins)
+ throws BuildExecutionException {
+ try {
+ ZipFile zip = new ZipFile(jar);
+ try {
+ Enumeration extends ZipEntry> entries = zip.entries();
+ while (entries.hasMoreElements()) {
+ ZipEntry entry = entries.nextElement();
+ if (entry.isDirectory()) {
+ continue;
+ }
+ String name = entry.getName();
+ File destination;
+ if (name.startsWith("cn1-native/")) {
+ if (nativeTarget == null) {
+ continue;
+ }
+ destination = resolveInside(nativeTarget,
+ name.substring("cn1-native/".length()), jar, name);
+ } else if (name.startsWith("META-INF/")) {
+ continue;
+ } else {
+ destination = resolveInside(javaTarget, name, jar, name);
+ }
+ if (firstWins && destination.isFile()) {
+ continue;
+ }
+ mkdirs(destination.getParentFile());
+ InputStream in = zip.getInputStream(entry);
+ try {
+ copy(in, destination);
+ } finally {
+ in.close();
+ }
+ }
+ } finally {
+ zip.close();
+ }
+ } catch (IOException err) {
+ throw new BuildExecutionException("Could not unpack " + jar, err);
+ }
+ }
+
+ /**
+ * The entry's destination, proven to be inside the directory it unpacks into.
+ *
+ * An archive entry name is attacker-controlled data, not a path this build
+ * chose: an entry called `../../../../etc/whatever` makes `new File(root, name)`
+ * resolve outside `root`, so unpacking writes wherever the entry says. That is
+ * Zip Slip, and here it would run with the developer's privileges during an
+ * ordinary `mvn package` against whatever jar the coordinates resolved to.
+ *
+ * Compared after canonicalisation rather than on the raw string, because `..`
+ * is not the only way out -- a symlinked parent resolves elsewhere too, and the
+ * textual check passes for both. The separator is appended to the root so a
+ * sibling whose name merely starts with it ("/tmp/outdir-evil" against
+ * "/tmp/outdir") cannot satisfy the prefix test.
+ */
+ private static File resolveInside(File root, String relative, File jar, String entryName)
+ throws IOException {
+ File destination = new File(root, relative);
+ String prefix = root.getCanonicalPath() + File.separator;
+ String resolved = destination.getCanonicalPath();
+ if (!resolved.startsWith(prefix)) {
+ throw new IOException("Refusing to unpack " + jar + ": entry \"" + entryName
+ + "\" resolves to " + resolved + ", outside " + root.getCanonicalPath());
+ }
+ return destination;
+ }
+
+ private static void copy(InputStream in, File destination) throws IOException {
+ OutputStream out = new FileOutputStream(destination);
+ try {
+ byte[] chunk = new byte[8192];
+ int n;
+ while ((n = in.read(chunk)) > 0) {
+ out.write(chunk, 0, n);
+ }
+ } finally {
+ out.close();
+ }
+ }
+
+ /** As copyDirectory, but never replacing a file that is already there. */
+ private void copyDirectoryFirstWins(File from, File to) throws BuildExecutionException {
+ copyDirectoryFirstWins(from, to, null);
+ }
+
+ /**
+ * @param nativeTarget where a top-level cn1-native subtree belongs, or null
+ * when this is already below one
+ *
+ * The same routing the jar branch does, and for the same reason: the
+ * translator has no code that looks for cn1-native inside a class input, and
+ * only nativeSources is copied into its C source root. Copied as it stood,
+ * a reactor module's C was staged among the classes where nothing reads it --
+ * and a native whose C is absent is not a link error, because a Java native
+ * method is kept alive BY its symbol appearing in the native sources, so the
+ * dead-code pass drops the method and the build stays green with the feature
+ * inert. The same dependency packaged correctly once it was installed as a
+ * jar and consumed that way, which is the worst shape for this to take.
+ */
+ private void copyDirectoryFirstWins(File from, File to, File nativeTarget)
+ throws BuildExecutionException {
+ File[] children = from.listFiles();
+ if (children == null) {
+ return;
+ }
+ for (File child : children) {
+ if (nativeTarget != null && child.isDirectory()
+ && "cn1-native".equals(child.getName())) {
+ mkdirs(nativeTarget);
+ // Without the native target below it: cn1-native is matched at the
+ // root only, exactly as the jar branch matches the prefix.
+ copyDirectoryFirstWins(child, nativeTarget);
+ continue;
+ }
+ File destination = new File(to, child.getName());
+ if (child.isDirectory()) {
+ mkdirs(destination);
+ copyDirectoryFirstWins(child, destination);
+ continue;
+ }
+ if (destination.isFile()) {
+ continue;
+ }
+ try {
+ InputStream in = new java.io.FileInputStream(child);
+ try {
+ copy(in, destination);
+ } finally {
+ in.close();
+ }
+ } catch (IOException err) {
+ throw new BuildExecutionException("Could not copy " + child, err);
+ }
+ }
+ }
+
+ private void copyDirectory(File from, File to) throws BuildExecutionException {
+ File[] children = from.listFiles();
+ if (children == null) {
+ return;
+ }
+ for (File child : children) {
+ File destination = new File(to, child.getName());
+ if (child.isDirectory()) {
+ mkdirs(destination);
+ copyDirectory(child, destination);
+ continue;
+ }
+ try {
+ InputStream in = new java.io.FileInputStream(child);
+ try {
+ copy(in, destination);
+ } finally {
+ in.close();
+ }
+ } catch (IOException err) {
+ throw new BuildExecutionException("Could not copy " + child, err);
+ }
+ }
+ }
+
+ private void collectJava(File dir, List out) {
+ File[] children = dir.listFiles();
+ if (children == null) {
+ return;
+ }
+ for (File child : children) {
+ if (child.isDirectory()) {
+ collectJava(child, out);
+ } else if (child.getName().endsWith(".java")) {
+ out.add(child.getAbsolutePath());
+ }
+ }
+ }
+
+ private void run(List command, File directory, String what)
+ throws BuildExecutionException, BuildFailureException {
+ try {
+ ProcessBuilder builder = new ProcessBuilder(command);
+ builder.directory(directory);
+ builder.redirectErrorStream(true);
+ Process process = builder.start();
+ StringBuilder output = new StringBuilder();
+ InputStream in = process.getInputStream();
+ byte[] chunk = new byte[8192];
+ int n;
+ while ((n = in.read(chunk)) > 0) {
+ output.append(new String(chunk, 0, n, "UTF-8"));
+ }
+ int status = process.waitFor();
+ if (status != 0) {
+ throw new BuildFailureException("Could not " + what + ":\n" + output);
+ }
+ if (output.length() > 0) {
+ getLog().debug(output.toString());
+ }
+ } catch (IOException err) {
+ throw new BuildExecutionException("Could not " + what, err);
+ } catch (InterruptedException err) {
+ Thread.currentThread().interrupt();
+ throw new BuildExecutionException("Interrupted while trying to " + what, err);
+ }
+ }
+
+ /**
+ * Removes each directory and its contents, and refuses to continue if one
+ * survives.
+ *
+ * The emptiness is the point, and it used to be assumed: File.delete returns
+ * false for a locked file on Windows or anything under a read-only directory,
+ * nothing looked at that, and the stale .class stayed where ClassScanner,
+ * requireMainClass and the translator would all find it. A controller or an
+ * entry point deleted from the source tree is then still packaged, so the
+ * build ships the previous implementation and says nothing. Checking the
+ * result rather than each delete catches every reason one can survive.
+ */
+ private static void emptyDirs(File... dirs) throws BuildExecutionException {
+ for (File dir : dirs) {
+ deleteTree(dir);
+ if (dir == null || !dir.exists()) {
+ continue;
+ }
+ String[] left = dir.list();
+ if (left != null && left.length > 0) {
+ throw new BuildExecutionException("Could not empty " + dir
+ + ": " + left.length + " entr" + (left.length == 1 ? "y" : "ies")
+ + " could not be deleted, and building over them would package "
+ + "classes that are no longer in the source tree.");
+ }
+ }
+ }
+
+ private static void deleteTree(File file) {
+ if (file == null || !file.exists()) {
+ return;
+ }
+ File[] children = file.listFiles();
+ if (children != null) {
+ for (File child : children) {
+ deleteTree(child);
+ }
+ }
+ // The result is checked by emptyDirs, which looks at what actually
+ // survived rather than at each delete: a directory that could not be
+ // removed but is empty is harmless, and one that still holds a class is
+ // not, whatever the reason.
+ file.delete();
+ }
+
+ private static void mkdirs(File... dirs) {
+ for (File dir : dirs) {
+ if (dir != null && !dir.isDirectory()) {
+ dir.mkdirs();
+ }
+ }
+ }
+
+ private static String join(List parts, String separator) {
+ StringBuilder out = new StringBuilder();
+ for (int iter = 0; iter < parts.size(); iter++) {
+ if (iter > 0) {
+ out.append(separator);
+ }
+ out.append(parts.get(iter));
+ }
+ return out.toString();
+ }
+}
diff --git a/maven/build-engine/src/main/java/com/codename1/maven/BytecodeCompliance.java b/maven/build-engine/src/main/java/com/codename1/maven/BytecodeCompliance.java
new file mode 100644
index 00000000000..1c85b905e57
--- /dev/null
+++ b/maven/build-engine/src/main/java/com/codename1/maven/BytecodeCompliance.java
@@ -0,0 +1,1395 @@
+/*
+ * Copyright (c) 2012, 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.maven;
+
+import org.apache.commons.io.FileUtils;
+import com.codename1.build.BuildArtifact;
+import com.codename1.build.BuildExecutionException;
+import com.codename1.build.BuildFailureException;
+import com.codename1.build.Log;
+import com.codename1.build.ProjectHost;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassVisitor;
+import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.FieldVisitor;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+import org.objectweb.asm.util.CheckClassAdapter;
+import org.objectweb.asm.tree.AbstractInsnNode;
+import org.objectweb.asm.tree.ClassNode;
+import org.objectweb.asm.tree.InvokeDynamicInsnNode;
+import org.objectweb.asm.tree.MethodInsnNode;
+import org.objectweb.asm.tree.MethodNode;
+import org.objectweb.asm.tree.analysis.Analyzer;
+import org.objectweb.asm.tree.analysis.AnalyzerException;
+import org.objectweb.asm.tree.analysis.BasicInterpreter;
+import org.objectweb.asm.tree.analysis.BasicValue;
+import org.objectweb.asm.tree.analysis.Frame;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FilenameFilter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+import static com.codename1.maven.PathUtil.path;
+
+/**
+ * Performs bytecode-level API compliance checks by scanning compiled classes.
+ */
+/// Checks the compiled application against the Codename One Java runtime API and
+/// rewrites what it can: caps class files at Java 17, redirects the JDK calls the
+/// runtime provides elsewhere, and fails the build on anything a device build
+/// could not link.
+///
+/// The body of the Maven plugin's `bytecode-compliance` goal, shared with the
+/// Gradle plugin, which runs it after every compile.
+public class BytecodeCompliance {
+ protected static final String GROUP_ID = "com.codenameone";
+ protected static final String JAVA_RUNTIME_ARTIFACT_ID = "java-runtime";
+
+ /// The build tool's answers about the project.
+ protected final ProjectHost host;
+
+ private List siblingClassRoots = Collections.emptyList();
+
+ private Set pendingProjectClasses = Collections.emptySet();
+
+ /// A check of the project `host` describes.
+ public BytecodeCompliance(ProjectHost host) {
+ this.host = host;
+ }
+
+ /// Other directories of this project's own compiled classes, which the
+ /// checked classes may refer to but which are not checked here. Gradle
+ /// compiles Kotlin and Java into separate directories and checks each in its
+ /// own compile task, so a Java class calling a Kotlin one needs Kotlin's
+ /// directory as a sibling. Maven compiles both into one directory and needs
+ /// none.
+ public BytecodeCompliance siblingClassRoots(List roots) {
+ this.siblingClassRoots = roots == null ? Collections.emptyList() : new ArrayList(roots);
+ return this;
+ }
+
+ /// Internal names (`a/b/C`) of this project's classes that are not compiled
+ /// yet, so a reference to one -- or to a class nested in one -- is the
+ /// project's own and allowed. Kotlin compiles before javac, so a Kotlin class
+ /// calling a Java one is checked before that class exists. The Java class
+ /// itself is checked by javac's own pass; what this gives up is only the
+ /// inherited-member walk through it, for members the Kotlin compiler has
+ /// already resolved against the Java source.
+ public BytecodeCompliance pendingProjectClasses(Set internalNames) {
+ this.pendingProjectClasses = internalNames == null ? Collections.emptySet()
+ : new HashSet(internalNames);
+ return this;
+ }
+
+ private boolean isPendingProjectClass(String owner) {
+ if (pendingProjectClasses.isEmpty() || owner == null) {
+ return false;
+ }
+ int nested = owner.indexOf('$');
+ return pendingProjectClasses.contains(nested < 0 ? owner : owner.substring(0, nested));
+ }
+
+ protected Log getLog() {
+ return host.log();
+ }
+
+ /// The newest modification time among the sources that decide whether the
+ /// last check still stands.
+ protected long sourcesModificationTime() throws IOException {
+ return host.sourcesModificationTime();
+ }
+
+ /// Runs before the output is examined; the Maven plugin copies Kotlin's
+ /// incremental output into place here.
+ protected void beforeCheck() {
+ }
+
+ protected static long lastModifiedRecursive(File file, FilenameFilter filter) {
+ long lastModified = 0L;
+ if (file.isDirectory()) {
+ File[] children = file.listFiles();
+ if (children != null) {
+ for (File child : children) {
+ lastModified = Math.max(lastModifiedRecursive(child, filter), lastModified);
+ }
+ }
+ } else if (filter.accept(file.getParentFile(), file.getName())) {
+ lastModified = file.lastModified();
+ }
+ return lastModified;
+ }
+
+
+ private static final Map SUGGESTED_REPLACEMENTS;
+ private static final Set SIMD_OWNER_NAMES;
+ private static final Set PRIMITIVE_WRAPPER_INTERNAL_NAMES;
+
+ static {
+ Map m = new HashMap();
+ m.put("java/lang/System#exit(I)V", "Use com.codename1.ui.CN.exitApplication() to close apps on supported targets.");
+ m.put("java/lang/Thread#sleep(J)V", "Use com.codename1.ui.util.UITimer or Display.callSerially() instead of blocking sleeps.");
+ m.put("java/lang/Thread#sleep(JI)V", "Use com.codename1.ui.util.UITimer or Display.callSerially() instead of blocking sleeps.");
+ m.put("java/lang/Runtime#getRuntime()Ljava/lang/Runtime;", "Use Codename One platform services instead of raw java.lang.Runtime access.");
+ SUGGESTED_REPLACEMENTS = Collections.unmodifiableMap(m);
+ Set simdOwners = new HashSet();
+ simdOwners.add("com/codename1/util/Simd");
+ simdOwners.add("com/codename1/impl/ios/IOSSimd");
+ simdOwners.add("com/codename1/impl/javase/JavaSESimd");
+ SIMD_OWNER_NAMES = Collections.unmodifiableSet(simdOwners);
+ Set primitiveWrappers = new HashSet();
+ primitiveWrappers.add("java/lang/Boolean");
+ primitiveWrappers.add("java/lang/Byte");
+ primitiveWrappers.add("java/lang/Character");
+ primitiveWrappers.add("java/lang/Double");
+ primitiveWrappers.add("java/lang/Float");
+ primitiveWrappers.add("java/lang/Integer");
+ primitiveWrappers.add("java/lang/Long");
+ primitiveWrappers.add("java/lang/Short");
+ PRIMITIVE_WRAPPER_INTERNAL_NAMES = Collections.unmodifiableSet(primitiveWrappers);
+ }
+
+ private static final int MAX_CLASS_MAJOR_VERSION = Opcodes.V17;
+ private static final String JDK_API_REWRITE_HELPER_INTERNAL_NAME = "com/codename1/impl/JdkApiRewriteHelper";
+ private static final String SIMD_INTERNAL_NAME = "com/codename1/util/Simd";
+ private static final Map INVOCATION_REWRITE_RULES = createInvocationRewriteRules();
+
+ private File complianceOutputFile;
+ private InvocationRewriteSummary lastInvocationRewriteSummary = new InvocationRewriteSummary();
+ private URLClassLoader validationClassLoader;
+
+ private static Map createInvocationRewriteRules() {
+ Map rules = new LinkedHashMap();
+ rules.put(
+ MethodRef.virtual("java/lang/String", "split", "(Ljava/lang/String;)[Ljava/lang/String;"),
+ MethodRef.staticRef(JDK_API_REWRITE_HELPER_INTERNAL_NAME, "split", "(Ljava/lang/String;Ljava/lang/String;)[Ljava/lang/String;")
+ );
+ rules.put(
+ MethodRef.virtual("java/lang/String", "split", "(Ljava/lang/String;I)[Ljava/lang/String;"),
+ MethodRef.staticRef(JDK_API_REWRITE_HELPER_INTERNAL_NAME, "split", "(Ljava/lang/String;Ljava/lang/String;I)[Ljava/lang/String;")
+ );
+ rules.put(
+ MethodRef.virtual("java/lang/String", "replaceAll", "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;"),
+ MethodRef.staticRef(JDK_API_REWRITE_HELPER_INTERNAL_NAME, "replaceAll", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;")
+ );
+ rules.put(
+ MethodRef.virtual("java/lang/String", "replaceFirst", "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;"),
+ MethodRef.staticRef(JDK_API_REWRITE_HELPER_INTERNAL_NAME, "replaceFirst", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;")
+ );
+ return Collections.unmodifiableMap(rules);
+ }
+
+ private static boolean isSimdOwner(String owner) {
+ return owner != null && SIMD_OWNER_NAMES.contains(owner);
+ }
+
+ private static boolean isSimdAllocaMethod(String owner, String name, String descriptor) {
+ if (!isSimdOwner(owner)) {
+ return false;
+ }
+ return name != null
+ && name.startsWith("alloca")
+ && name.length() > "alloca".length()
+ && Character.isUpperCase(name.charAt("alloca".length()))
+ && isSimdAllocaDescriptor(descriptor);
+ }
+
+ private static boolean isSimdAllocaDescriptor(String descriptor) {
+ if (descriptor == null) {
+ return false;
+ }
+ Type returnType = Type.getReturnType(descriptor);
+ if (returnType == null || returnType.getSort() != Type.ARRAY || returnType.getDimensions() != 1) {
+ return false;
+ }
+ Type elementType = returnType.getElementType();
+ int elementSort = elementType.getSort();
+ return elementSort == Type.BYTE || elementSort == Type.INT || elementSort == Type.FLOAT;
+ }
+
+ /// Runs the check.
+ public void execute() throws BuildExecutionException {
+ if (shouldSkipComplianceCheck()) {
+ return;
+ }
+
+ complianceOutputFile = new File(path(host.buildDirectory().getPath(), "codenameone", "compliance_check.txt"));
+ getLog().info("Running bytecode compliance check against Codename One Java Runtime API");
+ getLog().info("See https://www.codenameone.com/javadoc/ for supported Classes and Methods");
+
+ if (!hasChangedSinceLastCheck()) {
+ getLog().info("Sources haven't changed since the last compliance check. Skipping check");
+ return;
+ }
+
+ beforeCheck();
+
+ File outputDir = new File(host.outputDirectory().getPath());
+ if (!outputDir.isDirectory()) {
+ writeComplianceSuccess("No output classes found for compliance check in " + outputDir.getAbsolutePath(), 0);
+ return;
+ }
+
+ int rewrittenClassCount = enforceMaxClassVersion(outputDir, MAX_CLASS_MAJOR_VERSION);
+ InvocationRewriteSummary invocationRewriteSummary = applyInvocationRewrites(outputDir);
+ lastInvocationRewriteSummary = invocationRewriteSummary;
+
+ // Both calls above rewrite class files IN PLACE -- capping a class to the
+ // supported version, and redirecting a call the runtime does not have. If
+ // the main class is one of them, its bytes no longer match the digest the
+ // build hint manifest recorded, and the simulator -- which has no bytecode
+ // reader and so compares the class file itself -- reads a manifest written
+ // moments ago as stale and publishes none of the annotated hints.
+ //
+ // Every pom in this repository runs process-annotations after this goal,
+ // where the stamp would be taken from the rewritten bytes anyway. Stamping
+ // here as well is what makes that ordering stop mattering: whichever of
+ // the two runs last leaves a manifest describing the class on disk. A
+ // no-op when there is no manifest, which is every project that declares
+ // its hints in codenameone_settings.properties.
+ try {
+ com.codename1.maven.processors.BuildHintAnnotationProcessor
+ .restampClassDigest(outputDir);
+ } catch (IOException ioe) {
+ throw new BuildExecutionException(
+ "Could not stamp the build hint manifest under " + outputDir, ioe);
+ }
+
+ List dependencyJars = getDependencyJarsForScanning();
+ Map allowedIndex = buildClassIndex(Arrays.asList(getJavaRuntimeJar(), getCodenameOneJar()));
+ Map projectAndDependencyIndex = buildClassIndexWithOutput(outputDir, dependencyJars);
+ projectAndDependencyIndex.putAll(buildClassIndex(siblingClassRoots));
+
+ List violations = scanProjectClasses(outputDir, allowedIndex, projectAndDependencyIndex);
+ if (!violations.isEmpty()) {
+ writeComplianceReport(violations, outputDir, dependencyJars, rewrittenClassCount);
+ logViolationSummary(violations);
+ throw new BuildFailureException(buildFailureSummary(violations));
+ }
+
+ writeComplianceSuccess("Completed compliance check on " + host.finalName(), rewrittenClassCount);
+ getLog().info("Invocation rewrite summary: classes rewritten=" + invocationRewriteSummary.rewrittenClasses + ", callsites rewritten=" + invocationRewriteSummary.rewrittenCallsites);
+ }
+
+ private boolean shouldSkipComplianceCheck() {
+ if ("true".equals(System.getProperty("skipComplianceCheck", "false"))) {
+ return true;
+ }
+ if ("true".equals(host.projectProperties().getProperty("skipComplianceCheck", "false"))) {
+ return true;
+ }
+ if ("true".equals(System.getProperty("reloadClasses", "false"))) {
+ return true;
+ }
+ return "true".equals(host.projectProperties().getProperty("reloadClasses", "false"));
+ }
+
+ private boolean hasChangedSinceLastCheck() {
+ if (!complianceOutputFile.exists()) {
+ return true;
+ }
+ try {
+ long lastCheck = complianceOutputFile.lastModified();
+ if (sourcesModificationTime() > lastCheck) {
+ return true;
+ }
+ if (lastCheckFailed()) {
+ // A failure report is not a completed check. Without this a
+ // rerun with unchanged sources would skip straight past the
+ // violations that just failed the build.
+ return true;
+ }
+ // The invocation rewrites and the class-version cap mutate the
+ // compiled classes in place, so gating on source mtimes alone is
+ // not enough: a later compile pass can regenerate target/classes
+ // with unchanged sources (e.g. another `mvn package` re-running
+ // javac), silently shedding the rewrites. Shipping such classes
+ // breaks device builds -- the iOS translator emits calls to
+ // virtual_java_lang_String_replaceAll etc. that ParparVM's
+ // JavaAPI never declares. Re-run whenever any compiled class is
+ // newer than the last check; the marker is written after the
+ // rewrites, so an up-to-date output tree stays skippable.
+ return getCompiledClassesModificationTime() > lastCheck;
+ } catch (IOException ex) {
+ getLog().error("Failed to check sources/classes modification time for compliance check", ex);
+ return true;
+ }
+ }
+
+ private boolean lastCheckFailed() throws IOException {
+ String content = FileUtils.readFileToString(complianceOutputFile, "UTF-8");
+ return content.startsWith(FAILURE_REPORT_HEADER);
+ }
+
+ private static final FilenameFilter CLASS_FILES_FILTER = (dir, name) -> name.endsWith(".class");
+
+ private long getCompiledClassesModificationTime() {
+ long mTime = lastModifiedRecursive(new File(host.outputDirectory().getPath()), CLASS_FILES_FILTER);
+ // With kotlin.compiler.incremental the Kotlin compiler writes to its
+ // own output tree which executeImpl copies into the output directory,
+ // so fresh classes can sit there before any copy has happened.
+ File kotlinIncrementalOutputDir = new File(path(host.buildDirectory().getPath(), "kotlin-ic", "compile", "classes"));
+ if (kotlinIncrementalOutputDir.exists()) {
+ mTime = Math.max(mTime, lastModifiedRecursive(kotlinIncrementalOutputDir, CLASS_FILES_FILTER));
+ }
+ return mTime;
+ }
+
+ private void writeComplianceSuccess(String message, int rewrittenClassCount) throws BuildExecutionException {
+ complianceOutputFile.getParentFile().mkdirs();
+ try {
+ StringBuilder content = new StringBuilder();
+ content.append(message).append("\n");
+ content.append("Rewritten class files to Java 17 major version: ").append(rewrittenClassCount).append("\n");
+ content.append("Rewritten JDK API callsites: ").append(lastInvocationRewriteSummary.rewrittenCallsites)
+ .append(" across ").append(lastInvocationRewriteSummary.rewrittenClasses).append(" class(es)").append("\n");
+ FileUtils.writeStringToFile(complianceOutputFile, content.toString(), "UTF-8");
+ } catch (IOException ex) {
+ throw new BuildExecutionException("Failed to write compliance file", ex);
+ }
+ }
+
+ private static final String FAILURE_REPORT_HEADER = "Codename One compliance check failed.";
+
+ private void writeComplianceReport(List violations, File outputDir, List dependencyJars, int rewrittenClassCount) throws BuildExecutionException {
+ StringBuilder report = new StringBuilder();
+ report.append(FAILURE_REPORT_HEADER).append("\n");
+ report.append("Project: ").append(host.finalName()).append("\n");
+ report.append("Output classes: ").append(outputDir.getAbsolutePath()).append("\n");
+ report.append("Dependency jars scanned: ").append(dependencyJars.size()).append("\n");
+ report.append("Rewritten class files to Java 17 major version: ").append(rewrittenClassCount).append("\n\n");
+ report.append("Rewritten JDK API callsites: ").append(lastInvocationRewriteSummary.rewrittenCallsites)
+ .append(" across ").append(lastInvocationRewriteSummary.rewrittenClasses).append(" class(es)").append("\n\n");
+ report.append("Violations (").append(violations.size()).append(")\n");
+ report.append("========================================\n");
+ int i = 1;
+ for (Violation violation : violations) {
+ report.append(i++).append(") ").append(violation.render()).append("\n\n");
+ }
+
+ complianceOutputFile.getParentFile().mkdirs();
+ try {
+ FileUtils.writeStringToFile(complianceOutputFile, report.toString(), "UTF-8");
+ } catch (IOException ex) {
+ throw new BuildExecutionException("Failed to write compliance report", ex);
+ }
+ }
+
+ private String buildFailureSummary(List violations) {
+ int maxInMessage = Math.min(5, violations.size());
+ StringBuilder sb = new StringBuilder();
+ sb.append("Compliance check failed with ").append(violations.size()).append(" forbidden API reference");
+ if (violations.size() != 1) {
+ sb.append("s");
+ }
+ sb.append(".\n");
+ sb.append("See ").append(complianceOutputFile.getAbsolutePath()).append(" for the full report.\n");
+ sb.append("First ").append(maxInMessage).append(" violation(s):");
+ for (int i = 0; i < maxInMessage; i++) {
+ Violation v = violations.get(i);
+ sb.append("\n - ").append(v.renderInline());
+ }
+ return sb.toString();
+ }
+
+ private void logViolationSummary(List violations) {
+ int maxToLog = Math.min(5, violations.size());
+ getLog().error("Bytecode compliance check found " + violations.size() + " violation(s).");
+ getLog().error("Detailed report written to " + complianceOutputFile.getAbsolutePath());
+ for (int i = 0; i < maxToLog; i++) {
+ getLog().error("[" + (i + 1) + "] " + violations.get(i).renderInline());
+ }
+ }
+
+
+ private int enforceMaxClassVersion(File outputDir, final int maxVersion) throws BuildExecutionException {
+ List classFiles = new ArrayList();
+ collectClassFiles(outputDir, classFiles);
+ int rewritten = 0;
+ for (File classFile : classFiles) {
+ try {
+ byte[] originalBytes = FileUtils.readFileToByteArray(classFile);
+ ClassVersionInfo versionInfo = readClassVersion(originalBytes);
+ if (versionInfo.majorVersion > maxVersion) {
+ byte[] rewrittenBytes = rewriteClassVersion(originalBytes, maxVersion);
+ FileUtils.writeByteArrayToFile(classFile, rewrittenBytes);
+ rewritten++;
+ getLog().info("Rewrote class major version " + versionInfo.majorVersion + " -> " + maxVersion + " for " + classFile.getAbsolutePath());
+ }
+ } catch (IOException ex) {
+ throw new BuildExecutionException("Failed to enforce class version for " + classFile, ex);
+ }
+ }
+ if (rewritten > 0) {
+ getLog().info("Rewrote " + rewritten + " class file(s) to Java 17 major version " + maxVersion);
+ }
+ return rewritten;
+ }
+
+ private ClassVersionInfo readClassVersion(byte[] classBytes) {
+ final ClassVersionInfo out = new ClassVersionInfo();
+ ClassReader reader = new ClassReader(classBytes);
+ reader.accept(new ClassVisitor(Opcodes.ASM9) {
+ @Override
+ public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
+ out.majorVersion = version;
+ out.className = name;
+ }
+ }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
+ return out;
+ }
+
+ private byte[] rewriteClassVersion(byte[] classBytes, final int maxVersion) {
+ ClassReader reader = new ClassReader(classBytes);
+ ClassWriter writer = new ClassWriter(reader, 0);
+ ClassVisitor visitor = new ClassVisitor(Opcodes.ASM9, writer) {
+ @Override
+ public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
+ int effectiveVersion = version > maxVersion ? maxVersion : version;
+ super.visit(effectiveVersion, access, name, signature, superName, interfaces);
+ }
+ };
+ reader.accept(visitor, 0);
+ return writer.toByteArray();
+ }
+
+ private InvocationRewriteSummary applyInvocationRewrites(File outputDir) throws BuildExecutionException {
+ InvocationRewriteSummary summary = new InvocationRewriteSummary();
+ List classFiles = new ArrayList();
+ collectClassFiles(outputDir, classFiles);
+ try {
+ for (File classFile : classFiles) {
+ try {
+ byte[] originalBytes = FileUtils.readFileToByteArray(classFile);
+ InvocationRewriteResult rewriteResult = rewriteClassInvocations(originalBytes);
+ if (rewriteResult.rewrittenCallsites > 0) {
+ validateClass(rewriteResult.bytes, classFile, outputDir);
+ FileUtils.writeByteArrayToFile(classFile, rewriteResult.bytes);
+ summary.rewrittenClasses++;
+ summary.rewrittenCallsites += rewriteResult.rewrittenCallsites;
+ getLog().info("Applied " + rewriteResult.rewrittenCallsites + " invocation rewrite(s) in " + classFile.getAbsolutePath());
+ }
+ } catch (IOException ex) {
+ throw new BuildExecutionException("Failed to rewrite invocations for " + classFile, ex);
+ }
+ }
+ } finally {
+ closeValidationClassLoader();
+ }
+ return summary;
+ }
+
+ private InvocationRewriteResult rewriteClassInvocations(byte[] classBytes) {
+ final InvocationRewriteResult result = new InvocationRewriteResult();
+ final ClassReader reader = new ClassReader(classBytes);
+ final ClassWriter writer = new ClassWriter(reader, 0);
+ ClassVisitor visitor = new ClassVisitor(Opcodes.ASM9, writer) {
+ @Override
+ public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
+ MethodVisitor delegate = super.visitMethod(access, name, descriptor, signature, exceptions);
+ return new MethodVisitor(Opcodes.ASM9, delegate) {
+ @Override
+ public void visitMethodInsn(int opcode, String owner, String methodName, String methodDescriptor, boolean isInterface) {
+ MethodRef source = new MethodRef(opcode, owner, methodName, methodDescriptor);
+ MethodRef target = INVOCATION_REWRITE_RULES.get(source);
+ if (target != null) {
+ result.rewrittenCallsites++;
+ super.visitMethodInsn(target.opcode, target.owner, target.name, target.descriptor, false);
+ return;
+ }
+ super.visitMethodInsn(opcode, owner, methodName, methodDescriptor, isInterface);
+ }
+ };
+ }
+ };
+ reader.accept(visitor, 0);
+ result.bytes = result.rewrittenCallsites > 0 ? writer.toByteArray() : classBytes;
+ return result;
+ }
+
+ private void validateClass(byte[] classBytes, File classFile, File outputDir) throws BuildExecutionException {
+ ClassLoader loader = getValidationClassLoader(outputDir);
+ try {
+ StringWriter stringWriter = new StringWriter();
+ PrintWriter printWriter = new PrintWriter(stringWriter);
+ CheckClassAdapter.verify(new ClassReader(classBytes), loader, false, printWriter);
+ printWriter.flush();
+ String validationOutput = stringWriter.toString().trim();
+ if (!validationOutput.isEmpty()) {
+ if (isUnresolvableTypeOutput(validationOutput)) {
+ getLog().debug("Skipping deep verification for " + classFile.getName()
+ + ": referenced type(s) not on classpath. Output: " + validationOutput);
+ return;
+ }
+ throw new BuildExecutionException("Bytecode validation failed for " + classFile + ": " + validationOutput);
+ }
+ } catch (RuntimeException ex) {
+ if (isUnresolvableTypeCause(ex)) {
+ getLog().debug("Skipping deep verification for " + classFile.getName()
+ + ": referenced type(s) not on classpath (" + ex.getMessage() + ").");
+ return;
+ }
+ throw new BuildExecutionException("Bytecode validation failed for " + classFile, ex);
+ }
+ }
+
+ private ClassLoader getValidationClassLoader(File outputDir) {
+ if (validationClassLoader != null) {
+ return validationClassLoader;
+ }
+ List urls = new ArrayList();
+ try {
+ if (outputDir != null && outputDir.isDirectory()) {
+ urls.add(outputDir.toURI().toURL());
+ }
+ if (host != null) {
+ for (File jar : getDependencyJarsForScanning()) {
+ if (jar != null && jar.exists()) {
+ urls.add(jar.toURI().toURL());
+ }
+ }
+ }
+ } catch (MalformedURLException ex) {
+ getLog().debug("Failed to assemble validation classloader URLs; falling back to plugin classloader.", ex);
+ return getClass().getClassLoader();
+ }
+ validationClassLoader = new URLClassLoader(urls.toArray(new URL[0]), getClass().getClassLoader());
+ return validationClassLoader;
+ }
+
+ private void closeValidationClassLoader() {
+ if (validationClassLoader != null) {
+ try {
+ validationClassLoader.close();
+ } catch (IOException ex) {
+ getLog().debug("Failed to close validation classloader", ex);
+ }
+ validationClassLoader = null;
+ }
+ }
+
+ private static boolean isUnresolvableTypeCause(Throwable ex) {
+ Throwable t = ex;
+ while (t != null) {
+ if (t instanceof ClassNotFoundException || t instanceof TypeNotPresentException || t instanceof NoClassDefFoundError) {
+ return true;
+ }
+ t = t.getCause();
+ }
+ return false;
+ }
+
+ private static boolean isUnresolvableTypeOutput(String output) {
+ return output.contains("ClassNotFoundException")
+ || output.contains("TypeNotPresentException")
+ || output.contains("NoClassDefFoundError")
+ || output.contains(" not present");
+ }
+
+ private List scanProjectClasses(File outputDir, final Map allowedIndex, final Map projectAndDependencyIndex) throws BuildExecutionException {
+ List classFiles = new ArrayList();
+ collectClassFiles(outputDir, classFiles);
+ List