diff --git a/.github/workflows/archetype-smoke.yml b/.github/workflows/archetype-smoke.yml index 424545f63ce..c0bb92eda58 100644 --- a/.github/workflows/archetype-smoke.yml +++ b/.github/workflows/archetype-smoke.yml @@ -27,6 +27,7 @@ on: - 'maven/integration-tests/cn1app-archetype-test.sh' - 'maven/integration-tests/cn1app-staged-jar-test.sh' - 'maven/integration-tests/cn1app-minimal-layout-test.sh' + - 'maven/integration-tests/backend-cn1lib-test.sh' - 'scripts/initializr/common/src/main/resources/common-hosted-platform-profiles.xml' - 'scripts/initializr/common/src/main/resources/backend-only-pom.xml' # What a generated project stages for upload also depends on the @@ -54,6 +55,7 @@ on: - 'maven/integration-tests/cn1app-archetype-test.sh' - 'maven/integration-tests/cn1app-staged-jar-test.sh' - 'maven/integration-tests/cn1app-minimal-layout-test.sh' + - 'maven/integration-tests/backend-cn1lib-test.sh' - 'scripts/initializr/common/src/main/resources/common-hosted-platform-profiles.xml' - 'scripts/initializr/common/src/main/resources/backend-only-pom.xml' # What a generated project stages for upload also depends on the @@ -138,6 +140,15 @@ jobs: run: | cd maven/integration-tests xvfb-run -a bash cn1app-archetype-test.sh + # A cn1lib with a server half, used by a generated server: the library's + # controller, bean, entity, repository and migration are wired by the + # server's build, on the JVM and natively. + - name: Check a cn1lib's backend part in a generated server + env: + CN1_BACKEND_PACKAGE_REQUIRED: '1' + run: | + cd maven/integration-tests + env -u DISPLAY -u WAYLAND_DISPLAY bash backend-cn1lib-test.sh # The jar cn1:build would upload, for every target the generated build.sh # offers. #5380 put ~300 MB of ffmpeg natives into every desktop upload and # the build client refused them; nothing looked at this jar for two months. diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m index 7e22e11a7b8..15e2f3175b6 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m @@ -3402,12 +3402,40 @@ - (void)viewDidLayoutSubviews { // never leaves landscape, so when the user stays in landscape its bounds never // change and no layout pass runs to correct the published size. viewDidAppear // and the transition's completion therefore re-read the real bounds as well. +// Reads the window's safe-area insets into the globals the Java side asks for. +// Answers whether any of them changed. +- (BOOL)cn1ReadSafeAreaInsets { + UIEdgeInsets in = self.view.window.safeAreaInsets; + JAVA_INT l = (JAVA_INT)in.left * scaleValue; + JAVA_INT r = (JAVA_INT)in.right * scaleValue; + JAVA_INT t = (JAVA_INT)in.top * scaleValue; + JAVA_INT b = (JAVA_INT)in.bottom * scaleValue; + if (l == safeLeft && r == safeRight && t == safeTop && b == safeBottom) { + return NO; + } + safeLeft = l; + safeRight = r; + safeTop = t; + safeBottom = b; + return YES; +} + - (void)cn1PublishViewSizeIfChanged { // The same reason viewWillTransitionToSize: gives: iOS lays the view out // at the opposite orientation for its background snapshot. if (isAppSuspended || self.view == nil) { return; } + // The insets go stale the same way the size does. viewSafeAreaInsetsDidChange + // is not sent to a view that a presented controller covers, so a rotation + // under one -- an orientation-locked screen, a camera -- left the landscape + // insets in place after the app was back in portrait, and every form with a + // safe area was squeezed between the old notch margins. + if (self.view.window != nil && [self cn1ReadSafeAreaInsets]) { + [self cn1UpdateStatusBarTapProxyFrame]; + lockDrawing = NO; + repaintUI(); + } CGSize sz = self.view.bounds.size; int newW = (int)(sz.width * scaleValue); int newH = (int)(sz.height * scaleValue); @@ -5025,10 +5053,7 @@ - (void)viewSafeAreaInsetsDidChange { #if TARGET_OS_OSX #else [super viewSafeAreaInsetsDidChange]; - safeLeft = (JAVA_INT)self.view.window.safeAreaInsets.left * scaleValue; - safeRight = (JAVA_INT)self.view.window.safeAreaInsets.right * scaleValue; - safeTop = (JAVA_INT)self.view.window.safeAreaInsets.top * scaleValue; - safeBottom = (JAVA_INT)self.view.window.safeAreaInsets.bottom * scaleValue; + [self cn1ReadSafeAreaInsets]; // Status-bar tap proxy height tracks safeAreaInsets.top, so refresh it // here so rotations and other safe-area changes keep the proxy aligned diff --git a/docs/demos/common/src/main/snippets/developer-guide/backend-beans.xml b/docs/demos/common/src/main/snippets/developer-guide/backend-beans.xml new file mode 100644 index 00000000000..f3346a4ec69 --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/backend-beans.xml @@ -0,0 +1,9 @@ +// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline. + +// tag::backend-beans-library-dependency[] + + com.example + notelib-backend + 1.0 + +// end::backend-beans-library-dependency[] diff --git a/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc b/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc index 19b80a59e65..67f84f275b3 100644 --- a/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc +++ b/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc @@ -1081,6 +1081,8 @@ include::../demos/common/src/main/snippets/developer-guide/advanced-topics-under Once the project is generated, add your cross-platform Java sources under `common/src/main/java`, CSS under `common/src/main/css`, and run `mvn install` from the project root to build. The build produces both a Maven artifact for pom-style consumption and a legacy `.cn1lib` file in `common/target` for users still on Ant. +A library can also have a server half. The archetype's `backend` module holds classes for an application's server: its controllers, beans, entities, repositories and migrations are wired into any server that depends on the library, as if that server had compiled them. When the module has sources or resources, the `.cn1lib` carries them as `backend.zip`. See <>. + ==== Build hints in cn1libs Some cn1libs are pretty simple to install - just add them as a Maven dependency in `common/pom.xml`. For example, many of the more elaborate cn1libs need some pretty complex configurations. This is the case when native code is involved where you need to add permissions or plist entries for the various native platforms to get everything to work. This makes the cn1lib's helpful but less than seamless which is where you want to go. @@ -1159,6 +1161,7 @@ The table below covers the files that can/should be a part of a cn1lib file: | nativewin.zip | × | Native Windows sources if applicable // vale-skip: Microsoft.FirstPerson: "ME" in "Java ME" is the Java Micro Edition platform abbreviation, not a first-person pronoun. | nativeme.zip | × | Native Java ME sources if applicable +| backend.zip | × | The library's server half, compiled, if it has one. See <> |==== **** diff --git a/docs/developer-guide/Backend-Beans.asciidoc b/docs/developer-guide/Backend-Beans.asciidoc index 10363fcbb48..bc9e526719c 100644 --- a/docs/developer-guide/Backend-Beans.asciidoc +++ b/docs/developer-guide/Backend-Beans.asciidoc @@ -393,6 +393,43 @@ A start-up that fails partway runs the destroy methods of the beans already built, so a server a supervisor restarts doesn't leak what their constructors opened. +[[backend-libraries]] +=== Controllers and beans from a library + +A server's wiring is generated while it builds, from the classes it compiles. +A library can take part in that wiring: its `@RestController` classes get +routes, its `@Component` and `@Service` classes become beans, its entities and +repositories get their data access, and its `db/migration` scripts run, all as +if the server had compiled them itself. The server's own code can inject the +library's beans like any other. + +A jar opts in by carrying the empty resource +`META-INF/codenameone/backend-library`. A jar without the marker is an ordinary +dependency: its classes are on the classpath, but none of them are wired. + +A cn1lib carries its server half inside the `.cn1lib` file, as `backend.zip` +beside the client parts: + +* A library made from the Maven cn1lib archetype has a `backend` module. Put + the server classes in `backend/src/main/java`; the module already holds the + marker. When the module has sources or resources of its own, the `.cn1lib` + includes it, and a server + that uses the library depends on the `-backend` artifact (or the + `backend` classifier of an installed `.cn1lib`): ++ +[source,xml] +---- +include::../demos/common/src/main/snippets/developer-guide/backend-beans.xml[tag=backend-beans-library-dependency,indent=0] +---- +* A Gradle cn1lib puts the server classes in `src/backend/java` and publishes + them as `-backend`, marked. +* A library with no client side is a plain jar that depends on + `codenameone-backend` with `provided` scope and carries the marker. + +The library itself is never wired at its own build; only the server that uses +it generates the routes and beans. When the server defines a class with the +same name as one in a library, the server's class is used. + === What the build refuses Everything a container would discover at start-up is checked while the module diff --git a/docs/developer-guide/Backend-Data.asciidoc b/docs/developer-guide/Backend-Data.asciidoc index d15eab7a32c..a1f458d4418 100644 --- a/docs/developer-guide/Backend-Data.asciidoc +++ b/docs/developer-guide/Backend-Data.asciidoc @@ -408,6 +408,9 @@ name. The set gets its own history table, so its versions never collide with the application's, and it runs before the application's scripts so they can refer to its tables. +A library jar marked as a backend library ships its own `db/migration` +scripts the same way; see <>. + [source,java,role=cn1-no-try] ---- include::../demos/backend/src/main/java/com/codenameone/developerguide/backend/MigrationSnippets.java[tag=backend-migrations-library,indent=0] diff --git a/docs/developer-guide/Gradle-Project-Workflow.asciidoc b/docs/developer-guide/Gradle-Project-Workflow.asciidoc index c113e6f9a6e..1a3003fbe4f 100644 --- a/docs/developer-guide/Gradle-Project-Workflow.asciidoc +++ b/docs/developer-guide/Gradle-Project-Workflow.asciidoc @@ -334,6 +334,7 @@ The library is published in the same shape the Maven `cn1lib-archetype` produces `N-common`, classifier `cn1css`:: The library's CSS, as a zip. `N-`:: One jar per platform: compiled classes for `javase`, sources for the others, which the native builders compile. `N-lib`:: A `pom` that applications depend on, with one `codename1.platform` profile per platform. +`N-backend`:: The library's server half, compiled from `src/backend/java` and `src/backend/resources`, when there is one. A server that depends on it wires its controllers, beans and entities as its own; see <>. Gradle cn1libs don't produce `.cn1lib` files. diff --git a/maven/build-engine/src/main/java/com/codename1/maven/AnnotationProcessing.java b/maven/build-engine/src/main/java/com/codename1/maven/AnnotationProcessing.java index c5e06b4c0ea..bfd71fa6d95 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/AnnotationProcessing.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/AnnotationProcessing.java @@ -109,6 +109,19 @@ public AnnotationProcessing withOwnClassDirectories(List directories) { return this; } + /// Whether backend libraries on the classpath are merged into the output + /// first; see [BackendLibraries]. + private boolean mergeBackendLibraries; + + /// Merges the backend libraries on the compile classpath into the output + /// directory before anything scans it, so their controllers, beans and + /// entities are wired as the module's own. For a module's main classes + /// only: its tests see the merged classes through the main output. + public AnnotationProcessing withBackendLibraries(boolean merge) { + this.mergeBackendLibraries = merge; + return this; + } + /// The compile classpath without [#ownClassDirectories]. private List dependencyClasspath() { if (ownClassDirectories.isEmpty()) { @@ -142,10 +155,28 @@ public void run() throws BuildExecutionException { classpath.add(new File(element)); } com.codename1.maven.annotations.JavaSourceCompiler.setProjectClasspath(classpath); + boolean processed = false; try { runProcessors(); + processed = true; } finally { com.codename1.maven.annotations.JavaSourceCompiler.clearProjectClasspath(); + // The processors rewrite merged classes in place; the record must + // describe what this build left -- a failed build too, since a + // processor may have woven some classes before another one threw -- + // or the next build reads every rewritten library class as the + // module's own and never refreshes it. + if (mergeBackendLibraries) { + try { + BackendLibraries.restamp(outputDirectory); + } catch (IOException e) { + if (processed) { + throw new BuildExecutionException("Could not record the merged backend " + + "libraries in " + outputDirectory + ": " + e.getMessage(), e); + } + // Otherwise the processors' own failure is the one to report. + } + } } } @@ -154,6 +185,27 @@ private void runProcessors() throws BuildExecutionException { log.debug("cn1: nothing compiled at " + outputDirectory + " -- skipping annotation processing"); return; } + if (new File(outputDirectory, BackendLibraries.MARKER).isFile()) { + // A backend library is wired by the server that uses it, as that + // server's own classes. Processed here as well, it would ship + // generated classes with fixed names -- cn1app.DaoBootstrap, the + // wiring record -- that collide with the server's. + log.info("cn1: " + outputDirectory + " is a backend library; it is processed " + + "by the server that depends on it"); + return; + } + if (mergeBackendLibraries) { + try { + int merged = BackendLibraries.merge(compileClasspath, outputDirectory); + if (merged > 0) { + log.info("cn1: merged " + merged + " backend librar" + + (merged == 1 ? "y" : "ies") + " into " + outputDirectory); + } + } catch (IOException e) { + throw new BuildExecutionException("Could not merge the backend libraries into " + + outputDirectory + ": " + e.getMessage(), e); + } + } List processors = loadProcessors(); if (processors.isEmpty()) { log.debug("cn1: no AnnotationProcessor services registered -- nothing to do"); diff --git a/maven/build-engine/src/main/java/com/codename1/maven/BackendLibraries.java b/maven/build-engine/src/main/java/com/codename1/maven/BackendLibraries.java new file mode 100644 index 00000000000..fda684c03c8 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/BackendLibraries.java @@ -0,0 +1,317 @@ +/* + * 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 java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/// Backend libraries: jars whose classes a server module builds as its own. +/// +/// A server is wired at build time. Its routers, its bean wiring and the dao of +/// every entity are generated from the annotated classes the build scans, and +/// the build scans the module's own output. A library's `@RestController`, +/// `@Service` or `@Entity` was therefore compiled, shipped and never wired: no +/// route, no bean, no table. +/// +/// A jar that carries [#MARKER] says it is meant to be wired by the server that +/// uses it -- the backend part of a cn1lib (`backend.zip` inside the `.cn1lib`) +/// or a backend-only library. Before anything scans the module, its classes and +/// resources are copied into the module's output directory, so every processor, +/// every bytecode rewrite and the native translation see one application and +/// nothing has to know where a class came from. The library is not processed +/// when it is built: generated wiring has fixed names (`cn1app.DaoBootstrap`), +/// and a library's copy would collide with the application's. +/// +/// The module's own classes always win. What was copied is listed in +/// [#RECORD] with its size and checksum, so the next build replaces or removes +/// exactly those files -- a library upgraded or dropped leaves nothing stale +/// behind. A listed file whose content changed since is no longer the +/// library's: the module compiled a class of that name over it, and it stays. +public final class BackendLibraries { + + /// The entry that marks a jar as a backend library. Its content is ignored. + public static final String MARKER = "META-INF/codenameone/backend-library"; + + /// What the last merge copied: one line per file, the output-relative path, + /// its size and its CRC-32, separated by tabs. + static final String RECORD = "META-INF/codenameone/backend-libraries.merged"; + + private BackendLibraries() { + } + + /// Whether `element`, a classpath entry, is a backend library. + public static boolean isLibrary(File element) { + if (element.isDirectory()) { + return new File(element, MARKER).isFile(); + } + if (!element.isFile()) { + return false; + } + try (ZipFile zip = new ZipFile(element)) { + return zip.getEntry(MARKER) != null; + } catch (IOException notAnArchive) { + return false; + } + } + + /// Copies every backend library on `classpath` into `output`. + /// + /// @param classpath classpath elements, in classpath order; the first + /// library to define a path wins, as on a classpath + /// @param output the module's compiled classes + /// @return the number of libraries merged + public static int merge(List classpath, File output) throws IOException { + File record = new File(output, RECORD); + Map previous = read(record); + // Undo the last merge first: a file still listed there, with the + // content the merge wrote, is a library's, and whatever the libraries + // hold now is copied back below. One whose content changed was written + // since by the module's own compile, and is the module's. + for (Map.Entry e : previous.entrySet()) { + File stale = new File(output, e.getKey()); + if (stale.isFile() && (e.getValue() == null || e.getValue().equals(stamp(stale))) + && !stale.delete()) { + throw new IOException("Could not remove " + stale); + } + } + Set merged = new LinkedHashSet(); + int libraries = 0; + if (classpath != null) { + File own = canonical(output); + for (String path : classpath) { + File element = new File(path); + if (canonical(element).equals(own) || !isLibrary(element)) { + continue; + } + libraries++; + if (element.isDirectory()) { + copyDirectory(element, element, output, merged); + } else { + copyArchive(element, output, merged); + } + } + } + if (merged.isEmpty()) { + if (record.isFile() && !record.delete()) { + throw new IOException("Could not remove " + record); + } + return libraries; + } + mkdirs(record.getParentFile()); + try (Writer out = new OutputStreamWriter(new FileOutputStream(record), StandardCharsets.UTF_8)) { + for (String path : merged) { + out.write(path); + out.write('\t'); + out.write(stamp(new File(output, path))); + out.write('\n'); + } + } + return libraries; + } + + /// Whether a library entry is copied: class files and resources, but not the + /// jar's own metadata, which describes the library and not the application. + private static boolean wanted(String name) { + if (name.startsWith("META-INF/")) { + // A service registration is copied like any resource; when the + // module registers the same service, the module's file wins. + return !name.equals("META-INF/MANIFEST.MF") && !name.equals(MARKER) + && !name.startsWith("META-INF/maven/") && !name.endsWith(".SF") + && !name.endsWith(".RSA") && !name.endsWith(".DSA") + && !name.equals(RECORD); + } + return !name.equals("module-info.class"); + } + + private static void copyArchive(File jar, File output, Set merged) throws IOException { + try (ZipFile zip = new ZipFile(jar)) { + Enumeration entries = zip.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + String name = entry.getName(); + if (entry.isDirectory() || !wanted(name) || name.contains("..")) { + continue; + } + File target = new File(output, name); + if (target.exists()) { + // The module's own file, or an earlier library's. + continue; + } + mkdirs(target.getParentFile()); + try (InputStream in = zip.getInputStream(entry)) { + copy(in, target); + } + merged.add(name); + } + } + } + + private static void copyDirectory(File root, File dir, File output, Set merged) + throws IOException { + File[] children = dir.listFiles(); + if (children == null) { + return; + } + for (File child : children) { + if (child.isDirectory()) { + copyDirectory(root, child, output, merged); + continue; + } + String name = root.toURI().relativize(child.toURI()).getPath(); + if (!wanted(name)) { + continue; + } + File target = new File(output, name); + if (target.exists()) { + continue; + } + mkdirs(target.getParentFile()); + try (InputStream in = new FileInputStream(child)) { + copy(in, target); + } + merged.add(name); + } + } + + /// A file's size and CRC-32, as the record stores them. + private static String stamp(File file) throws IOException { + CRC32 crc = new CRC32(); + try (InputStream in = new FileInputStream(file)) { + byte[] buffer = new byte[8192]; + int n; + while ((n = in.read(buffer)) > 0) { + crc.update(buffer, 0, n); + } + } + return file.length() + "\t" + Long.toHexString(crc.getValue()); + } + + /// The record's paths, each with its stamp; null for a line written + /// without one. + private static Map read(File record) throws IOException { + Map out = new LinkedHashMap(); + if (!record.isFile()) { + return out; + } + try (BufferedReader in = new BufferedReader(new InputStreamReader( + new FileInputStream(record), StandardCharsets.UTF_8))) { + String line; + while ((line = in.readLine()) != null) { + int tab = line.indexOf('\t'); + String path = tab < 0 ? line : line.substring(0, tab); + if (!path.isEmpty() && !path.contains("..")) { + out.put(path, tab < 0 ? null : line.substring(tab + 1)); + } + } + } + return out; + } + + /// Stamps every file the last merge into `output` copied with its current + /// content. The build calls this once it has finished rewriting classes -- + /// bean weaving and the ORM enhancer change merged classes in place -- so + /// the next merge recognizes them as the library's. A file the stamp at + /// merge time no longer matched before this runs would be the module's, but + /// nothing in a build writes a class over a merged one between the merge + /// and here except those rewrites. + public static void restamp(File output) throws IOException { + File record = new File(output, RECORD); + Map listed = read(record); + if (listed.isEmpty()) { + return; + } + try (Writer out = new OutputStreamWriter(new FileOutputStream(record), StandardCharsets.UTF_8)) { + for (String path : listed.keySet()) { + File file = new File(output, path); + if (!file.isFile()) { + continue; + } + out.write(path); + out.write('\t'); + out.write(stamp(file)); + out.write('\n'); + } + } + } + + /// What the last merge into `output` copied, as output-relative paths. + public static Set mergedPaths(File output) throws IOException { + return new LinkedHashSet(read(new File(output, RECORD)).keySet()); + } + + /// The classpath entries that are backend libraries. + public static List libraries(List classpath) { + List out = new ArrayList(); + if (classpath != null) { + for (String path : classpath) { + if (isLibrary(new File(path))) { + out.add(path); + } + } + } + return out; + } + + private static void copy(InputStream in, File target) throws IOException { + try (OutputStream out = new FileOutputStream(target)) { + byte[] buffer = new byte[8192]; + int n; + while ((n = in.read(buffer)) > 0) { + out.write(buffer, 0, n); + } + } + } + + private static void mkdirs(File dir) throws IOException { + if (dir != null && !dir.isDirectory() && !dir.mkdirs()) { + throw new IOException("Could not create " + dir); + } + } + + private static File canonical(File file) { + try { + return file.getCanonicalFile(); + } catch (IOException e) { + return file.getAbsoluteFile(); + } + } +} 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 index 5c2b86194f6..d4d53c5133f 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/BackendPackager.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/BackendPackager.java @@ -268,11 +268,31 @@ public File execute() throws BuildExecutionException { prepareSources(work, runtimeVersion); compile(jdk, javaApi, runtimeSources, classes); + // Backend libraries are wired as the module's own classes, exactly as + // process-annotations does on the JVM; see BackendLibraries. + try { + BackendLibraries.merge(compileClasspathWithoutRuntime(), classes); + } catch (IOException ex) { + throw new BuildExecutionException("Could not merge the backend libraries: " + + ex.getMessage(), ex); + } // Generated code stays at Java 8, but its compiler must be able to read // the application's newer class files (including when Maven runs on 8). + boolean generated = false; try (JavaSourceCompiler.CompilerScope ignored = JavaSourceCompiler.useJdk(jdk)) { generateControllers(classes, work); afterGenerate(jdk, javaApi, classes, work); + generated = true; + } finally { + // After the rewrites above, failed or not; see AnnotationProcessing.run. + try { + BackendLibraries.restamp(classes); + } catch (IOException ex) { + if (generated) { + throw new BuildExecutionException("Could not record the merged backend " + + "libraries: " + ex.getMessage(), ex); + } + } } requireMainClass(classes); translate(jdk, compilerJar, javaApi, classes, nativeSources, translated, @@ -843,7 +863,22 @@ List stageDependencyClasses(List classpath, File staged, boolean any = false; for (int i = 0; i < classpath.size(); i++) { File element = new File(classpath.get(i)); - if (element.isDirectory()) { + if (BackendLibraries.isLibrary(element)) { + // Its classes were merged into the module's own; only its + // natives are taken from here, so no class is translated twice. + // Tested before the directory branch: a sibling reactor module's + // target/classes is a library too. + if (element.isDirectory()) { + File natives = new File(element, "cn1-native"); + if (natives.isDirectory()) { + mkdirs(nativeSources); + copyDirectoryFirstWins(natives, nativeSources); + } + } else { + File scratch = new File(staged.getParentFile(), "backend-library-" + i); + unzip(element, scratch, nativeSources, true); + } + } else if (element.isDirectory()) { copyDirectoryFirstWins(element, staged, nativeSources); any = true; } else if (element.isFile()) { diff --git a/maven/build-engine/src/main/java/com/codename1/maven/annotations/AnnotatedClass.java b/maven/build-engine/src/main/java/com/codename1/maven/annotations/AnnotatedClass.java index ced17c086b3..035874b5fd0 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/annotations/AnnotatedClass.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/annotations/AnnotatedClass.java @@ -117,6 +117,15 @@ private static Set collectAllDescriptors( /// guess and this is not. public String getSourceFile() { return sourceFile; } + private boolean fromBackendLibrary; + + /// Whether a backend library contributed this class to the module's output. + /// Such a class is the module's to wire, though no source of the module + /// declares it. + public boolean isFromBackendLibrary() { return fromBackendLibrary; } + + void setFromBackendLibrary(boolean fromBackendLibrary) { this.fromBackendLibrary = fromBackendLibrary; } + /** * The name to write in generated JAVA SOURCE. * diff --git a/maven/build-engine/src/main/java/com/codename1/maven/annotations/ClassScanner.java b/maven/build-engine/src/main/java/com/codename1/maven/annotations/ClassScanner.java index a38844a06ca..a794d8b573e 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/annotations/ClassScanner.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/annotations/ClassScanner.java @@ -73,9 +73,32 @@ public static Map scan(File root) throws ProcessingExcep Map out = new LinkedHashMap(); if (root == null || !root.isDirectory()) return out; scan(root, out); + markLibraryClasses(root, out); return out; } + /// Marks what a backend library contributed to `root` (see + /// `com.codename1.maven.BackendLibraries`): those classes have no source + /// in the module, and the checks that drop a class whose source was + /// deleted must not read them as stale. + private static void markLibraryClasses(File root, Map out) + throws ProcessingException { + java.util.Set merged; + try { + merged = com.codename1.maven.BackendLibraries.mergedPaths(root); + } catch (IOException e) { + throw new ProcessingException("Could not read the backend library record under " + root, e); + } + for (String path : merged) { + if (path.endsWith(".class")) { + AnnotatedClass cls = out.get(path.substring(0, path.length() - ".class".length())); + if (cls != null) { + cls.setFromBackendLibrary(true); + } + } + } + } + private static void scan(File dir, Map out) throws ProcessingException { File[] children = dir.listFiles(); if (children == null) return; diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/BuildHintAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/BuildHintAnnotationProcessor.java index ee68b0cfdda..424b40efc02 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/BuildHintAnnotationProcessor.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/BuildHintAnnotationProcessor.java @@ -322,6 +322,11 @@ public static boolean hasBackingSource(AnnotatedClass cls, List compileS /// forever and failing its placement check on every incremental build. public static boolean hasBackingSource(AnnotatedClass cls, List compileSourceRoots, String sourceEncoding) { + if (cls.isFromBackendLibrary()) { + // A backend library's class, merged into the module: its source is + // the library's, and it is as live as the dependency. + return true; + } List roots = compileSourceRoots; if (roots == null || roots.isEmpty()) { return true; diff --git a/maven/build-engine/src/test/java/com/codename1/maven/BackendLibrariesTest.java b/maven/build-engine/src/test/java/com/codename1/maven/BackendLibrariesTest.java new file mode 100644 index 00000000000..b5289a87b0d --- /dev/null +++ b/maven/build-engine/src/test/java/com/codename1/maven/BackendLibrariesTest.java @@ -0,0 +1,283 @@ +/* + * 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.backend.HttpServer; +import com.codename1.build.SystemStreamLog; +import com.codename1.maven.annotations.JavaSourceCompiler; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/// A server wires the controllers, beans and entities of a backend library as +/// its own; see [BackendLibraries]. +public class BackendLibrariesTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private static final String LIBRARY_CONTROLLER = "package com.lib;\n" + + "import com.codename1.backend.annotations.*;\n" + + "@RestController\n" + + "@RequestMapping(\"/lib\")\n" + + "public class LibNotes {\n" + + " @GetMapping(\"/ping\")\n" + + " public String ping() { return \"pong\"; }\n" + + "}\n"; + + private static final String LIBRARY_ENTITY = "package com.lib;\n" + + "import com.codename1.annotations.*;\n" + + "@Entity(table=\"lib_note\")\n" + + "public class LibNote { @Id public long id; public String text; }\n"; + + private static final String LIBRARY_SERVICE = "package com.lib;\n" + + "@com.codename1.backend.annotations.Component\n" + + "public class LibService { public String name() { return \"lib\"; } }\n"; + + private static final String APP_CONTROLLER = "package com.app;\n" + + "import com.codename1.backend.annotations.*;\n" + + "@RestController\n" + + "@RequestMapping(\"/app\")\n" + + "public class AppNotes {\n" + + " private final com.lib.LibService service;\n" + + " public AppNotes(com.lib.LibService service) { this.service = service; }\n" + + " @GetMapping(\"/hello\")\n" + + " public String hello() { return \"hi\"; }\n" + + "}\n"; + + @Test + public void aLibrarysControllerAndEntityAreWiredIntoTheServer() throws Exception { + File library = library(true); + File classes = app(library); + process(classes, library); + + assertTrue("library controller merged", new File(classes, "com/lib/LibNotes.class").isFile()); + assertTrue("library router generated into the server", + new File(classes, "com/lib/LibNotesRouter.class").isFile()); + assertTrue("the server's own router", new File(classes, "com/app/AppNotesRouter.class").isFile()); + assertTrue("library entity registered with the server's dao bootstrap", + contains(new File(classes, "cn1app/BackendDaoBootstrap.class"), "com/lib/LibNote")); + assertFalse("the marker describes the library, not the server", + new File(classes, BackendLibraries.MARKER).exists()); + } + + @Test + public void anUnmarkedJarIsLeftOnTheClasspath() throws Exception { + File library = library(false); + File classes = app(library); + try { + process(classes, library); + fail("an unmarked jar's @Component was wired"); + } catch (com.codename1.build.BuildFailureException expected) { + assertTrue(expected.getMessage(), expected.getMessage().contains("com.lib.LibService")); + } + assertFalse(new File(classes, "com/lib/LibNotes.class").exists()); + assertFalse(new File(classes, "com/lib/LibNotesRouter.class").exists()); + } + + @Test + public void theModulesOwnClassWins() throws Exception { + File library = library(true); + File classes = app(library); + File own = new File(classes, "com/lib/LibNote.class"); + mkdirs(own.getParentFile()); + byte[] mine = "mine".getBytes(StandardCharsets.UTF_8); + Files.write(own.toPath(), mine); + BackendLibraries.merge(Collections.singletonList(library.getAbsolutePath()), classes); + assertTrue(Arrays.equals(mine, Files.readAllBytes(own.toPath()))); + // Merged again, the module's file is still the module's. + BackendLibraries.merge(Collections.singletonList(library.getAbsolutePath()), classes); + assertTrue(Arrays.equals(mine, Files.readAllBytes(own.toPath()))); + } + + @Test + public void aClassTheModuleCompiledOverAMergedOneIsKept() throws Exception { + File library = library(true); + File classes = app(library); + List cp = Collections.singletonList(library.getAbsolutePath()); + BackendLibraries.merge(cp, classes); + File merged = new File(classes, "com/lib/LibNote.class"); + assertTrue(merged.isFile()); + // The module adds its own class of that name; javac writes over the copy. + byte[] mine = "mine".getBytes(StandardCharsets.UTF_8); + Files.write(merged.toPath(), mine); + BackendLibraries.merge(cp, classes); + assertTrue("the module's class survives the undo of the last merge", + Arrays.equals(mine, Files.readAllBytes(merged.toPath()))); + BackendLibraries.merge(cp, classes); + assertTrue(Arrays.equals(mine, Files.readAllBytes(merged.toPath()))); + } + + @Test + public void aMergedClassTheBuildRewroteIsStillTheLibrarys() throws Exception { + File library = library(true); + File classes = app(library); + List cp = Collections.singletonList(library.getAbsolutePath()); + BackendLibraries.merge(cp, classes); + File merged = new File(classes, "com/lib/LibNote.class"); + byte[] original = Files.readAllBytes(merged.toPath()); + // Weaving rewrites the copy in place; the build then restamps. + Files.write(merged.toPath(), "woven".getBytes(StandardCharsets.UTF_8)); + BackendLibraries.restamp(classes); + BackendLibraries.merge(cp, classes); + assertTrue("the next merge replaces it with the library's class", + Arrays.equals(original, Files.readAllBytes(merged.toPath()))); + } + + @Test + public void aDroppedLibraryLeavesNothingBehind() throws Exception { + File library = library(true); + File classes = app(library); + assertEquals(1, BackendLibraries.merge(Collections.singletonList(library.getAbsolutePath()), classes)); + assertTrue(new File(classes, "com/lib/LibNotes.class").isFile()); + assertEquals(0, BackendLibraries.merge(Collections.emptyList(), classes)); + assertFalse(new File(classes, "com/lib/LibNotes.class").exists()); + assertFalse(new File(classes, BackendLibraries.RECORD).exists()); + assertTrue("the server's own classes stay", new File(classes, "com/app/AppNotes.class").isFile()); + } + + @Test + public void aLibraryModuleIsNotProcessedItself() throws Exception { + File classes = tmp.newFolder(); + compile(classes, "com.lib.LibNotes", LIBRARY_CONTROLLER); + File marker = new File(classes, BackendLibraries.MARKER); + mkdirs(marker.getParentFile()); + Files.write(marker.toPath(), new byte[0]); + process(classes, null); + assertFalse(new File(classes, "com/lib/LibNotesRouter.class").exists()); + } + + private File appSources; + + private File app(File library) throws Exception { + File classes = tmp.newFolder(); + List classpath = new ArrayList(backendClasspath()); + classpath.add(library); + JavaSourceCompiler.compile(Collections.singletonMap("com.app.AppNotes", APP_CONTROLLER), + classes, classpath); + appSources = tmp.newFolder(); + File source = new File(appSources, "com/app/AppNotes.java"); + mkdirs(source.getParentFile()); + Files.write(source.toPath(), APP_CONTROLLER.getBytes(StandardCharsets.UTF_8)); + return classes; + } + + /// A library jar, marked as a backend library or not. + private File library(boolean marked) throws Exception { + File classes = tmp.newFolder(); + Map sources = new LinkedHashMap(); + sources.put("com.lib.LibNotes", LIBRARY_CONTROLLER); + sources.put("com.lib.LibNote", LIBRARY_ENTITY); + sources.put("com.lib.LibService", LIBRARY_SERVICE); + JavaSourceCompiler.compile(sources, classes, backendClasspath()); + File jar = new File(tmp.newFolder(), "library.jar"); + try (ZipOutputStream out = new ZipOutputStream(new FileOutputStream(jar))) { + if (marked) { + out.putNextEntry(new ZipEntry(BackendLibraries.MARKER)); + out.closeEntry(); + } + add(out, classes, classes); + } + return jar; + } + + private void compile(File classes, String name, String source) throws Exception { + JavaSourceCompiler.compile(Collections.singletonMap(name, source), classes, backendClasspath()); + } + + private void process(File classes, File library) throws Exception { + List classpath = new ArrayList(); + for (File f : backendClasspath()) { + classpath.add(f.getAbsolutePath()); + } + if (library != null) { + classpath.add(library.getAbsolutePath()); + } + // A real source root, as Maven passes one: with none, every class reads + // as having its source, and a library class -- whose source is not the + // module's -- would pass for the wrong reason. + File sources = appSources != null ? appSources : tmp.newFolder(); + new AnnotationProcessing(new SystemStreamLog(), classes, tmp.newFolder(), tmp.newFolder(), + new Properties(), null, Collections.singletonList(sources.getAbsolutePath()), + "UTF-8", classpath) + .withBackendLibraries(true) + .run(); + } + + private static void add(ZipOutputStream out, File root, File dir) throws Exception { + File[] children = dir.listFiles(); + if (children == null) { + return; + } + for (File child : children) { + if (child.isDirectory()) { + add(out, root, child); + continue; + } + out.putNextEntry(new ZipEntry(root.toURI().relativize(child.toURI()).getPath())); + try (FileInputStream in = new FileInputStream(child)) { + byte[] buffer = new byte[8192]; + int n; + while ((n = in.read(buffer)) > 0) { + out.write(buffer, 0, n); + } + } + out.closeEntry(); + } + } + + private static boolean contains(File file, String text) throws Exception { + return file.isFile() && new String(Files.readAllBytes(file.toPath()), + StandardCharsets.ISO_8859_1).contains(text); + } + + private static void mkdirs(File dir) { + assertTrue(dir.isDirectory() || dir.mkdirs()); + } + + private static List backendClasspath() throws Exception { + URL url = HttpServer.class.getProtectionDomain().getCodeSource().getLocation(); + return Arrays.asList(new File(url.toURI())); + } +} diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/pom.xml b/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/pom.xml index 9c610c4059a..3a9400427b5 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/pom.xml +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/backend/pom.xml @@ -84,6 +84,8 @@ 5.9.3 test + + diff --git a/maven/cn1lib-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml b/maven/cn1lib-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml index ac99b187955..7d4612cdb89 100644 --- a/maven/cn1lib-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml +++ b/maven/cn1lib-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml @@ -78,6 +78,22 @@ + + + + src/main/java + + **/*.java + + + + src/main/resources + + **/* + + + + diff --git a/maven/cn1lib-archetype/src/main/resources/archetype-resources/backend/pom.xml b/maven/cn1lib-archetype/src/main/resources/archetype-resources/backend/pom.xml new file mode 100644 index 00000000000..24156c39701 --- /dev/null +++ b/maven/cn1lib-archetype/src/main/resources/archetype-resources/backend/pom.xml @@ -0,0 +1,40 @@ + + + 4.0.0 + + ${groupId} + ${rootArtifactId} + ${version} + + ${groupId} + ${artifactId} + ${version} + + ${artifactId} + + + + UTF-8 + + + + + + com.codenameone + codenameone-backend + provided + + + diff --git a/maven/cn1lib-archetype/src/main/resources/archetype-resources/backend/src/main/java/.gitignore b/maven/cn1lib-archetype/src/main/resources/archetype-resources/backend/src/main/java/.gitignore new file mode 100644 index 00000000000..e69de29bb2d diff --git a/maven/cn1lib-archetype/src/main/resources/archetype-resources/backend/src/main/resources/META-INF/codenameone/backend-library b/maven/cn1lib-archetype/src/main/resources/archetype-resources/backend/src/main/resources/META-INF/codenameone/backend-library new file mode 100644 index 00000000000..0f7b6bda85b --- /dev/null +++ b/maven/cn1lib-archetype/src/main/resources/archetype-resources/backend/src/main/resources/META-INF/codenameone/backend-library @@ -0,0 +1,2 @@ +# Marks this jar as a Codename One backend library: a server that depends on it +# wires its @RestController, @Service and @Entity classes as the server's own. diff --git a/maven/cn1lib-archetype/src/main/resources/archetype-resources/pom.xml b/maven/cn1lib-archetype/src/main/resources/archetype-resources/pom.xml index 48c8c146d32..e0e0e5c1675 100644 --- a/maven/cn1lib-archetype/src/main/resources/archetype-resources/pom.xml +++ b/maven/cn1lib-archetype/src/main/resources/archetype-resources/pom.xml @@ -63,6 +63,8 @@ 1.8 + + backend common android ios @@ -89,6 +91,11 @@ codenameone-javase ${cn1.version} + + com.codenameone + codenameone-backend + ${cn1.version} + org.jetbrains.kotlin kotlin-stdlib diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendSupport.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendSupport.java index d8097f2a79a..7764898fed0 100644 --- a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendSupport.java +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/BackendSupport.java @@ -86,6 +86,18 @@ static Provider webAppDirectory(Project project) private BackendSupport() { } + /// A named class rather than a method reference, so the configuration + /// cache can serialize the filtered input it belongs to. + private static final class IsBackendLibrary implements org.gradle.api.specs.Spec, + java.io.Serializable { + private static final long serialVersionUID = 1L; + + @Override + public boolean isSatisfiedBy(File element) { + return com.codename1.maven.BackendLibraries.isLibrary(element); + } + } + static void apply(final Project project, final ProjectLayout layout, final CodenameOneExtension ext, final Provider> userProperties) { AppSupport.addFramework(project, "implementation", ext.getVersion(), "codenameone-backend"); @@ -99,10 +111,19 @@ static void apply(final Project project, final ProjectLayout layout, final Coden project.getTasks().named(main.getCompileJavaTaskName(), JavaCompile.class, compile -> { AppSupport.processingInputs(compile, layout, userProperties); backendSettingsInputs(project, compile, layout); + // The backend libraries' whole content, not their ABI: compile + // avoidance leaves compileJava up to date when a library changes only + // a method body, a migration or a resource, and the merge below would + // keep serving the old ones until a clean build. Maven's + // process-annotations runs every build and never meets this. + compile.getInputs().files(compile.getClasspath().filter(new IsBackendLibrary())) + .withPropertyName("cn1BackendLibraries") + .withPathSensitivity(org.gradle.api.tasks.PathSensitivity.NONE); compile.doLast("processCn1Annotations", new ProcessAnnotationsAction( compile.getDestinationDirectory().getAsFile(), AppSupport.stubsDir(layout), layout.projectDir(), layout.settingsFile(), project.provider(() -> AppSupport.sourceRoots(main, layout)), "UTF-8", userProperties, compile.getClasspath()) + .withBackendLibraries() .withSourceEncoding(AppSupport.javaEncoding(project, main)) .withCompilerJdk(compile.getJavaCompiler().map(c -> c.getMetadata().getInstallationPath().getAsFile()))); compile.doLast("cn1SplitOutputCheck", new com.codename1.gradle.tasks.SplitOutputCheck( diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/LibrarySupport.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/LibrarySupport.java index f7db8955306..6659ab49d66 100644 --- a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/LibrarySupport.java +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/LibrarySupport.java @@ -58,6 +58,7 @@ /// | `G:N-common` | the compiled library, with `codenameone_library_appended.properties` and `codenameone_library_required.properties` under `META-INF/codenameone/G/N-common/` | /// | `G:N-common:cn1css@zip` | `src/main/css`, merged into an application's theme | /// | `G:N-` | `src//` (compiled, for `javase`), one per platform | +/// | `G:N-backend` | `src/backend/java`, compiled against the backend runtime and marked as a backend library; only when it or `src/backend/resources` has files | /// | `G:N-lib@pom` | what applications depend on: the common jar and CSS, plus one `codename1.platform` profile per platform | /// /// `./gradlew publishToMavenLocal` installs it; any `publishing { repositories }` @@ -442,6 +443,7 @@ static void apply(final Project project, final ProjectLayout layout, final Coden }); }); } + registerBackend(project, layout, ext, publishing, name); publishing.getPublications().create("cn1libLib", MavenPublication.class, pub -> { pub.setArtifactId(name + "-lib"); pub.getPom().setPackaging("pom"); @@ -452,4 +454,71 @@ static void apply(final Project project, final ProjectLayout layout, final Coden }); }); } + + /// The server half of the library, from `src/backend/java` and + /// `src/backend/resources`: compiled against the backend runtime, published + /// as `N-backend` and marked as a backend library, so a server that depends + /// on it wires its controllers, beans and entities as its own (see + /// `BackendLibraries`). The Maven archetype's `backend` module and the + /// `.cn1lib`'s `backend.zip` carry the same classes. + /// + /// Not annotation processed here: the server does that, and a library's + /// generated wiring would collide with the server's. + private static void registerBackend(final Project project, final ProjectLayout layout, + final CodenameOneExtension ext, PublishingExtension publishing, + final String name) { + final File sources = new File(layout.projectDir(), "src" + File.separator + "backend" + + File.separator + "java"); + // A server half with only resources -- migrations, say -- is one too. + if (!hasFiles(sources) && !hasFiles(new File(layout.projectDir(), "src" + File.separator + + "backend" + File.separator + "resources"))) { + return; + } + SourceSetContainer sourceSets = project.getExtensions().getByType(JavaPluginExtension.class).getSourceSets(); + final SourceSet backend = sourceSets.create("backend", ss -> { + ss.getJava().setSrcDirs(Collections.singletonList(sources)); + ss.getResources().setSrcDirs(Collections.singletonList(new File(layout.projectDir(), + "src" + File.separator + "backend" + File.separator + "resources"))); + }); + // The server that uses the library brings its own runtime. + AppSupport.addFramework(project, backend.getCompileOnlyConfigurationName(), ext.getVersion(), + "codenameone-backend"); + final File markerDir = new File(project.getLayout().getBuildDirectory().getAsFile().get(), + "cn1-backend-marker"); + final TaskProvider marker = project.getTasks().register("cn1libBackendMarker", t -> { + t.getOutputs().dir(markerDir); + t.doLast("cn1BackendMarker", x -> { + File file = new File(markerDir, "META-INF" + File.separator + "codenameone" + + File.separator + "backend-library"); + if (!file.getParentFile().isDirectory() && !file.getParentFile().mkdirs()) { + throw new org.gradle.api.GradleException("Could not create " + file.getParentFile()); + } + try { + java.nio.file.Files.write(file.toPath(), new byte[0]); + } catch (java.io.IOException e) { + throw new org.gradle.api.GradleException("Could not write " + file, e); + } + }); + }); + final TaskProvider jar = project.getTasks().register("cn1libBackendJar", Jar.class, j -> { + j.setGroup(AppSupport.GROUP); + j.setDescription("Packages the library's server half"); + j.getArchiveBaseName().set(name + "-backend"); + j.from(backend.getOutput()); + j.from(marker); + }); + // The same jar for a server in this build that declares + // implementation(project(path = ":lib", configuration = "cn1libBackendElements")). + Configuration elements = project.getConfigurations().create("cn1libBackendElements", c -> { + c.setCanBeConsumed(true); + c.setCanBeResolved(false); + c.setDescription("The library's server half, for servers in this build"); + }); + project.getArtifacts().add(elements.getName(), jar); + publishing.getPublications().create("cn1libBackend", MavenPublication.class, + pub -> { + pub.setArtifactId(name + "-backend"); + pub.artifact(jar); + }); + } } diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/ProcessAnnotationsAction.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/ProcessAnnotationsAction.java index 5b272673225..5d640571390 100644 --- a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/ProcessAnnotationsAction.java +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/tasks/ProcessAnnotationsAction.java @@ -61,6 +61,7 @@ public final class ProcessAnnotationsAction implements Action { private final org.gradle.api.provider.Provider> userProperties; private final FileCollection compileClasspath; private final List pendingJavaSources = new ArrayList(); + private boolean backendLibraries; private org.gradle.api.provider.Provider sourceEncoding; /// @param classesDir `compileJava`'s destination @@ -86,6 +87,13 @@ public ProcessAnnotationsAction(org.gradle.api.provider.Provider classesDi /// The encoding javac reads the sources in (`compileJava.options.encoding`), /// in place of the constructor's: the processors read sources back to tell a /// live class from a stale output, and must decode them as javac did. + /// Merges the backend libraries on the classpath into the classes first, + /// so they are wired as this module's own. A server's main Java pass only. + public ProcessAnnotationsAction withBackendLibraries() { + this.backendLibraries = true; + return this; + } + public ProcessAnnotationsAction withSourceEncoding(org.gradle.api.provider.Provider encoding) { this.sourceEncoding = encoding; return this; @@ -219,6 +227,7 @@ public void execute(Task task) { .withOwnClassDirectories(pending == null ? java.util.Collections.emptyList() : java.util.Collections.singletonList(pending)) + .withBackendLibraries(backendLibraries) .run(); } catch (BuildExecutionException ex) { throw new GradleException(ex.getMessage(), ex.getCause() == null ? ex : ex.getCause()); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendModules.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendModules.java index 3e985fea24a..06aed7843af 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendModules.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendModules.java @@ -58,7 +58,11 @@ static boolean passOver(MavenProject project, List reactor, Log lo return true; } - private static boolean dependsOnBackend(MavenProject project) { + static boolean dependsOnBackend(MavenProject project) { + if (project == null) { + // A mojo run outside a build, as the plugin's tests do. + return false; + } Set artifacts = project.getArtifacts(); if (artifacts == null) { return false; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/Cn1libInstaller.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/Cn1libInstaller.java index b9cea67f352..6525f6b8a45 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/Cn1libInstaller.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/Cn1libInstaller.java @@ -423,6 +423,32 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException } + // The server half of the library: compiled classes the application's + // backend wires as its own (see BackendLibraries). Marked here whatever + // wrote the archive, since a backend.zip is a backend library by + // definition. + File backendJar = new File(libOutputJars, "backend.zip"); + if (backendJar.exists()) { + File marker = new File(libOutputJars, path("backend-marker", "META-INF", "codenameone", + "backend-library")); + marker.getParentFile().mkdirs(); + try { + FileUtils.writeStringToFile(marker, "", "UTF-8"); + } catch (IOException ex) { + throw new MojoExecutionException("Failed to write " + marker, ex); + } + Zip addMarker = (Zip)antProject.createTask("zip"); + addMarker.setDestFile(backendJar); + addMarker.setUpdate(true); + addMarker.setBasedir(new File(libOutputJars, "backend-marker")); + addMarker.execute(); + try { + FileUtils.copyFile(backendJar, new File(cn1libJars, backendJar.getName())); + } catch (IOException ex) { + throw new MojoExecutionException("Failed to copy "+backendJar+" to "+cn1libJars, ex); + } + } + File cssJar = new File(libOutputJars, "css.zip"); if (cssJar.exists()) { // We can't pass the cssJar as is beause the css directory must @@ -492,6 +518,7 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException + (iosJar.exists() ? profileXml("ios", groupId, artifactId, version): "") + (jsJar.exists() ? profileXml("javascript", groupId, artifactId, version) : "") + (winJar.exists() ? profileXml("win", groupId, artifactId, version) : "") + + (linuxJar.exists() ? profileXml("linux", groupId, artifactId, version) : "") + "\n" + "\n" + " \n" + @@ -539,6 +566,16 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException "jar\n" + "win\n" + "\n") : "") + + (linuxJar.exists() ? ("\n" + + "${basedir}/jars/"+linuxJar.getName()+"\n" + + "jar\n" + + "linux\n" + + "\n") : "") + + (backendJar.exists() ? ("\n" + + "${basedir}/jars/"+backendJar.getName()+"\n" + + "jar\n" + + "backend\n" + + "\n") : "") + (cssJar.exists() ? ("\n" + "${basedir}/jars/css.zip\n" + "zip\n" + @@ -614,6 +651,10 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException } } + if (backendJar.exists()) { + injectBackendDependency(groupId, artifactId, version); + } + File parentPomFile = new File(cn1libsDirectory, "pom.xml"); @@ -690,6 +731,61 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException + /// Adds the library's server half to the application's backend module, the + /// sibling `backend` directory of the common module, when there is one. + /// The server depends on the `backend` classifier alone: the library's pom + /// brings the client classes and the core with it, which a server has no + /// use for. + private void injectBackendDependency(String groupId, String artifactId, String version) + throws MojoExecutionException { + String dependency = "\n" + + " "+groupId+"\n" + + " "+artifactId+"\n" + + " "+version+"\n" + + " backend\n" + + "\n"; + File backendPom = new File(getProjectBasedir().getParentFile(), path("backend", "pom.xml")); + if (!backendPom.isFile()) { + getLog().info("The cn1lib has a backend part. To use it from a server module, add:\n" + dependency); + return; + } + String contents; + try { + contents = FileUtils.readFileToString(backendPom, "UTF-8"); + } catch (IOException ex) { + throw new MojoExecutionException("Failed to read " + backendPom, ex); + } + if (contents.contains(""+artifactId+"")) { + getLog().warn("The artifact "+artifactId+" is already referenced in "+backendPom + +". Make sure it is this dependency:\n" + dependency); + return; + } + // The marker when the pom has one, else the end of the module's own + // : the first one after any , and + // before , whose plugins have dependencies of their own. + int at = contents.indexOf(""); + if (at < 0) { + int from = Math.max(0, contents.indexOf("")); + at = contents.indexOf("", from); + int build = contents.indexOf(""); + if (build >= 0 && at > build) { + at = -1; + } + } + if (at < 0) { + getLog().warn("Could not find where to add the backend dependency in "+backendPom + +". Add it by hand:\n" + dependency); + return; + } + contents = contents.substring(0, at) + dependency + contents.substring(at); + try { + FileUtils.writeStringToFile(backendPom, contents, "UTF-8"); + } catch (IOException ex) { + throw new MojoExecutionException("Failed to update " + backendPom, ex); + } + getLog().info("Added the cn1lib's backend part to "+backendPom); + } + private String profileXml(String platform, String groupId, String artifactId, String version) { return " \n" + " "+platform+"" diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/Cn1libMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/Cn1libMojo.java index 15e9cfad8cd..2a45e9f330c 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/Cn1libMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/Cn1libMojo.java @@ -345,8 +345,73 @@ private void buildCSS() { - private void buildCn1lib() throws IOException { + /// The server half of the library, `backend.zip`: the compiled classes of + /// the sibling `backend` module, which the application's server wires as + /// its own (see BackendLibraries). Classes rather than sources, unlike the + /// native parts, because a server runs them on the JVM as they are and the + /// native backend build translates their bytecode. + /// + /// The archetype lists `backend` before `common`, so the reactor has built + /// it by the time this runs. + private void buildBackend() throws IOException, MojoExecutionException { + File module = getModuleProject("backend"); + if (!new File(module, "pom.xml").isFile()) { + return; + } + File source = new File(module, path("src", "main", "java")); + File resources = new File(module, path("src", "main", "resources")); + if (!hasJava(source) && !hasResources(resources, resources)) { + // The archetype generates the module for every library; most have + // no server half. One with only resources -- migrations, say -- has. + return; + } + File classes = new File(module, path("target", "classes")); + File marker = new File(classes, path("META-INF", "codenameone", "backend-library")); + if (!marker.isFile()) { + throw new MojoExecutionException("The cn1lib has a backend module, but " + marker + + " is missing. Build the backend module before common, as the cn1lib " + + "archetype's module order does (mvn package from the project root)."); + } + // Packed from target/classes as it stands, like every jar Maven builds: + // a class whose source was deleted without a clean ships here too, and + // a server would wire it. A release build is a clean build. + buildZip("backend", new File[]{classes}); + } + + /// Whether `dir` holds a resource other than the backend-library marker, + /// which the archetype puts in every library. + private static boolean hasResources(File root, File dir) { + File[] children = dir.listFiles(); + if (children == null) { + return false; + } + for (File child : children) { + if (child.isDirectory() ? hasResources(root, child) + : !root.toURI().relativize(child.toURI()).getPath() + .equals("META-INF/codenameone/backend-library")) { + return true; + } + } + return false; + } + + private static boolean hasJava(File dir) { + File[] children = dir.listFiles(); + if (children == null) { + return false; + } + for (File child : children) { + if (child.isDirectory() ? hasJava(child) + : child.getName().endsWith(".java") || child.getName().endsWith(".kt")) { + return true; + } + } + return false; + } + + private void buildCn1lib() throws IOException, MojoExecutionException { buildMainZip(); + buildBackend(); buildCSS(); buildAndroid(); buildIOS(); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ProcessAnnotationsMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ProcessAnnotationsMojo.java index 9677be2df64..bf8ad328ec4 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ProcessAnnotationsMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ProcessAnnotationsMojo.java @@ -92,7 +92,10 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException // given; see AnnotationProcessing. compileSourceRoots(project, userProperties()), sourceEncodingOf(project, userProperties()), - compileClasspathOf(project)).run(); + compileClasspathOf(project)) + // A server wires its backend libraries as its own classes. + .withBackendLibraries(BackendModules.dependsOnBackend(project)) + .run(); } catch (com.codename1.build.BuildFailureException e) { throw new MojoFailureException(e.getMessage(), e.getCause() == null ? e : e.getCause()); } catch (com.codename1.build.BuildExecutionException e) { diff --git a/maven/integration-tests/backend-cn1lib-test.sh b/maven/integration-tests/backend-cn1lib-test.sh new file mode 100755 index 00000000000..b37f2d6780f --- /dev/null +++ b/maven/integration-tests/backend-cn1lib-test.sh @@ -0,0 +1,255 @@ +#!/bin/bash +# +# A cn1lib with a server half, used by a generated application's server. +# +# A cn1lib's backend module holds classes a server wires as its own: the +# library's @RestController gets a route, its @Entity a dao, its @Repository an +# implementation and its @Component a bean, all generated in the SERVER's build +# (see BackendLibraries in build-engine). This builds one of each and asks the +# questions nothing else in CI asks: +# +# the cn1lib archetype's backend module builds, and the .cn1lib it produces +# carries backend.zip, marked as a backend library +# a project from the app archetype that depends on the library's backend jar +# answers the library's routes, through the library's repository and its +# migration, in a @BackendTest -- without one line of registration code +# the server's own code can inject the library's bean +# all of it again natively, translated by ParparVM +# +# Run with the checkout's artifacts installed (core, backend, backend-test, the +# Maven plugin, both archetypes) into the repository MAVEN_ARGS names. +SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )" +set -e +source "$SCRIPTPATH/inc/env.sh" + +WORK="$SCRIPTPATH/build/backend-cn1lib" +GROUP=com.example.backendlib +LIB=notelib +APP=noteapp +mkdir -p "$SCRIPTPATH/build" +if [ -d "$WORK" ]; then + rm -rf "$WORK" +fi +mkdir -p "$WORK" +cd "$WORK" + +fail() { echo "backend-cn1lib-test: $*" >&2; exit 1; } + +echo "== the library" +mvn -B archetype:generate \ + -DarchetypeArtifactId=cn1lib-archetype \ + -DarchetypeGroupId=com.codenameone \ + -DarchetypeVersion="$CN1_VERSION" \ + -DartifactId=$LIB \ + -DgroupId=$GROUP \ + -Dversion=1.0-SNAPSHOT \ + -DjavaVersion="$CN1_ARCHETYPE_JAVA_VERSION" \ + -DinteractiveMode=false + +SRC="$LIB/backend/src/main/java/com/example/backendlib" +mkdir -p "$SRC" "$LIB/backend/src/main/resources/db/migration" +[ -f "$LIB/backend/src/main/resources/META-INF/codenameone/backend-library" ] \ + || fail "the archetype's backend module has no backend-library marker" + +cat > "$LIB/backend/src/main/resources/db/migration/V900__notelib.sql" <<'EOF' +CREATE TABLE notelib_note (id INTEGER PRIMARY KEY, text VARCHAR(200) NOT NULL); +EOF +cat > "$SRC/LibNote.java" <<'EOF' +package com.example.backendlib; + +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +@Entity(table = "notelib_note") +public class LibNote { + @Id + public long id; + public String text; +} +EOF +cat > "$SRC/LibNotes.java" <<'EOF' +package com.example.backendlib; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; + +@Repository(entity = LibNote.class, key = Long.class) +public interface LibNotes extends CrudRepository { +} +EOF +cat > "$SRC/NoteService.java" <<'EOF' +package com.example.backendlib; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; + +@Component +public class NoteService { + private final LibNotes notes; + + public NoteService(LibNotes notes) { + this.notes = notes; + } + + @Transactional + public long add(String text) { + LibNote note = new LibNote(); + note.text = text; + notes.insert(note); + return note.id; + } + + @Transactional + public long count() { + return notes.count(); + } +} +EOF +cat > "$SRC/NoteEndpoint.java" <<'EOF' +package com.example.backendlib; + +import com.codename1.backend.annotations.GetMapping; +import com.codename1.backend.annotations.PostMapping; +import com.codename1.backend.annotations.RequestBody; +import com.codename1.backend.annotations.RequestMapping; +import com.codename1.backend.annotations.RestController; + +@RestController +@RequestMapping("/notelib") +public class NoteEndpoint { + private final NoteService notes; + + public NoteEndpoint(NoteService notes) { + this.notes = notes; + } + + @PostMapping("/notes") + public String add(@RequestBody String text) { + return String.valueOf(notes.add(text)); + } + + @GetMapping("/notes/count") + public String count() { + return String.valueOf(notes.count()); + } +} +EOF + +(cd "$LIB" && mvn -B install -DskipTests) + +CN1LIB="$(ls "$LIB"/common/target/*.cn1lib | head -n 1)" +[ -f "$CN1LIB" ] || fail "no .cn1lib was produced" +unzip -l "$CN1LIB" | grep -q " backend.zip$" || fail "$CN1LIB has no backend.zip" +unzip -p "$CN1LIB" backend.zip > "$WORK/backend.zip" +unzip -l "$WORK/backend.zip" | grep -q "META-INF/codenameone/backend-library" \ + || fail "backend.zip is not marked as a backend library" +unzip -l "$WORK/backend.zip" | grep -q "com/example/backendlib/NoteEndpoint.class" \ + || fail "backend.zip does not hold the library's classes" +if unzip -l "$WORK/backend.zip" | grep -q "Router.class\|cn1app/"; then + fail "backend.zip holds generated wiring; the server generates it" +fi + +echo "== the application" +mvn -B archetype:generate -DplatformModules=all -DprojectType=app-with-backend \ + -DarchetypeArtifactId=cn1app-archetype \ + -DarchetypeGroupId=com.codenameone \ + -DarchetypeVersion="$CN1_VERSION" \ + -DartifactId=$APP \ + -DgroupId=com.example.noteapp \ + -Dversion=1.0-SNAPSHOT \ + -DmainName=NoteApp \ + -DjavaVersion="$CN1_ARCHETYPE_JAVA_VERSION" \ + -DinteractiveMode=false + +python3 - "$APP/backend/pom.xml" "$GROUP" "$LIB" <<'PY' +import sys +path, group, lib = sys.argv[1:] +text = open(path).read() +marker = '' +if marker not in text: + sys.exit('the app archetype backend pom has no ' + marker) +dependency = ('\n %s\n %s-backend\n' + ' 1.0-SNAPSHOT\n\n' % (group, lib)) +open(path, 'w').write(text.replace(marker, dependency + marker, 1)) +PY + +APP_PKG_DIR="$(dirname "$(grep -rl "@RestController" "$APP/backend/src/main/java" | head -n 1)")" +APP_PKG="$(sed -n 's/^package \(.*\);/\1/p' "$(grep -rl "@RestController" "$APP/backend/src/main/java" | head -n 1)")" +TEST_DIR="$APP/backend/src/test/java/$(echo "$APP_PKG" | tr '.' '/')" +mkdir -p "$TEST_DIR" +# The server's own bean, built on the library's. +cat > "$APP_PKG_DIR/NoteCounter.java" < "$TEST_DIR/LibraryWiringTest.java" </dev/null 2>&1 \ + || fail "LibraryWiringTest did not run" +[ -f "$APP/backend/target/classes/com/example/backendlib/NoteEndpointRouter.class" ] \ + || fail "the server generated no router for the library's controller" + +# The same tests translated by ParparVM and run natively: the native packager +# merges the library through its own compile, not process-annotations, and +# stages the library jar for its natives only. Needs clang and the backend's +# native headers; CN1_BACKEND_PACKAGE_REQUIRED=1 (CI) makes their absence an +# error instead of a skip. +if command -v clang >/dev/null 2>&1; then + (cd "$APP" && mvn -B -pl backend -Dcodename1.platform=backend -Dcn1.backend.compiledTests=true verify) \ + | tee "$WORK/native.log" + grep -q "compiled tests: [0-9]* passed, 0 failed" "$WORK/native.log" \ + || fail "the natively compiled tests did not all pass" +elif [ "${CN1_BACKEND_PACKAGE_REQUIRED:-}" = "1" ]; then + fail "clang is not installed, and CN1_BACKEND_PACKAGE_REQUIRED=1" +else + echo "backend-cn1lib-test: no clang; the native leg is skipped" +fi + +echo "backend-cn1lib-test: OK" diff --git a/maven/integration-tests/gradle-cn1lib-test.sh b/maven/integration-tests/gradle-cn1lib-test.sh index 48870f8ce44..f004647e411 100755 --- a/maven/integration-tests/gradle-cn1lib-test.sh +++ b/maven/integration-tests/gradle-cn1lib-test.sh @@ -123,6 +123,22 @@ public class GreeterAndroid { } EOF echo "// ios" > "$LIB/src/ios/objectivec/greeter_ios.m" +# The server half, published as greeter-backend and marked as a backend library. +mkdir -p "$LIB/src/backend/java/$GROUP_PATH" +cat > "$LIB/src/backend/java/$GROUP_PATH/GreeterEndpoint.java" < "$WORKDIR/publish.log" 2>&1 || { cat "$WORKDIR/publish.log"; fail "publishing the library"; } @@ -136,6 +152,7 @@ for f in greeter-common/$LIBVER/greeter-common-$LIBVER.jar \ greeter-android/$LIBVER/greeter-android-$LIBVER.jar \ greeter-ios/$LIBVER/greeter-ios-$LIBVER.jar \ greeter-javase/$LIBVER/greeter-javase-$LIBVER.jar \ + greeter-backend/$LIBVER/greeter-backend-$LIBVER.jar \ greeter-lib/$LIBVER/greeter-lib-$LIBVER.pom; do [ -f "$P/$f" ] || { find "$LIBREPO" -type f; fail "the library did not publish $f"; } done @@ -145,6 +162,11 @@ assert_zip_has "$P/greeter-common/$LIBVER/greeter-common-$LIBVER.jar" \ assert_zip_has "$P/greeter-common/$LIBVER/greeter-common-$LIBVER-cn1css.zip" "css/theme\.css$" assert_zip_has "$P/greeter-android/$LIBVER/greeter-android-$LIBVER.jar" "^$GROUP_PATH/GreeterAndroid\.java$" assert_zip_has "$P/greeter-ios/$LIBVER/greeter-ios-$LIBVER.jar" "^greeter_ios\.m$" +assert_zip_has "$P/greeter-backend/$LIBVER/greeter-backend-$LIBVER.jar" "^$GROUP_PATH/GreeterEndpoint\.class$" +assert_zip_has "$P/greeter-backend/$LIBVER/greeter-backend-$LIBVER.jar" "^META-INF/codenameone/backend-library$" +if unzip -l "$P/greeter-backend/$LIBVER/greeter-backend-$LIBVER.jar" | grep -q "Router\.class"; then + fail "greeter-backend holds generated wiring; the server that uses it generates that" +fi grep -q "codename1.platform" "$P/greeter-lib/$LIBVER/greeter-lib-$LIBVER.pom" \ || fail "the -lib pom has no codename1.platform profiles" # A Maven consumer reaches the inner library through this dependency, which only diff --git a/scripts/ios/screenshots-metal-27/KmpCompatLayout.png b/scripts/ios/screenshots-metal-27/KmpCompatLayout.png new file mode 100644 index 00000000000..6f44bf70f27 Binary files /dev/null and b/scripts/ios/screenshots-metal-27/KmpCompatLayout.png differ diff --git a/scripts/ios/screenshots-metal-27/KmpCompatList.png b/scripts/ios/screenshots-metal-27/KmpCompatList.png new file mode 100644 index 00000000000..55afd7fd9a2 Binary files /dev/null and b/scripts/ios/screenshots-metal-27/KmpCompatList.png differ diff --git a/scripts/ios/screenshots-metal-27/KmpCompatState.png b/scripts/ios/screenshots-metal-27/KmpCompatState.png new file mode 100644 index 00000000000..169f9aa982f Binary files /dev/null and b/scripts/ios/screenshots-metal-27/KmpCompatState.png differ diff --git a/scripts/ios/screenshots-metal-27/KmpCompatTypography.png b/scripts/ios/screenshots-metal-27/KmpCompatTypography.png new file mode 100644 index 00000000000..9f16d664122 Binary files /dev/null and b/scripts/ios/screenshots-metal-27/KmpCompatTypography.png differ diff --git a/scripts/ios/screenshots-metal/KmpCompatLayout.png b/scripts/ios/screenshots-metal/KmpCompatLayout.png new file mode 100644 index 00000000000..71b620c001e Binary files /dev/null and b/scripts/ios/screenshots-metal/KmpCompatLayout.png differ diff --git a/scripts/ios/screenshots-metal/KmpCompatList.png b/scripts/ios/screenshots-metal/KmpCompatList.png new file mode 100644 index 00000000000..ea2eab61d1a Binary files /dev/null and b/scripts/ios/screenshots-metal/KmpCompatList.png differ diff --git a/scripts/ios/screenshots-metal/KmpCompatState.png b/scripts/ios/screenshots-metal/KmpCompatState.png new file mode 100644 index 00000000000..fe98a7b176f Binary files /dev/null and b/scripts/ios/screenshots-metal/KmpCompatState.png differ diff --git a/scripts/ios/screenshots-metal/KmpCompatTypography.png b/scripts/ios/screenshots-metal/KmpCompatTypography.png new file mode 100644 index 00000000000..1c447208a3d Binary files /dev/null and b/scripts/ios/screenshots-metal/KmpCompatTypography.png differ diff --git a/scripts/ios/screenshots-tv/KmpCompatLayout.png b/scripts/ios/screenshots-tv/KmpCompatLayout.png new file mode 100644 index 00000000000..af5f0b6166e Binary files /dev/null and b/scripts/ios/screenshots-tv/KmpCompatLayout.png differ diff --git a/scripts/ios/screenshots-tv/KmpCompatList.png b/scripts/ios/screenshots-tv/KmpCompatList.png new file mode 100644 index 00000000000..98afb57defb Binary files /dev/null and b/scripts/ios/screenshots-tv/KmpCompatList.png differ diff --git a/scripts/ios/screenshots-tv/KmpCompatState.png b/scripts/ios/screenshots-tv/KmpCompatState.png new file mode 100644 index 00000000000..8307c18578d Binary files /dev/null and b/scripts/ios/screenshots-tv/KmpCompatState.png differ diff --git a/scripts/ios/screenshots-tv/KmpCompatTypography.png b/scripts/ios/screenshots-tv/KmpCompatTypography.png new file mode 100644 index 00000000000..8ce5206386e Binary files /dev/null and b/scripts/ios/screenshots-tv/KmpCompatTypography.png differ diff --git a/scripts/ios/screenshots-watch/KmpCompatLayout.png b/scripts/ios/screenshots-watch/KmpCompatLayout.png new file mode 100644 index 00000000000..2bc27184a42 Binary files /dev/null and b/scripts/ios/screenshots-watch/KmpCompatLayout.png differ diff --git a/scripts/ios/screenshots-watch/KmpCompatList.png b/scripts/ios/screenshots-watch/KmpCompatList.png new file mode 100644 index 00000000000..688d93f65ec Binary files /dev/null and b/scripts/ios/screenshots-watch/KmpCompatList.png differ diff --git a/scripts/ios/screenshots-watch/KmpCompatState.png b/scripts/ios/screenshots-watch/KmpCompatState.png new file mode 100644 index 00000000000..105a4e2e4eb Binary files /dev/null and b/scripts/ios/screenshots-watch/KmpCompatState.png differ diff --git a/scripts/ios/screenshots-watch/KmpCompatTypography.png b/scripts/ios/screenshots-watch/KmpCompatTypography.png new file mode 100644 index 00000000000..d1a7e4edc3e Binary files /dev/null and b/scripts/ios/screenshots-watch/KmpCompatTypography.png differ diff --git a/scripts/ios/screenshots-watch/TabsAnimatedIndicatorScreenshotTest.png b/scripts/ios/screenshots-watch/TabsAnimatedIndicatorScreenshotTest.png index f98e6c4e9d1..00a2221631e 100644 Binary files a/scripts/ios/screenshots-watch/TabsAnimatedIndicatorScreenshotTest.png and b/scripts/ios/screenshots-watch/TabsAnimatedIndicatorScreenshotTest.png differ