From d1c060c49d41e22bd8409cf24ca9c7239f29f83d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:32:37 +0300 Subject: [PATCH 1/5] Minimal and backend-only Maven projects, default from 7.0.275 Maven projects can now omit every platform module: the root pom and common/ build all platforms, run the simulator and tests, and package the desktop app. A platform has its own module exactly when /pom.xml exists; adding one later moves that platform's build into it. A backend-only project is a single module at the root. The archetype generates the minimal layout by default (-DplatformModules, -DprojectType); the Initializr does once its plugin is 7.0.275 or newer, with the full layout as an opt-in. Also fixes JUnit tests in generated apps, which never ran: Surefire only had Codename One's stub provider. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/archetype-smoke.yml | 15 + .github/workflows/scaffolding-parity.yml | 2 + .../Advanced-Topics-Under-The-Hood.asciidoc | 2 + docs/developer-guide/Backend.asciidoc | 14 +- .../Maven-Appendix-Archetypes.adoc | 29 +- .../Maven-Getting-Started.adoc | 8 +- maven/cn1app-archetype/pom.xml | 23 + .../META-INF/archetype-post-generate.groovy | 165 +++- .../META-INF/maven/archetype-metadata.xml | 28 + .../resources/archetype-resources/README.adoc | 4 + .../archetype-resources/common/pom.xml | 37 +- .../archetype-resources/javase/pom.xml | 16 + .../resources/archetype-resources/pom.xml | 27 + .../projects/auto-jdk/archetype.properties | 2 + .../projects/basic/archetype.properties | 2 + .../projects/netbeans/archetype.properties | 2 + .../java/com/codename1/gradle/AppSupport.java | 5 +- .../com/codename1/maven/AbstractCN1Mojo.java | 68 ++ .../maven/AndroidOnDeviceDebuggingMojo.java | 15 + .../com/codename1/maven/CN1BuildMojo.java | 37 + .../com/codename1/maven/CN1DebugMojo.java | 10 +- .../java/com/codename1/maven/CN1RunMojo.java | 14 +- .../maven/CompileJavaSENativesMojo.java | 293 ++++++ .../maven/GenerateAppProjectMojo.java | 31 +- .../maven/GenerateDesktopAppWrapperMojo.java | 32 +- .../codename1/maven/HostedPlatformMojo.java | 54 ++ .../com/codename1/maven/HostedPlatforms.java | 125 +++ .../maven/JavaSEExecutableJarMojo.java | 198 ++++ .../maven/PrepareSimulatorClasspathMojo.java | 41 +- .../com/codename1/maven/RunTestsMojo.java | 4 + .../com/codename1/maven/SimulatorMojo.java | 10 +- .../maven/UpdateCodenameOneMojo.java | 32 + .../codename1/maven/HostedPlatformsTest.java | 257 +++++ maven/integration-tests/all.sh | 1 + .../android-native-interface-test.sh | 1 + .../bare-bones-kotlin-test.sh | 1 + .../build-hint-annotations-test.sh | 1 + .../cn1app-archetype-test.sh | 1 + .../cn1app-desktop-build-test.sh | 1 + .../cn1app-minimal-layout-test.sh | 227 +++++ .../cn1app-staged-jar-test.sh | 1 + maven/integration-tests/cssfonts.sh | 1 + maven/integration-tests/googlemaps-demo.sh | 1 + .../gradle-maven-parity-test.sh | 1 + .../integration-tests/gradle-migrate-test.sh | 1 + .../initializr-roundtrip-test.sh | 2 + .../migrate-googlemapsdemo-test.sh | 1 + .../migrate-kitchensink-test.sh | 1 + maven/integration-tests/native-interfaces.sh | 1 + .../validate_generated_repositories.py | 15 +- .../com/codename1/project/ProjectLayout.java | 44 + .../com/codename1/project/ProjectLayouts.java | 21 + .../codename1/project/ProjectLayoutsTest.java | 75 ++ .../com/codename1/initializr/Initializr.java | 86 +- .../initializr/model/GeneratorModel.java | 511 +++++++++- .../initializr/model/ProjectOptions.java | 37 +- .../main/resources/agent-skill-agents-md.md | 7 +- .../src/main/resources/backend-only-pom.xml | 117 +++ .../common-hosted-platform-profiles.xml | 889 ++++++++++++++++++ .../common/src/main/resources/common.zip | Bin 259445 -> 259640 bytes .../common/src/main/resources/skill/SKILL.md | 25 +- .../skill/references/build-and-run.md | 35 +- .../skill/references/junit-testing.md | 12 +- .../skill/references/native-interfaces.md | 6 +- .../GeneratorModelIntegrationBuildTest.java | 93 ++ .../model/GeneratorModelMatrixTest.java | 263 +++++- scripts/initializr/update-cn1-version.sh | 7 + scripts/tests/GenerateInitializr.java | 15 + scripts/tests/generate-initializr-fixtures.py | 42 +- 69 files changed, 3961 insertions(+), 184 deletions(-) create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CompileJavaSENativesMojo.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/HostedPlatformMojo.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/HostedPlatforms.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/JavaSEExecutableJarMojo.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HostedPlatformsTest.java create mode 100755 maven/integration-tests/cn1app-minimal-layout-test.sh create mode 100644 scripts/initializr/common/src/main/resources/backend-only-pom.xml create mode 100644 scripts/initializr/common/src/main/resources/common-hosted-platform-profiles.xml diff --git a/.github/workflows/archetype-smoke.yml b/.github/workflows/archetype-smoke.yml index dfa2a2da91f..9cb832acda0 100644 --- a/.github/workflows/archetype-smoke.yml +++ b/.github/workflows/archetype-smoke.yml @@ -24,6 +24,9 @@ on: - 'maven/backend/**' - 'maven/integration-tests/cn1app-archetype-test.sh' - 'maven/integration-tests/cn1app-staged-jar-test.sh' + - 'maven/integration-tests/cn1app-minimal-layout-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 # dependency versions and aggregators the archetype pulls in. - 'maven/pom.xml' @@ -46,6 +49,9 @@ on: - 'maven/backend/**' - 'maven/integration-tests/cn1app-archetype-test.sh' - 'maven/integration-tests/cn1app-staged-jar-test.sh' + - 'maven/integration-tests/cn1app-minimal-layout-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 # dependency versions and aggregators the archetype pulls in. - 'maven/pom.xml' @@ -131,3 +137,12 @@ jobs: cd maven/integration-tests # The project's CSS compile opens an AWT frame, so it needs a display. xvfb-run -a bash cn1app-staged-jar-test.sh + # The archetype's default layouts: an app with no platform modules, whose + # common module builds, tests and stages every platform, and a backend-only + # project. Nothing is submitted and the simulator is never launched. + - name: Build the minimal and backend-only layouts + env: + CN1_BACKEND_PACKAGE_REQUIRED: '1' + run: | + cd maven/integration-tests + xvfb-run -a bash cn1app-minimal-layout-test.sh diff --git a/.github/workflows/scaffolding-parity.yml b/.github/workflows/scaffolding-parity.yml index 79b3b8643c0..250d994a296 100644 --- a/.github/workflows/scaffolding-parity.yml +++ b/.github/workflows/scaffolding-parity.yml @@ -24,6 +24,7 @@ on: - 'maven/cn1app-archetype/src/main/resources/archetype-resources/pom.xml' - 'maven/cn1lib-archetype/src/main/resources/archetype-resources/pom.xml' - 'scripts/initializr/common/src/main/resources/common.zip' + - 'scripts/initializr/common/src/main/resources/backend-only-pom.xml' - 'maven/integration-tests/scaffolding-settings-parity-test.sh' - 'maven/integration-tests/normalize_cn1_settings.py' - 'maven/integration-tests/validate_initializr_pom_coordinates.py' @@ -36,6 +37,7 @@ on: - 'maven/cn1app-archetype/src/main/resources/archetype-resources/pom.xml' - 'maven/cn1lib-archetype/src/main/resources/archetype-resources/pom.xml' - 'scripts/initializr/common/src/main/resources/common.zip' + - 'scripts/initializr/common/src/main/resources/backend-only-pom.xml' - 'maven/integration-tests/scaffolding-settings-parity-test.sh' - 'maven/integration-tests/normalize_cn1_settings.py' - 'maven/integration-tests/validate_initializr_pom_coordinates.py' diff --git a/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc b/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc index 6b07d7f2a4d..6c65ec801fe 100644 --- a/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc +++ b/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc @@ -409,6 +409,8 @@ The implementation of this interface is identical for Android (Java/Kotlin) & Ja For iOS native interfaces you can implement the generated `...Impl` class in Objective-C _or_ Swift. + For Android native interfaces you can implement the generated `...Impl` class in Java _or_ Kotlin. +In a Maven project the directories below hold the native code whether the project has a module for that platform or not. `mvn cn1:generate-native-interfaces` creates them as needed, and a project without the platform's module builds them from the `common` module (see <>). + [cols="1,3,3",options="header"] |=== | Platform diff --git a/docs/developer-guide/Backend.asciidoc b/docs/developer-guide/Backend.asciidoc index d9622f026e6..5d6bf4f3d9e 100644 --- a/docs/developer-guide/Backend.asciidoc +++ b/docs/developer-guide/Backend.asciidoc @@ -73,20 +73,21 @@ a server and go into depth: === A first server -The archetype and the initializr both generate a `backend` module beside the -client ones: +The archetype (with `-DprojectType=app-with-backend`) and the initializr (with the +_App with backend_ project type) both generate a `backend` module beside the app: ---- myapp/ common/ shared app code - javase/ desktop build - ios/ iOS build - android/ Android build backend/ the server pom.xml src/main/java/com/example/myapp/Notes.java ---- +A server-only project, generated with `-DprojectType=backend-only` or the +initializr's _Backend only_ type, has no `common` module: the server is the whole +project, at the root (see <>). + A server is a class with routes on it. The annotations are Spring's, under Codename One's package names, so this reads the same way to anyone who has written a Spring controller: @@ -124,6 +125,9 @@ mvn -pl backend -Dcodename1.platform=backend cn1:backend # run it on t mvn -pl backend -Dcodename1.platform=backend cn1:backend-package # build the native binary ---- +In a backend-only Maven project, run `./mvnw cn1:backend` and +`./mvnw cn1:backend-package` from the root, with no module or property. + In a Gradle project the backend is the `backend` subproject (or the whole project, for a backend-only application), and the same two steps are tasks: diff --git a/docs/developer-guide/Maven-Appendix-Archetypes.adoc b/docs/developer-guide/Maven-Appendix-Archetypes.adoc index fd4bbaf1126..bcbc3636cea 100644 --- a/docs/developer-guide/Maven-Appendix-Archetypes.adoc +++ b/docs/developer-guide/Maven-Appendix-Archetypes.adoc @@ -4,7 +4,34 @@ [#cn1app-archetype] === Codename One application project archetype (cn1app-archetype) -The `cn1app-archetype` is the basis for all maven Codename One application projects. It provides a multimodule project with the following modules: +The `cn1app-archetype` is the basis for all Maven Codename One application projects. Two properties choose the shape of the project it generates: + +`platformModules`:: +The platform modules to generate. The default, `none`, is the minimal layout: the root `pom.xml` and the `common` module, and nothing else. `all` generates a module for each platform (`javase`, `android`, `ios`, `javascript`, `win` and `linux`), and a comma-separated list such as `javase,android` generates just those. + +`projectType`:: +`app` (the default), `app-with-backend`, which adds the `backend` module (see <<_server_side_backend,the backend chapter>>), or `backend-only`, which generates a server on its own: a single module at the root with no application. + +`-DplatformModules=all -DprojectType=app-with-backend` generates the full multi-module layout. + +[#maven-minimal-layout] +==== Projects without platform modules + +The platform modules are optional. When a platform has no module, the `common` module builds it: `./build.sh android` and `mvn package -Dcodename1.platform=android` work the same with or without an `android` module, and so do the simulator, the desktop app and the unit tests. A platform has a module of its own exactly when `/pom.xml` exists, so you can add one later, for example to give that platform its own build plugins or dependencies, and from then on the module builds that platform instead of `common`. + +Native interface implementations live in the same directories either way. `mvn cn1:generate-native-interfaces` creates `android/src/main/java`, `ios/src/main/objectivec`, `javase/src/main/java` and the others as needed, without a `pom.xml`, and the build picks them up from there. The Java SE implementations are compiled into `common/target/cn1-javase/classes`, apart from the application's own classes, so they never reach a device build. + +A project without a `javase` module keeps the desktop app's native theme in `common/src/desktop/resources`. `mvn package -Pexecutable-jar -Dcodename1.platform=javase` writes the desktop jar to `common/target`. + +[#maven-backend-only] +==== Backend-only projects + +A project generated with `-DprojectType=backend-only` is a server and nothing else: a `pom.xml`, `application.properties` and the sources under `src/main/java`, all at the root. The commands drop the module and the profile: + +---- +./mvnw cn1:backend # run it on this JVM +./mvnw cn1:backend-package # build the native binary +---- See https://shannah.github.io/cn1-maven-archetypes/cn1app-archetype-tutorial/getting-started.html[Getting Started with the Bare-Bones Java App Template] for details on using this archetype. diff --git a/docs/developer-guide/Maven-Getting-Started.adoc b/docs/developer-guide/Maven-Getting-Started.adoc index 7a053759dae..2a019a37552 100644 --- a/docs/developer-guide/Maven-Getting-Started.adoc +++ b/docs/developer-guide/Maven-Getting-Started.adoc @@ -252,7 +252,7 @@ With Codename One projects, there are a few caveats (see <>), ==== Which `pom.xml` to add the `` snippet to Suppose you have a Maven `` snippet that you've copied from Maven central, and it's burning a hole in your clipboard while you're trying to figure out where to paste it into your project. -Codename One application projects, being multi-module projects, have more than one `pom.xml` file; One per module. +Codename One application projects are multi-module projects, so they have more than one `pom.xml` file: the root one, the `common` module's, and one per platform module the project has. A project generated with the defaults has no platform modules (see <>). **Question:** Which pom.xml file should you paste the snippet into? @@ -274,14 +274,16 @@ Here's an overview: %PROJECT_ROOT%/pom.xml:: The root pom.xml file is the parent module of all other modules. Anything you add here will be inherited by all the modules. It can be helpful to use `` and `` sections in this file to merge versions for dependencies and plugins project-wide. This is also a good place to add project meta-data like ``, ``. -Java SE/pom.xml:: +javase/pom.xml (when the project has a javase module):: Any dependencies that are only required for native implementations on the Java SE platform can be added here. Dependencies added to this project aren't subject to <>. + Additionally, this module handles the build toolchain for the Java SE platform. This includes Mac and Windows Desktop builds, as well as Java SE desktop builds. If you want to customize the build workflow for any of these targets, you would do so by adding plugin executions in this pom.xml file. -android, ios, win, and JavaScript:: +android, ios, win, linux, and javascript (when the project has them):: These modules don't use Maven for their dependencies (Android may deserve a small asterisk here, but that's complicated), so the primary thing you'd want to *change* in these pom.xml files are the build toolchain for those targets. For example, you might add plugin executions for your CI workflow on builds targeting these particular platforms. +A project without these modules builds every platform from the common module. To customize one platform's build, add its module: a `/pom.xml` copied from a project generated with `-DplatformModules=all`. The common module stops building that platform as soon as the module exists. + **** [#maven-dependency-example] diff --git a/maven/cn1app-archetype/pom.xml b/maven/cn1app-archetype/pom.xml index c7ee0188195..6e10c1119c4 100644 --- a/maven/cn1app-archetype/pom.xml +++ b/maven/cn1app-archetype/pom.xml @@ -122,6 +122,29 @@ + + + + + + + + + diff --git a/maven/cn1app-archetype/src/main/resources/META-INF/archetype-post-generate.groovy b/maven/cn1app-archetype/src/main/resources/META-INF/archetype-post-generate.groovy index 82ab2e07087..2923300a864 100644 --- a/maven/cn1app-archetype/src/main/resources/META-INF/archetype-post-generate.groovy +++ b/maven/cn1app-archetype/src/main/resources/META-INF/archetype-post-generate.groovy @@ -7,23 +7,34 @@ setupModules(rootPom); def resolvedJava = resolveJavaVersion(rootDir); applyJavaVersionTransforms(rootDir, rootPom, resolvedJava); +def projectType = (request.getProperties().getProperty("projectType", "app") ?: "app").trim() +def platformModules = (request.getProperties().getProperty("platformModules", "none") ?: "none").trim() +if (!(projectType in ["app", "app-with-backend", "backend-only"])) { + throw new IllegalArgumentException("projectType must be app, app-with-backend or backend-only, not '" + + projectType + "'") +} +def keptModules = parsePlatformModules(platformModules) +if (projectType == "backend-only") { + if (!keptModules.isEmpty()) { + throw new IllegalArgumentException("A backend-only project has no platform modules; drop -DplatformModules") + } + assembleBackendOnly(rootDir, request) +} else { + pruneModules(rootDir, rootPom, keptModules, projectType == "app-with-backend") +} +deleteRecursively(new java.io.File(rootDir, ".cn1-backend-only")) + /** * There are a few scripts that need to be executable (or should be) */ +["mvnw", "run.sh", "build.sh"].each { name -> + def script = new java.io.File(rootDir, name) + if (script.exists()) { + script.setExecutable(true, false) + } +} -// The maven wrapper scripts should be executable -def mvnw = new java.io.File(rootDir, "mvnw") -mvnw.setExecutable(true, false) - -// run.sh should be executable -def runSh = new java.io.File(rootDir, "run.sh") -runSh.setExecutable(true, false) - -// The build.sh should be executable -def buildSh = new java.io.File(rootDir, "build.sh") -buildSh.setExecutable(true, false) - -if (request.getProperties().getProperty("ide", null) == "netbeans") { +if (projectType != "backend-only" && request.getProperties().getProperty("ide", null) == "netbeans") { def netbeansDir = new java.io.File(rootDir, "tools/netbeans"); if (netbeansDir.exists()) { netbeansDir.listFiles().each { @@ -182,6 +193,134 @@ def deleteRecursively(file) { file.delete() } +/** + * The platform modules a project is generated with. The default, none, is the + * minimal layout: common builds every platform itself (see the cn1-host-* + * profiles in common/pom.xml), and a module can be added later. "all" is the + * full multi-module layout; a comma-separated list keeps just those. + */ +def parsePlatformModules(value) { + def all = ["javase", "android", "ios", "javascript", "win", "linux"] + if (value == "" || value == "none") { + return [] + } + if (value == "all") { + return all + } + def out = [] + value.split(",").each { raw -> + def id = raw.trim() + if (id.length() == 0) { + return + } + if (!(id in all)) { + throw new IllegalArgumentException("platformModules: unknown platform '" + id + + "'; expected none, all, or a list of " + all.join(",")) + } + out << id + } + return out +} + +/** + * Removes the platform modules the project was not asked to keep, and the + * backend module unless it was. A module that is gone is simply not in the + * reactor: each root profile also needs //pom.xml. The javase + * profile's activeByDefault goes with the javase module, because Maven applies + * it even when the profile's own conditions fail. + */ +def pruneModules(rootDir, rootPom, keptModules, keepBackend) { + def javase = new java.io.File(rootDir, "javase") + if (!("javase" in keptModules) && javase.exists()) { + // The packaged desktop app's native theme moves to common, where the + // desktop goals look for it when there is no javase module. + def theme = new java.io.File(javase, "src/desktop/resources") + if (theme.isDirectory()) { + def dest = new java.io.File(rootDir, "common/src/desktop/resources") + dest.mkdirs() + theme.listFiles().each { f -> + if (f.isFile()) { + java.nio.file.Files.copy(f.toPath(), new java.io.File(dest, f.getName()).toPath(), + java.nio.file.StandardCopyOption.REPLACE_EXISTING) + } + } + } + def content = rootPom.text.replaceAll(/\n[ \t]*true<\/activeByDefault>/, "") + rootPom.newWriter("UTF-8").withWriter { w -> w << content } + } + ["javase", "android", "ios", "javascript", "win", "linux"].each { id -> + if (!(id in keptModules)) { + def dir = new java.io.File(rootDir, id) + if (dir.exists()) { + deleteRecursively(dir) + } + } + } + if (!keepBackend) { + def backend = new java.io.File(rootDir, "backend") + if (backend.exists()) { + deleteRecursively(backend) + } + } +} + +/** + * Turns the generated tree into a backend-only project: one module, the server, + * at the root. Its files are the ones the initializr and the Gradle generators + * use, staged into .cn1-backend-only/ when the archetype is built. + */ +def assembleBackendOnly(rootDir, request) { + def staged = new java.io.File(rootDir, ".cn1-backend-only") + def pkg = request.getPackage() + def props = request.getProperties() + def maven = { String text -> + text.replace("./gradlew __BACKEND__runBackend", "./mvnw cn1:backend") + .replace("./gradlew __BACKEND__backendPackage", "./mvnw cn1:backend-package") + .replace("under `runBackend`", "under `cn1:backend`") + .replace("\${package}", pkg) + } + def pom = new java.io.File(staged, "backend-only-pom.xml").getText("UTF-8") + .replace("com.example.myapp", "" + request.getGroupId() + "") + .replace("myappname", request.getArtifactId()) + .replace("1.0-SNAPSHOT", "" + request.getVersion() + "") + .replace("8.0-SNAPSHOT", + "" + props.getProperty("cn1PluginVersion") + "") + .replace("8.0-SNAPSHOT", + "" + props.getProperty("cn1Version") + "") + + def keep = ["pom.xml", "mvnw", "mvnw.cmd", ".mvn", ".gitignore", ".cn1-backend-only"] as Set + rootDir.listFiles().each { f -> + if (!(f.getName() in keep)) { + deleteRecursively(f) + } + } + new java.io.File(rootDir, "pom.xml").newWriter("UTF-8").withWriter { w -> w << pom } + ["application.properties", "application-dev.properties"].each { name -> + new java.io.File(rootDir, name).newWriter("UTF-8").withWriter { w -> + w << maven(new java.io.File(staged, name + ".txt").getText("UTF-8")) + } + } + def srcDir = new java.io.File(rootDir, "src/main/java/" + pkg.replace('.', '/')) + srcDir.mkdirs() + ["Api", "Greeter"].each { name -> + new java.io.File(srcDir, name + ".java").newWriter("UTF-8").withWriter { w -> + w << maven(new java.io.File(staged, name + ".java.txt").getText("UTF-8")) + } + } + def gitignore = new java.io.File(rootDir, ".gitignore") + if (!gitignore.exists()) { + gitignore.newWriter("UTF-8").withWriter { w -> w << "target/\n" } + } + new java.io.File(rootDir, "README.md").newWriter("UTF-8").withWriter { w -> + w << "# " + request.getArtifactId() + "\n\n" + + "A Codename One backend. The routes are the @RestController classes under src/main/java.\n\n" + + " ./mvnw cn1:backend # run it on this JVM\n" + + " CN1_PROFILE=dev ./mvnw cn1:backend # with application-dev.properties\n" + + " ./mvnw cn1:backend-package # build a single native binary\n\n" + + "Settings are read from application.properties, beside this file.\n" + } +} + def setupModules(pomFile) { def content = pomFile.text; def modulesPos = content.indexOf(""); diff --git a/maven/cn1app-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml b/maven/cn1app-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml index 9fb192cc5a0..66a8f12b38f 100644 --- a/maven/cn1app-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml +++ b/maven/cn1app-archetype/src/main/resources/META-INF/maven/archetype-metadata.xml @@ -22,6 +22,22 @@ auto + + + none + + + + app + + ^[A-Z][a-zA-Z0-9]+$ @@ -67,6 +83,18 @@ filtered="false" — skill markdown has $foo placeholders that Velocity would otherwise misinterpret. --> + + + .cn1-backend-only + + ** + + .agent-skills diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/README.adoc b/maven/cn1app-archetype/src/main/resources/archetype-resources/README.adoc index 2c1ecc4a5c3..49bd59214a8 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/README.adoc +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/README.adoc @@ -47,6 +47,10 @@ CodeRAD includes an annotation processor that needs to be activated. There are a See https://github.com/codenameone/CodenameOne/issues/3724[this issue] for more details. +== Project Layout + +The app lives in the `common` module. Platform modules (`javase`, `android`, `ios`, `javascript`, `win`, `linux`) are optional: when a platform has no module, `common` builds it, so `./build.sh` and `./run.sh` work either way. Native interface implementations go under `/src/main/...` (for example `android/src/main/java`), which `mvn cn1:generate-native-interfaces` creates when needed. Add a `/pom.xml` only if that platform needs its own build plugins or dependencies. + == NetBeans Users This project is a multi-module Maven project that was generated from a Maven archetype. diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/common/pom.xml b/maven/cn1app-archetype/src/main/resources/archetype-resources/common/pom.xml index dcadd0d70f1..964f8a0508a 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/common/pom.xml +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/common/pom.xml @@ -23,10 +23,9 @@ Test-scope deps needed if you add JUnit 5 tests under src/test/java that drive the simulator via com.codename1.testing.junit.CodenameOneTest. Surefire stays - skipped at this module (see skipTests on surefire below); - JUnit tests actually run from the javase module, which - inherits these sources via testSourceDirectory. They live - here only so common can compile the test sources. + skipped at this module while a javase module exists (see + skipTests on surefire below); that module then runs them, + through testSourceDirectory. Without one they run here. Apps that prefer the legacy AbstractTest / cn1:test flow can delete both blocks; they are inert unless a test imports @@ -226,21 +225,7 @@ - - - - simulator - - javase - - - + true diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/javase/pom.xml b/maven/cn1app-archetype/src/main/resources/archetype-resources/javase/pom.xml index 0b106709568..8feac721cb1 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/javase/pom.xml +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/javase/pom.xml @@ -22,6 +22,22 @@ ${project.basedir}/../common/src/test/java + + org.apache.maven.plugins + maven-surefire-plugin + + + + org.apache.maven.surefire + surefire-junit-platform + 3.0.0-M5 + + + codenameone-maven-plugin com.codenameone diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/pom.xml b/maven/cn1app-archetype/src/main/resources/archetype-resources/pom.xml index dc94b22dc95..6c77f19a20f 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/pom.xml +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/pom.xml @@ -171,6 +171,12 @@ + javascript @@ -179,6 +185,9 @@ codename1.platform javascript + + ${basedir}/javascript/pom.xml + javascript @@ -191,6 +200,9 @@ codename1.platform ios + + ${basedir}/ios/pom.xml + ios @@ -203,6 +215,9 @@ codename1.platform win + + ${basedir}/win/pom.xml + win @@ -215,6 +230,9 @@ codename1.platform linux + + ${basedir}/linux/pom.xml + linux @@ -234,6 +252,9 @@ codename1.platform backend + + ${basedir}/backend/pom.xml + backend @@ -246,6 +267,9 @@ codename1.platform android + + ${basedir}/android/pom.xml + android @@ -258,6 +282,9 @@ codename1.platform javase + + ${basedir}/javase/pom.xml + true diff --git a/maven/cn1app-archetype/src/test/resources/projects/auto-jdk/archetype.properties b/maven/cn1app-archetype/src/test/resources/projects/auto-jdk/archetype.properties index 70358376f5d..7bbd0c8121c 100644 --- a/maven/cn1app-archetype/src/test/resources/projects/auto-jdk/archetype.properties +++ b/maven/cn1app-archetype/src/test/resources/projects/auto-jdk/archetype.properties @@ -8,3 +8,5 @@ cn1PluginVersion=8.0-SNAPSHOT mainName=HelloWorldAutoJdk kotlinVersion=1.4.32 javaVersion=auto +platformModules=none +projectType=app diff --git a/maven/cn1app-archetype/src/test/resources/projects/basic/archetype.properties b/maven/cn1app-archetype/src/test/resources/projects/basic/archetype.properties index 39822b0e834..ebb3a928c42 100644 --- a/maven/cn1app-archetype/src/test/resources/projects/basic/archetype.properties +++ b/maven/cn1app-archetype/src/test/resources/projects/basic/archetype.properties @@ -8,3 +8,5 @@ cn1PluginVersion=8.0-SNAPSHOT mainName=HelloWorldBasic kotlinVersion=1.4.32 javaVersion=8 +platformModules=all +projectType=app-with-backend diff --git a/maven/cn1app-archetype/src/test/resources/projects/netbeans/archetype.properties b/maven/cn1app-archetype/src/test/resources/projects/netbeans/archetype.properties index 9c2d2f685dc..26b08a94877 100644 --- a/maven/cn1app-archetype/src/test/resources/projects/netbeans/archetype.properties +++ b/maven/cn1app-archetype/src/test/resources/projects/netbeans/archetype.properties @@ -9,3 +9,5 @@ mainName=HelloWorldBasic kotlinVersion=1.4.32 javaVersion=8 ide=netbeans +platformModules=all +projectType=app-with-backend diff --git a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/AppSupport.java b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/AppSupport.java index d52be6ca9d5..73239a27de7 100644 --- a/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/AppSupport.java +++ b/maven/codenameone-gradle-plugin/src/main/java/com/codename1/gradle/AppSupport.java @@ -524,8 +524,9 @@ private static Object platformSources(ProjectLayout layout, String platform, Sou // engine skips a classpath element that does not exist. Beside the native // sources, the platform's resources -- a Maven platform module's // src/main/resources, which the conversion moves to src//resources. - return java.util.Arrays.asList(layout.nativeSourceDir(p), - new File(layout.projectDir(), "src" + File.separator + p.id() + File.separator + "resources")); + // The Maven plugin uploads the same pair when common builds a platform + // that has no module of its own. + return layout.platformUploadDirs(p); } /// The native platform whose implementations a build platform needs; the diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/AbstractCN1Mojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/AbstractCN1Mojo.java index 2171fb0f4b1..e28f916f26b 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/AbstractCN1Mojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/AbstractCN1Mojo.java @@ -136,6 +136,71 @@ public abstract class AbstractCN1Mojo extends AbstractMojo { protected Properties properties; + /** + * The platform whose module this goal stands in for, when it is bound in an + * application's {@code common/pom.xml} rather than in the platform module. Set only + * in the pom, never from the command line. The goal then runs only when the + * platform has no module of its own; see {@link HostedPlatforms}. + */ + @Parameter + protected String hostedPlatform; + + private boolean hosting; + + /** + * Whether this goal is doing the work of a platform module the application does + * not have, from {@code common}. + */ + protected boolean isHosting() { + return hosting; + } + + /** + * Where {@code compile-javase-natives} compiles the JavaSE native sources of an + * application without a {@code javase} module. + */ + protected File hostedNativesDir() { + return new File(project.getBuild().getDirectory(), HostedPlatforms.JAVASE_NATIVES_DIR); + } + + /** + * Where {@code generate-desktop-app-wrapper} writes the desktop app's icons and + * properties for an application without a {@code javase} module. + */ + protected File hostedDesktopResourcesDir() { + return new File(project.getBuild().getDirectory(), HostedPlatforms.DESKTOP_RESOURCES_DIR); + } + + /** + * Applies {@link #hostedPlatform}: records the decision for the executions that + * read {@link HostedPlatforms#skipProperty(String)} and answers whether the goal + * should run at all. + */ + private boolean decideHosting() { + if (hostedPlatform == null || hostedPlatform.trim().length() == 0) { + return true; + } + String platform = hostedPlatform.trim(); + hosting = HostedPlatforms.shouldHost(project.getBasedir(), getCN1ProjectDir(), platform); + project.getProperties().setProperty(HostedPlatforms.skipProperty(platform), String.valueOf(!hosting)); + if (!hosting) { + getLog().debug("Skipping: " + platform + " is built by its own module, not by " + project.getArtifactId()); + } + return hosting; + } + + /** + * Whether this module is the first of the projects this build runs, for goals that + * act on the whole application and must run once however many modules are selected. + */ + protected boolean isFirstProjectOfTheBuild() { + MavenSession s = getSession(); + if (s == null || s.getProjects() == null || s.getProjects().isEmpty()) { + return true; + } + return s.getProjects().get(0) == project; + } + protected long getSourcesModificationTime() throws IOException { return getSourcesModificationTime(false); } @@ -548,6 +613,9 @@ protected void overlayCommandLineBuildHints(Properties target) { @Override public void execute() throws MojoExecutionException, MojoFailureException { + if (!decideHosting()) { + return; + } if (getCN1ProjectDir() != null) { properties = new Properties(); File cn1Properties = new File(getCN1ProjectDir(), "codenameone_settings.properties"); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/AndroidOnDeviceDebuggingMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/AndroidOnDeviceDebuggingMojo.java index e0f3d531ca5..216ac8a224d 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/AndroidOnDeviceDebuggingMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/AndroidOnDeviceDebuggingMojo.java @@ -6,6 +6,19 @@ * 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; @@ -310,6 +323,8 @@ private File resolveApk(File rootProjectDir) { List roots = new ArrayList<>(); File androidModule = new File(rootProjectDir, "android"); if (androidModule.isDirectory()) roots.add(new File(androidModule, "target")); + // An application without an android module builds Android from common. + roots.add(new File(new File(rootProjectDir, "common"), "target")); roots.add(new File(rootProjectDir, "target")); roots.add(rootProjectDir); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java index 0671ffc7feb..08fc7a548a7 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java @@ -189,6 +189,43 @@ protected void mergeAnnotationBuildHints(Properties target, List classpa protected void overlayCommandLineBuildHints(Properties target) { CN1BuildMojo.this.overlayCommandLineBuildHints(target); } + + /** + * When {@code common} builds a platform the application has no module for, the + * platform's own files ride the upload beside {@code common}'s classpath, exactly as + * that module would have packaged them: the compiled JavaSE natives for the desktop + * targets, and otherwise the native sources and resources directories. Elements + * that do not exist are skipped by the engine. + */ + @Override + protected List compileClasspathElements() throws Exception { + List elements = super.compileClasspathElements(); + if (!isHosting()) { + return elements; + } + List out = new ArrayList(elements); + out.addAll(hostedUploadElements(projectHost().layout(), hostedPlatform.trim(), hostedNativesDir())); + return out; + } + } + + /** + * The extra classpath elements a build from {@code common} uploads for {@code platform}. + */ + static List hostedUploadElements(com.codename1.project.ProjectLayout layout, String platform, + File javaseNatives) { + List out = new ArrayList(); + if ("javase".equals(platform)) { + out.add(javaseNatives.getAbsolutePath()); + return out; + } + com.codename1.project.NativePlatform p = com.codename1.project.NativePlatform.fromId(platform); + if (p != null) { + for (File dir : layout.platformUploadDirs(p)) { + out.add(dir.getAbsolutePath()); + } + } + return out; } @Override diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1DebugMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1DebugMojo.java index 212f8e544ba..8a8e051a115 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1DebugMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1DebugMojo.java @@ -51,9 +51,15 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException if (commonDir == null) { return; } + if (!isFirstProjectOfTheBuild()) { + // Every module of the reactor resolves the same common directory; one nested + // run is the simulator, a second would be another window. The first project, + // not the execution root: `mvn -pl common cn1:run` selects common alone. + return; + } File rootMavenProjectDir = commonDir.getParentFile(); - File javaSEDir = new File(rootMavenProjectDir, "javase"); - if (!javaSEDir.exists()) { + // The javase module is optional: without one, common runs the simulator itself. + if (!new File(rootMavenProjectDir, "pom.xml").isFile()) { return; } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1RunMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1RunMojo.java index bd5260c0185..b5e0d850705 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1RunMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1RunMojo.java @@ -51,9 +51,15 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException if (commonDir == null) { return; } + if (!isFirstProjectOfTheBuild()) { + // Every module of the reactor resolves the same common directory; one nested + // run is the simulator, a second would be another window. The first project, + // not the execution root: `mvn -pl common cn1:run` selects common alone. + return; + } File rootMavenProjectDir = commonDir.getParentFile(); - File javaSEDir = new File(rootMavenProjectDir, "javase"); - if (!javaSEDir.exists()) { + // The javase module is optional: without one, common runs the simulator itself. + if (!new File(rootMavenProjectDir, "pom.xml").isFile()) { return; } @@ -77,6 +83,10 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException // nested build also has to inherit -Dmaven.repo.local or it resolves // against a different repository than the one this build installed into. forwardLocalRepository(props); + // What activates the simulator's module, or common's stand-in for it. + if (props.getProperty("codename1.platform") == null) { + props.setProperty("codename1.platform", "javase"); + } request.setProperties(props); request.setBaseDirectory(rootMavenProjectDir); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CompileJavaSENativesMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CompileJavaSENativesMojo.java new file mode 100644 index 00000000000..8642952ef06 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CompileJavaSENativesMojo.java @@ -0,0 +1,293 @@ +/* + * 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.project.NativePlatform; +import com.codename1.project.ProjectLayout; +import org.apache.commons.io.FileUtils; +import org.apache.maven.model.Plugin; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.LifecyclePhase; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.ResolutionScope; +import org.apache.tools.ant.taskdefs.Expand; +import org.codehaus.plexus.util.xml.Xpp3Dom; + +import javax.tools.JavaCompiler; +import javax.tools.ToolProvider; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static com.codename1.maven.PathUtil.path; + +/** + * Compiles an application's JavaSE native code when it has no {@code javase} module. + * + *

In the full layout the {@code javase} module compiles {@code javase/src/main/java} + * (the simulator's native-interface implementations), the {@code nativese} sources of its + * cn1libs and the desktop stub, against the JavaSE port. An application without that + * module still has all of them -- {@code generate-native-interfaces} writes the stubs to + * {@code javase/src/main/java} whether or not a pom sits beside it -- and this goal compiles + * them from {@code common} into {@code target/cn1-javase/classes}. The simulator, the JUnit + * tests, the desktop app and the desktop builds put that directory on their classpath.

+ * + *

Never into {@code target/classes}: that directory is what every device build uploads, + * and an Android or iOS build must not carry Swing code. Nor through the main compile, whose + * bytecode-compliance check would reject the JDK APIs native code exists to call.

+ */ +@Mojo(name = "compile-javase-natives", defaultPhase = LifecyclePhase.PROCESS_CLASSES, + requiresDependencyResolution = ResolutionScope.TEST, threadSafe = true) +public class CompileJavaSENativesMojo extends AbstractCN1Mojo { + + @Override + protected void executeImpl() throws MojoExecutionException, MojoFailureException { + if (!isHosting()) { + if (hostedPlatform == null) { + getLog().warn("compile-javase-natives needs javase; skipping."); + } + return; + } + ProjectLayout layout = projectHost().layout(); + File out = hostedNativesDir(); + File buildDir = new File(project.getBuild().getDirectory()); + + List classpath = new ArrayList(); + File cn1libSources = new File(buildDir, path("generated-sources", "cn1libs-javase")); + try { + String testOutput = new File(project.getBuild().getTestOutputDirectory()).getAbsolutePath(); + for (String element : project.getTestClasspathElements()) { + File f = new File(element); + if (f.getAbsolutePath().equals(testOutput)) { + continue; + } + if ("nativese.zip".equals(f.getName())) { + extract(f, cn1libSources); + continue; + } + classpath.add(element); + } + } catch (Exception ex) { + throw new MojoExecutionException("Failed to resolve the test classpath", ex); + } + + List roots = new ArrayList(); + roots.add(layout.nativeSourceDir(NativePlatform.JAVASE)); + roots.add(cn1libSources); + roots.add(new File(buildDir, path("generated-sources", "cn1-desktop"))); + roots.add(new File(layout.rootDir(), path("javase", "src", "desktop", "java"))); + List sources = new ArrayList(); + for (File root : roots) { + collectJavaFiles(root, sources); + } + Collections.sort(sources); + File resources = layout.nativeResourcesDir(NativePlatform.JAVASE); + + if (sources.isEmpty() && !resources.isDirectory()) { + if (out.exists()) { + FileUtils.deleteQuietly(out); + } + getLog().debug("No JavaSE native sources to compile"); + return; + } + + String[] level = sourceLevel(); + File stamp = new File(out.getParentFile(), "inputs.txt"); + String inputs = describeInputs(sources, classpath, level); + if (isUpToDate(stamp, inputs, sources, resources)) { + getLog().debug("JavaSE native code is up to date at " + out); + return; + } + + FileUtils.deleteQuietly(out); + if (!out.mkdirs() && !out.isDirectory()) { + throw new MojoExecutionException("Could not create " + out); + } + try { + if (resources.isDirectory()) { + FileUtils.copyDirectory(resources, out); + } + } catch (IOException ex) { + throw new MojoExecutionException("Failed to copy " + resources + " to " + out, ex); + } + if (!sources.isEmpty()) { + compile(sources, classpath, out, level); + } + try { + FileUtils.writeStringToFile(stamp, inputs, StandardCharsets.UTF_8); + } catch (IOException ex) { + throw new MojoExecutionException("Failed to write " + stamp, ex); + } + getLog().info("Compiled " + sources.size() + " JavaSE native source file(s) into " + out); + } + + private void compile(List sources, List classpath, File out, String[] level) + throws MojoExecutionException { + JavaCompiler javac = ToolProvider.getSystemJavaCompiler(); + if (javac == null) { + throw new MojoExecutionException("No Java compiler is available to compile the JavaSE native sources. " + + "Run Maven on a JDK, not a JRE."); + } + List args = new ArrayList(); + args.add("-d"); + args.add(out.getAbsolutePath()); + args.add("-encoding"); + args.add("UTF-8"); + if (level[0] != null) { + args.add("--release"); + args.add(level[0]); + } else { + args.add("-source"); + args.add(level[1]); + args.add("-target"); + args.add(level[2]); + } + args.add("-nowarn"); + args.add("-cp"); + StringBuilder cp = new StringBuilder(); + for (String element : classpath) { + if (cp.length() > 0) { + cp.append(File.pathSeparator); + } + cp.append(element); + } + args.add(cp.toString()); + for (File source : sources) { + args.add(source.getAbsolutePath()); + } + ByteArrayOutputStream err = new ByteArrayOutputStream(); + int result = javac.run(null, null, err, args.toArray(new String[args.size()])); + String output = new String(err.toByteArray(), StandardCharsets.UTF_8).trim(); + if (result != 0) { + throw new MojoExecutionException("Compiling the JavaSE native sources failed:\n" + output); + } + if (output.length() > 0) { + getLog().debug(output); + } + } + + /** + * The level the application compiles at, read from its maven-compiler-plugin settings so + * the native code matches it: {@code [release, source, target]}, release null unless set. + */ + private String[] sourceLevel() { + String release = project.getProperties().getProperty("maven.compiler.release"); + String source = project.getProperties().getProperty("maven.compiler.source", "1.8"); + String target = project.getProperties().getProperty("maven.compiler.target", source); + for (Plugin plugin : project.getBuildPlugins()) { + if (!"maven-compiler-plugin".equals(plugin.getArtifactId()) + || !(plugin.getConfiguration() instanceof Xpp3Dom)) { + continue; + } + Xpp3Dom config = (Xpp3Dom) plugin.getConfiguration(); + release = valueOf(config, "release", release); + source = valueOf(config, "source", source); + target = valueOf(config, "target", target); + } + return new String[] {release, source, target}; + } + + private static String valueOf(Xpp3Dom config, String name, String fallback) { + Xpp3Dom child = config.getChild(name); + if (child == null || child.getValue() == null) { + return fallback; + } + String v = child.getValue().trim(); + return v.length() == 0 || v.startsWith("${") ? fallback : v; + } + + static String describeInputs(List sources, List classpath, String[] level) { + StringBuilder sb = new StringBuilder(); + sb.append("level=").append(level[0]).append('/').append(level[1]).append('/').append(level[2]).append('\n'); + for (String element : classpath) { + sb.append("cp=").append(element).append('\n'); + } + for (File source : sources) { + sb.append("src=").append(source.getAbsolutePath()).append('\n'); + } + return sb.toString(); + } + + /** + * Up to date when the last compile saw exactly these inputs and nothing it read has + * changed since: no source or resource is newer than the stamp, and no classpath + * element either (a rebuilt {@code target/classes} changes what the natives link to). + */ + static boolean isUpToDate(File stamp, String inputs, List sources, File resources) { + if (!stamp.isFile()) { + return false; + } + try { + if (!inputs.equals(FileUtils.readFileToString(stamp, StandardCharsets.UTF_8))) { + return false; + } + } catch (IOException ex) { + return false; + } + long stamped = stamp.lastModified(); + for (File source : sources) { + if (source.lastModified() > stamped) { + return false; + } + } + if (resources.isDirectory() && lastModifiedRecursive(resources) > stamped) { + return false; + } + for (String line : inputs.split("\n")) { + if (line.startsWith("cp=")) { + File element = new File(line.substring(3)); + long modified = element.isDirectory() ? lastModifiedRecursive(element) : element.lastModified(); + if (modified > stamped) { + return false; + } + } + } + return true; + } + + private void extract(File zip, File dest) { + Expand unzip = (Expand) antProject.createTask("unzip"); + unzip.setSrc(zip); + unzip.setDest(dest); + unzip.execute(); + } + + private static void collectJavaFiles(File dir, List out) { + File[] children = dir.listFiles(); + if (children == null) { + return; + } + for (File child : children) { + if (child.isDirectory()) { + collectJavaFiles(child, out); + } else if (child.getName().endsWith(".java")) { + out.add(child); + } + } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateAppProjectMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateAppProjectMojo.java index 57377dd29b3..c6f217739ed 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateAppProjectMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateAppProjectMojo.java @@ -80,6 +80,23 @@ public class GenerateAppProjectMojo extends AbstractMojo { @Parameter(property = "cn1.buildTool", defaultValue = "maven") private String buildTool; + /** + * The platform modules to generate, passed to cn1app-archetype: {@code none} (its + * default, a root pom and {@code common/} only), {@code all}, or a comma-separated + * list such as {@code javase,android}. Native code from the source project lands in + * {@code /src/main/...} either way; without a module, {@code common} + * builds that platform and picks it up from there. + */ + @Parameter(property = "platformModules") + private String platformModules; + + /** + * {@code app} (the archetype's default) or {@code app-with-backend}, passed to + * cn1app-archetype. + */ + @Parameter(property = "projectType") + private String projectType; + private Properties loadSourceProjectProperties() throws IOException { Properties props = new Properties(); @@ -134,6 +151,17 @@ private void generateProject() throws MojoExecutionException{ } } + if (platformModules != null && platformModules.trim().length() > 0) { + props.setProperty("platformModules", platformModules.trim()); + } + if (projectType != null && projectType.trim().length() > 0) { + if ("backend-only".equals(projectType.trim())) { + throw new MojoExecutionException("generate-app-project converts an application; " + + "projectType=backend-only has nothing to convert."); + } + props.setProperty("projectType", projectType.trim()); + } + if (getLog().isDebugEnabled()) { request.setDebug(true); } @@ -685,7 +713,8 @@ private void copyJavaseFiles() { for (File child : resDir.listFiles()) { if (child.getName().endsWith(".jar")) { getLog().warn("Found jar file '" + child.getName() + "' in the native/javase directory. This has been copied to " + child + ", but you should " + - "remove this file and replace it with the equivalent Maven dependency inside your " + new File(targetJavaseDir(), "pom.xml") + " file."); + "remove this file and replace it with the equivalent Maven dependency (in the javase module's pom.xml, " + + "or in common/pom.xml when the project has no javase module)."); } } } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateDesktopAppWrapperMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateDesktopAppWrapperMojo.java index 169fccc6304..c831a3fc404 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateDesktopAppWrapperMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateDesktopAppWrapperMojo.java @@ -102,7 +102,7 @@ void applyAnnotationBuildHints() throws MojoFailureException { void generateThemeConfiguration() throws MojoExecutionException { Properties theme = new Properties(); theme.setProperty("desktop.themeMode", arg("desktop.themeMode", sharedThemeModeDefault())); - File output = new File(project.getBuild().getOutputDirectory(), "codenameone-desktop.properties"); + File output = new File(outputDirectory(), "codenameone-desktop.properties"); try { Files.createDirectories(output.toPath().getParent()); try (OutputStream stream = Files.newOutputStream(output.toPath())) { @@ -127,7 +127,7 @@ private void generateIcons() throws MojoExecutionException { getLog().warn("Icon file "+iconFile+" not found. Skipping desktop app icon generation."); return; } - File outputDir = new File(project.getBuild().getOutputDirectory()); + File outputDir = outputDirectory(); if (!outputDir.exists()) { outputDir.mkdirs(); } @@ -144,9 +144,26 @@ private void generateIcons() throws MojoExecutionException { } } + /// Where the icons and the desktop properties go: the module's classes, or, when + /// `common` stands in for a missing `javase` module, a directory of their own that + /// the desktop jar and the desktop run add. Never `common`'s own classes, which + /// every device build uploads. + private File outputDirectory() { + return isHosting() ? hostedDesktopResourcesDir() : new File(project.getBuild().getOutputDirectory()); + } + + /// The directory a hand-written stub is read from: `src/desktop/java` of the + /// `javase` module, which a hosted build finds at the same place under the root. + private File customStubSourceRoot() { + File base = isHosting() ? new File(getCN1ProjectDir().getParentFile(), "javase") : project.getBasedir(); + return new File(base, path("src", "desktop", "java")); + } + private void registerCustomStubSourceRoot() { - File wrapperSources = new File(project.getBasedir(), path("src", "desktop", "java")); - if (wrapperSources.exists()) { + File wrapperSources = customStubSourceRoot(); + // A hosted build compiles the stub with the rest of the JavaSE native code + // (compile-javase-natives); in common's own compile it would need the JavaSE port. + if (wrapperSources.exists() && !isHosting()) { project.addCompileSourceRoot(wrapperSources.getAbsolutePath()); } } @@ -163,8 +180,7 @@ private void generateStub() throws MojoExecutionException { // treat it as a full override and skip generation - the source root that // registerCustomStubSourceRoot adds will pick it up. String packagePath = packageName.replace('.', File.separatorChar); - File customStub = new File(project.getBasedir(), - path("src", "desktop", "java", packagePath, mainName + "Stub.java")); + File customStub = new File(customStubSourceRoot(), path(packagePath, mainName + "Stub.java")); if (customStub.exists()) { getLog().info("Custom desktop stub found at " + customStub.getAbsolutePath() + " - skipping generation."); return; @@ -195,7 +211,9 @@ private void generateStub() throws MojoExecutionException { throw new MojoExecutionException("Failed to write generated desktop stub " + generatedStub, ex); } - project.addCompileSourceRoot(generatedRoot.getAbsolutePath()); + if (!isHosting()) { + project.addCompileSourceRoot(generatedRoot.getAbsolutePath()); + } } // package-private for unit testing the build-hint -> generated-stub substitution diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/HostedPlatformMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/HostedPlatformMojo.java new file mode 100644 index 00000000000..f058a29d485 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/HostedPlatformMojo.java @@ -0,0 +1,54 @@ +/* + * 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.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.LifecyclePhase; +import org.apache.maven.plugins.annotations.Mojo; + +import java.io.File; + +/** + * Announces, early in the build, that {@code common} is building a platform the + * application has no module for, and records that decision (see + * {@link HostedPlatforms#skipProperty(String)}) before the executions that read it. + * + *

Bound at {@code validate} in each {@code cn1-host-} profile of a + * minimal application's {@code common/pom.xml}. It also keeps such a pom from + * running against a plugin too old to understand it: that plugin has no goal of this + * name and fails the build at once, instead of silently building nothing.

+ */ +@Mojo(name = "hosted-platform", defaultPhase = LifecyclePhase.VALIDATE, threadSafe = true) +public class HostedPlatformMojo extends AbstractCN1Mojo { + @Override + protected void executeImpl() throws MojoExecutionException, MojoFailureException { + if (!isHosting()) { + // No hostedPlatform configured: nothing to decide. + return; + } + File root = getCN1ProjectDir().getParentFile(); + getLog().info("Building " + hostedPlatform.trim() + " from " + project.getArtifactId() + ": " + + new File(root, hostedPlatform.trim()) + " has no pom.xml of its own."); + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/HostedPlatforms.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/HostedPlatforms.java new file mode 100644 index 00000000000..8c6bbc52f67 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/HostedPlatforms.java @@ -0,0 +1,125 @@ +/* + * 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 java.io.File; +import java.io.IOException; +import java.util.List; + +/// Decides when an application's `common` module does a platform module's work. +/// +/// A Maven application's platform modules (`javase/`, `android/`, `ios/`, ...) +/// are optional. A project generated without them -- the minimal layout -- has +/// only the root pom and `common/`, and `common/pom.xml` carries the goals each +/// missing module would have run, every one of them configured with +/// ``. The rule both halves follow is a single file: a platform +/// has a module of its own exactly when `//pom.xml` exists. +/// +/// When it does, the module does the work as it always has, and `common`'s +/// hosted goals step aside, so a project that adds a module later -- or a full +/// layout that carries the same `common/pom.xml` -- never builds a platform twice. +/// When it does not, the goal runs in `common`. +/// +/// Third-party executions in the same profiles (exec, antrun) cannot ask; they +/// read [#skipProperty(String)], which a hosted goal earlier in the build sets. +final class HostedPlatforms { + /// Where `compile-javase-natives` puts the compiled JavaSE native code, under + /// the build directory: beside `classes`, never inside it, because + /// `target/classes` is what every device build uploads. + static final String JAVASE_NATIVES_DIR = "cn1-javase" + File.separator + "classes"; + + /// Where `generate-desktop-app-wrapper` puts the icons and desktop properties of + /// a packaged desktop app built from `common`. Apart from [#JAVASE_NATIVES_DIR], + /// which `compile-javase-natives` rebuilds from scratch later in the same build. + static final String DESKTOP_RESOURCES_DIR = "cn1-javase" + File.separator + "desktop"; + + /// The property naming the JDK argument file that holds the simulator's + /// classpath. See [PrepareSimulatorClasspathMojo]. + static final String CLASSPATH_ARG_FILE_PROPERTY = "cn1.hosted.classpathArgFile"; + + private HostedPlatforms() { + } + + /// The property a hosted goal sets to `true` when `platform` has a module of + /// its own and `false` when `common` hosts it, for the `` of the + /// executions that cannot decide for themselves. + static String skipProperty(String platform) { + return "cn1.hosted." + platform + ".skip"; + } + + /// Whether the module at `moduleDir` should do `platform`'s work. + /// + /// @param moduleDir the base directory of the module running the goal + /// @param cn1ProjectDir the application's `common` directory, or null when + /// the module is not inside an application + static boolean shouldHost(File moduleDir, File cn1ProjectDir, String platform) { + if (moduleDir == null || cn1ProjectDir == null || platform == null) { + return false; + } + if (!canonical(moduleDir).equals(canonical(cn1ProjectDir))) { + return false; + } + return !hasPlatformModule(cn1ProjectDir, platform); + } + + /// Whether the application whose `common` module is `cn1ProjectDir` has a + /// module of its own for `platform`. + static boolean hasPlatformModule(File cn1ProjectDir, String platform) { + File root = canonical(cn1ProjectDir).getParentFile(); + return root != null && new File(new File(root, platform), "pom.xml").isFile(); + } + + /// A JDK argument file (`java @file`) passing `classpath` as `-classpath`. + /// + /// The simulator is forked with exec:exec, whose `` cannot be + /// extended, and a hosted simulator needs the JavaSE natives on it too. A file + /// also keeps a long classpath clear of the Windows command-line limit. Inside + /// the quotes a backslash escapes the next character, so every one is doubled + /// and Windows paths survive. + static String classpathArgFile(List classpath) { + StringBuilder sb = new StringBuilder(); + for (String element : classpath) { + if (sb.length() > 0) { + sb.append(File.pathSeparatorChar); + } + sb.append(element); + } + StringBuilder out = new StringBuilder("-classpath\n\""); + for (int i = 0; i < sb.length(); i++) { + char c = sb.charAt(i); + if (c == '\\' || c == '"') { + out.append('\\'); + } + out.append(c); + } + return out.append("\"\n").toString(); + } + + private static File canonical(File f) { + try { + return f.getCanonicalFile(); + } catch (IOException ex) { + return f.getAbsoluteFile(); + } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/JavaSEExecutableJarMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/JavaSEExecutableJarMojo.java new file mode 100644 index 00000000000..85f1aa82def --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/JavaSEExecutableJarMojo.java @@ -0,0 +1,198 @@ +/* + * 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 org.apache.maven.artifact.Artifact; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.LifecyclePhase; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.ResolutionScope; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static com.codename1.maven.PathUtil.path; + +/** + * Packages the application as a runnable desktop jar when it has no {@code javase} module, + * the work that module's {@code executable-jar} profile does with the dependency, jar and + * antrun plugins. + * + *

The output has the names that module produces, so scripts and documentation that look + * for them still find them: {@code target/-javase-.jar}, its dependencies in + * {@code target/libs/}, and both zipped together as {@code target/-javase-.zip}. + * The jar holds {@code common}'s classes, the JavaSE natives with the desktop stub, the + * generated icons and desktop properties, and the desktop resources.

+ * + *

A goal rather than the module's plugin executions because the executable-jar profile is + * also active in {@code common} when a {@code javase} module does exist, and there + * maven-jar-plugin, which cannot be skipped, would rewrite {@code common}'s own jar.

+ */ +@Mojo(name = "javase-executable-jar", defaultPhase = LifecyclePhase.PACKAGE, + requiresDependencyResolution = ResolutionScope.RUNTIME, threadSafe = true) +public class JavaSEExecutableJarMojo extends AbstractCN1Mojo { + + @Override + protected void executeImpl() throws MojoExecutionException, MojoFailureException { + if (!isHosting()) { + return; + } + String packageName = properties.getProperty("codename1.packageName"); + String mainName = properties.getProperty("codename1.mainName"); + if (packageName == null || mainName == null) { + throw new MojoFailureException("codename1.packageName and codename1.mainName must be set in " + + "codenameone_settings.properties to build the desktop jar."); + } + File buildDir = new File(project.getBuild().getDirectory()); + String finalName = javaseFinalName(project.getArtifactId(), project.getVersion()); + File libs = new File(buildDir, "libs"); + File distDir = new File(buildDir, finalName); + File distLibs = new File(distDir, "libs"); + File jar = new File(buildDir, finalName + ".jar"); + + List classPath = new ArrayList(); + try { + FileUtils.deleteQuietly(distDir); + for (Artifact artifact : project.getArtifacts()) { + File file = artifact.getFile(); + if (file == null || !file.isFile() || !"jar".equals(artifact.getType())) { + continue; + } + FileUtils.copyFileToDirectory(file, libs); + FileUtils.copyFileToDirectory(file, distLibs); + classPath.add("libs/" + file.getName()); + } + } catch (IOException ex) { + throw new MojoExecutionException("Failed to copy the desktop app's dependencies", ex); + } + + File root = getCN1ProjectDir().getParentFile(); + List contents = new ArrayList(); + contents.add(new File(project.getBuild().getOutputDirectory())); + contents.add(hostedNativesDir()); + contents.add(hostedDesktopResourcesDir()); + contents.add(new File(getCN1ProjectDir(), path("src", "desktop", "resources"))); + contents.add(new File(root, path("javase", "src", "desktop", "resources"))); + + Manifest manifest = new Manifest(); + Attributes attributes = manifest.getMainAttributes(); + attributes.put(Attributes.Name.MANIFEST_VERSION, "1.0"); + attributes.put(Attributes.Name.MAIN_CLASS, packageName + "." + mainName + "Stub"); + attributes.put(Attributes.Name.CLASS_PATH, join(classPath)); + try { + writeJar(jar, manifest, contents); + FileUtils.copyFileToDirectory(jar, distDir); + zipDirectory(distDir, new File(buildDir, finalName + ".zip")); + } catch (IOException ex) { + throw new MojoExecutionException("Failed to write the desktop jar " + jar, ex); + } + getLog().info("Desktop app: " + jar); + } + + /** {@code myapp-common} is packaged as {@code myapp-javase-}, the javase module's name. */ + static String javaseFinalName(String commonArtifactId, String version) { + String base = commonArtifactId.endsWith("-common") + ? commonArtifactId.substring(0, commonArtifactId.length() - "-common".length()) + : commonArtifactId; + return base + "-javase-" + version; + } + + private static String join(List parts) { + StringBuilder sb = new StringBuilder(); + for (String p : parts) { + if (sb.length() > 0) { + sb.append(' '); + } + sb.append(p); + } + return sb.toString(); + } + + /** The jar of `dirs`, earlier directories winning when two hold the same entry. */ + static void writeJar(File jar, Manifest manifest, List dirs) throws IOException { + jar.getParentFile().mkdirs(); + Set seen = new HashSet(); + seen.add("META-INF/MANIFEST.MF"); + try (JarOutputStream out = new JarOutputStream(new FileOutputStream(jar), manifest)) { + for (File dir : dirs) { + if (dir.isDirectory()) { + addTree(out, dir, "", seen); + } + } + } + } + + private static void addTree(ZipOutputStream out, File dir, String prefix, Set seen) throws IOException { + File[] children = dir.listFiles(); + if (children == null) { + return; + } + java.util.Arrays.sort(children); + for (File child : children) { + String name = prefix + child.getName(); + if (child.isDirectory()) { + if (seen.add(name + "/")) { + out.putNextEntry(new JarEntry(name + "/")); + out.closeEntry(); + } + addTree(out, child, name + "/", seen); + } else if (seen.add(name)) { + out.putNextEntry(new JarEntry(name)); + copy(child, out); + out.closeEntry(); + } + } + } + + private static void zipDirectory(File dir, File zip) throws IOException { + try (ZipOutputStream out = new ZipOutputStream(new FileOutputStream(zip))) { + addTree(out, dir, "", new HashSet()); + } + } + + private static void copy(File f, OutputStream out) throws IOException { + try (InputStream in = new FileInputStream(f)) { + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) > 0) { + out.write(buf, 0, n); + } + } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/PrepareSimulatorClasspathMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/PrepareSimulatorClasspathMojo.java index 08f560773cd..51d9d774f2a 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/PrepareSimulatorClasspathMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/PrepareSimulatorClasspathMojo.java @@ -62,17 +62,32 @@ private boolean filterByName(Artifact artifact) { private String prepareClasspath() { StringBuilder sb = new StringBuilder(); - try { - for (String el : project.getRuntimeClasspathElements()) { - if (sb.length() > 0) { - sb.append(File.pathSeparator); - } - sb.append(el); + for (String el : simulatorClasspath()) { + if (sb.length() > 0) { + sb.append(File.pathSeparator); } + sb.append(el); + } + return sb.toString(); + } + + /** + * The runtime classpath, plus -- when {@code common} hosts the simulator because the + * application has no {@code javase} module -- the compiled JavaSE natives and the + * desktop resources that module would have contributed. The natives directory is named + * even before {@code compile-javase-natives} creates it later in this same build. + */ + private List simulatorClasspath() { + List out = new ArrayList(); + try { + out.addAll(project.getRuntimeClasspathElements()); } catch (Exception ex) { getLog().error("Failed to get runtime classpath elementes", ex); } - return sb.toString(); + if (isHosting()) { + out.add(hostedNativesDir().getAbsolutePath()); + } + return out; } @@ -135,6 +150,18 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException if ("true".equals(project.getProperties().getProperty("cn1.class.path.required"))) { project.getModel().addProperty("cn1.class.path", prepareClasspath()); } + if (isHosting()) { + // exec:exec's cannot be extended with the natives directory, so a + // hosted simulator is launched with `java @` instead. + File argFile = new File(project.getBuild().getDirectory(), + path("codenameone", "simulator-classpath.args")); + try { + FileUtils.writeStringToFile(argFile, HostedPlatforms.classpathArgFile(simulatorClasspath()), "UTF-8"); + } catch (IOException ex) { + throw new MojoExecutionException("Failed to write " + argFile, ex); + } + project.getModel().addProperty(HostedPlatforms.CLASSPATH_ARG_FILE_PROPERTY, argFile.getAbsolutePath()); + } File simulatorPropertiesFile = new File(getCN1ProjectDir(), path("target", "codenameone", "simulator.properties")); String compileClasspath = null; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/RunTestsMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/RunTestsMojo.java index 61ad9d584f5..baafff4d733 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/RunTestsMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/RunTestsMojo.java @@ -93,6 +93,10 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException List cp = new ArrayList(); cp.add(new File(project.getBuild().getTestOutputDirectory())); cp.add(new File(project.getBuild().getOutputDirectory())); + if (isHosting()) { + // common stands in for a missing javase module: its JavaSE natives. + cp.add(hostedNativesDir()); + } for (Artifact artifact : project.getArtifacts()) { if ("provided".equals(artifact.getScope())) { continue; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/SimulatorMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/SimulatorMojo.java index 0ec1fa2114a..d358da09411 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/SimulatorMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/SimulatorMojo.java @@ -89,17 +89,19 @@ public void executeImpl() throws MojoExecutionException, MojoFailureException { } File javaseProject = new File(canonicalCn1Project.getParentFile(), "javase"); - if (!javaseProject.exists()) { - throw new MojoExecutionException("JavaSE sibling project is required in order to run the simulator goal."); - } + boolean javaseModule = new File(javaseProject, "pom.xml").isFile(); InvocationRequest request = new DefaultInvocationRequest(); //request.setPomFile( new File( "/path/to/pom.xml" ) ); request.setGoals( Collections.singletonList( "verify" ) ); - request.setBaseDirectory(javaseProject); + // Without a javase module, common runs the simulator itself, from the root. + request.setBaseDirectory(javaseModule ? javaseProject : canonicalCn1Project.getParentFile()); // The command line reaches the nested build, which is where // prepare-simulator-classpath publishes it onward to the simulator JVM. Properties props = nestedBuildProperties( getSession() == null ? null : getSession().getUserProperties()); + if (!javaseModule && props.getProperty("codename1.platform") == null) { + props.setProperty("codename1.platform", "javase"); + } forwardLocalRepository(props); request.setProperties(props); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/UpdateCodenameOneMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/UpdateCodenameOneMojo.java index 1501a653a43..ac31fc18c69 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/UpdateCodenameOneMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/UpdateCodenameOneMojo.java @@ -137,6 +137,20 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException if (newVersion != null && !newVersion.isEmpty() && (!newVersion.equals(existingCn1Version) || !newVersion.equals(existingCn1PluginVersion))) { getLog().info("Attempting to update project to version " + newVersion); + File commonPom = getCN1ProjectDir() == null ? null : new File(getCN1ProjectDir(), "pom.xml"); + if (commonPom != null && commonPom.isFile()) { + String commonPomText; + try { + commonPomText = new String(java.nio.file.Files.readAllBytes(commonPom.toPath()), "UTF-8"); + } catch (IOException ex) { + throw new MojoExecutionException("Failed to read " + commonPom, ex); + } + if (tooOldForHostedPlatforms(newVersion, commonPomText)) { + throw new MojoFailureException("This project builds its platforms from common (its pom binds " + + "the hosted-platform goals), which plugin " + newVersion + " does not have. " + + "Use " + HOSTED_PLATFORMS_SINCE + " or newer."); + } + } //MavenXpp3Reader pomReader = new MavenXpp3Reader(); Model model = null; @@ -290,6 +304,24 @@ private static boolean wouldDowngrade(String candidate, String current) { return new ComparableVersion(candidate).compareTo(new ComparableVersion(current)) < 0; } + /** The first plugin release with the goals a minimal project's common pom binds. */ + static final String HOSTED_PLATFORMS_SINCE = "7.0.275"; + + /** + * @return true if {@code candidate} is a release too old for a common pom that binds the + * hosted-platform goals ({@code compile-javase-natives}, {@code hosted-platform}): that + * plugin would fail every build on an unknown goal. A -SNAPSHOT candidate is a + * development build and is not refused. + */ + static boolean tooOldForHostedPlatforms(String candidate, String commonPom) { + if (candidate == null || candidate.endsWith("-SNAPSHOT") || commonPom == null + || (!commonPom.contains("compile-javase-natives") + && !commonPom.contains("hosted-platform"))) { + return false; + } + return new ComparableVersion(candidate).compareTo(new ComparableVersion(HOSTED_PLATFORMS_SINCE)) < 0; + } + /** Host of a metadata URL, for both the containment test and the message. */ private static String repositoryHost(String url) { try { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HostedPlatformsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HostedPlatformsTest.java new file mode 100644 index 00000000000..c516b377a3f --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HostedPlatformsTest.java @@ -0,0 +1,257 @@ +/* + * 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.project.ProjectLayout; +import com.codename1.project.ProjectLayouts; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +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.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The minimal Maven layout: an application whose platform modules are absent, built by + * {@code common}. See {@link HostedPlatforms}. + */ +class HostedPlatformsTest { + private static final String[] PLATFORMS = {"javase", "android", "ios", "javascript", "win", "linux"}; + + @TempDir + File tmp; + + private File app() throws IOException { + File root = new File(tmp, "app"); + write(new File(root, "pom.xml"), ""); + write(new File(root, "common/pom.xml"), ""); + write(new File(root, "common/codenameone_settings.properties"), ""); + return root; + } + + private static void write(File f, String text) throws IOException { + f.getParentFile().mkdirs(); + Files.write(f.toPath(), text.getBytes(StandardCharsets.UTF_8)); + } + + @Test + void commonHostsAPlatformExactlyWhenItHasNoPom() throws IOException { + File root = app(); + File common = new File(root, "common"); + assertTrue(HostedPlatforms.shouldHost(common, common, "android")); + // Native sources without a pom are still hosted by common. + new File(root, "android/src/main/java").mkdirs(); + assertTrue(HostedPlatforms.shouldHost(common, common, "android")); + write(new File(root, "android/pom.xml"), ""); + assertFalse(HostedPlatforms.shouldHost(common, common, "android"), + "a module of its own does the work; common must not build it twice"); + assertTrue(HostedPlatforms.shouldHost(common, common, "ios")); + } + + @Test + void onlyCommonEverHosts() throws IOException { + File root = app(); + File common = new File(root, "common"); + assertFalse(HostedPlatforms.shouldHost(root, common, "android"), "the root aggregator"); + assertFalse(HostedPlatforms.shouldHost(new File(root, "javase"), common, "javase")); + assertFalse(HostedPlatforms.shouldHost(common, null, "android"), "not inside an application"); + } + + @Test + void theArgumentFileQuotesAndEscapes() { + String args = HostedPlatforms.classpathArgFile(Arrays.asList( + "C:\\Users\\Jo Smith\\.m2\\core.jar", "/tmp/a \"b\"/classes")); + assertEquals("-classpath\n\"C:\\\\Users\\\\Jo Smith\\\\.m2\\\\core.jar" + File.pathSeparator + + "/tmp/a \\\"b\\\"/classes\"\n", args); + } + + @Test + void anUpdateBelowTheHostedGoalsIsRefusedForAProjectThatUsesThem() { + String hosted = "compile-javase-natives"; + assertTrue(UpdateCodenameOneMojo.tooOldForHostedPlatforms("7.0.274", hosted)); + assertFalse(UpdateCodenameOneMojo.tooOldForHostedPlatforms("7.0.275", hosted)); + assertFalse(UpdateCodenameOneMojo.tooOldForHostedPlatforms("7.1.0", hosted)); + assertFalse(UpdateCodenameOneMojo.tooOldForHostedPlatforms("7.0.200-SNAPSHOT", hosted), + "a development build is not refused"); + assertFalse(UpdateCodenameOneMojo.tooOldForHostedPlatforms("7.0.200", "css"), + "a project without the goals can move to any version"); + assertTrue(UpdateCodenameOneMojo.tooOldForHostedPlatforms("7.0.100", + "hosted-platform")); + } + + @Test + void theDesktopJarKeepsTheJavaseModulesName() { + assertEquals("myapp-javase-1.0", JavaSEExecutableJarMojo.javaseFinalName("myapp-common", "1.0")); + assertEquals("other-javase-1.0", JavaSEExecutableJarMojo.javaseFinalName("other", "1.0")); + } + + @Test + void aHostedBuildUploadsWhatTheModuleWouldHavePackaged() throws IOException { + File root = app(); + ProjectLayout layout = ProjectLayouts.detect(new File(root, "common")); + File natives = new File(root, "common/target/cn1-javase/classes"); + assertEquals(Collections.singletonList(natives.getAbsolutePath()), + CN1BuildMojo.hostedUploadElements(layout, "javase", natives)); + List android = CN1BuildMojo.hostedUploadElements(layout, "android", natives); + assertEquals(2, android.size()); + assertTrue(android.get(0).endsWith("android" + File.separator + "src" + File.separator + "main" + + File.separator + "java"), android.get(0)); + assertTrue(android.get(1).endsWith("android" + File.separator + "src" + File.separator + "main" + + File.separator + "resources"), android.get(1)); + assertTrue(CN1BuildMojo.hostedUploadElements(layout, "ios", natives).get(0) + .endsWith("objectivec")); + assertTrue(CN1BuildMojo.hostedUploadElements(layout, "nosuch", natives).isEmpty()); + } + + @Test + void nativesRecompileWhenAnythingTheyReadChanges() throws Exception { + File src = new File(tmp, "HelloImpl.java"); + write(src, "class HelloImpl {}"); + File cp = new File(tmp, "classes"); + cp.mkdirs(); + File resources = new File(tmp, "resources"); + List sources = Collections.singletonList(src); + String inputs = CompileJavaSENativesMojo.describeInputs(sources, + Collections.singletonList(cp.getAbsolutePath()), new String[] {null, "1.8", "1.8"}); + File stamp = new File(tmp, "inputs.txt"); + assertFalse(CompileJavaSENativesMojo.isUpToDate(stamp, inputs, sources, resources), "never compiled"); + write(stamp, inputs); + long now = System.currentTimeMillis(); + stamp.setLastModified(now); + src.setLastModified(now - 10000); + cp.setLastModified(now - 10000); + assertTrue(CompileJavaSENativesMojo.isUpToDate(stamp, inputs, sources, resources)); + src.setLastModified(now + 10000); + assertFalse(CompileJavaSENativesMojo.isUpToDate(stamp, inputs, sources, resources), "an edited source"); + src.setLastModified(now - 10000); + String moreSources = CompileJavaSENativesMojo.describeInputs(Arrays.asList(src, new File(tmp, "B.java")), + Collections.singletonList(cp.getAbsolutePath()), new String[] {null, "1.8", "1.8"}); + assertFalse(CompileJavaSENativesMojo.isUpToDate(stamp, moreSources, sources, resources), "a new source"); + File appClass = new File(cp, "App.class"); + write(appClass, "x"); + appClass.setLastModified(now + 10000); + assertFalse(CompileJavaSENativesMojo.isUpToDate(stamp, inputs, sources, resources), + "the application classes the natives link against changed"); + } + + /** + * The fragment both generators put in common/pom.xml. Each hosted goal must carry + * {@code } and every other plugin's execution must read the skip property + * one of them sets, or a project with the module would run that work twice; each + * build profile must switch on for its own platform only while that module is absent. + */ + @Test + void theHostedProfilesStepAsideForAModule() throws Exception { + File fragment = new File("../../scripts/initializr/common/src/main/resources/common-hosted-platform-profiles.xml"); + String xml = new String(Files.readAllBytes(fragment.toPath()), StandardCharsets.UTF_8); + Element profiles = DocumentBuilderFactory.newInstance().newDocumentBuilder() + .parse(new ByteArrayInputStream(("" + xml + "").getBytes(StandardCharsets.UTF_8))) + .getDocumentElement(); + Set ids = new HashSet(); + Set hostProfiles = new HashSet(); + for (Element profile : children(profiles, "profile")) { + String id = text(profile, "id"); + assertTrue(ids.add(id), "duplicate profile " + id); + Element activation = child(profile, "activation"); + if (id.startsWith("cn1-host-")) { + String platform = id.substring("cn1-host-".length()); + hostProfiles.add(platform); + assertEquals(platform, text(child(activation, "property"), "value"), id); + assertEquals("${basedir}/../" + platform + "/pom.xml", text(child(activation, "file"), "missing"), id); + } + Element build = child(profile, "build"); + Element plugins = build == null ? null : child(build, "plugins"); + if (plugins == null) { + continue; + } + for (Element plugin : children(plugins, "plugin")) { + Element executions = child(plugin, "executions"); + if (executions == null) { + continue; + } + boolean ours = "codenameone-maven-plugin".equals(text(plugin, "artifactId")); + for (Element execution : children(executions, "execution")) { + Element config = child(execution, "configuration"); + assertNotNull(config, id + "/" + text(execution, "id") + " has no configuration"); + if (ours) { + assertNotNull(text(config, "hostedPlatform"), id + "/" + text(execution, "id") + + " runs even when the platform has a module of its own"); + } else { + String skip = text(config, "skip"); + assertTrue(skip != null && skip.matches("\\$\\{cn1\\.hosted\\.[a-z]+\\.skip}"), + id + "/" + text(execution, "id") + " must skip when a module does the work"); + } + } + } + } + assertEquals(new HashSet(Arrays.asList(PLATFORMS)), hostProfiles); + assertTrue(ids.contains("simulator")); + + // The archetype replaces its own simulator profile with this fragment; a second + // profile of that id fails every build. + String common = new String(Files.readAllBytes(new File( + "../cn1app-archetype/src/main/resources/archetype-resources/common/pom.xml").toPath()), + StandardCharsets.UTF_8); + assertTrue(common.contains("")); + assertFalse(common.contains("simulator")); + } + + private static List children(Element parent, String name) { + List out = new ArrayList(); + NodeList nodes = parent.getChildNodes(); + for (int i = 0; i < nodes.getLength(); i++) { + Node n = nodes.item(i); + if (n instanceof Element && name.equals(((Element) n).getTagName())) { + out.add((Element) n); + } + } + return out; + } + + private static Element child(Element parent, String name) { + List c = children(parent, name); + return c.isEmpty() ? null : c.get(0); + } + + private static String text(Element parent, String name) { + Element c = child(parent, name); + return c == null ? null : c.getTextContent().trim(); + } +} diff --git a/maven/integration-tests/all.sh b/maven/integration-tests/all.sh index b2134402148..d4713ba3a72 100644 --- a/maven/integration-tests/all.sh +++ b/maven/integration-tests/all.sh @@ -12,6 +12,7 @@ bash cn1app-archetype-test.sh bash build-hint-annotations-test.sh bash cn1app-desktop-build-test.sh bash cn1app-staged-jar-test.sh +bash cn1app-minimal-layout-test.sh bash bare-bones-kotlin-test.sh bash migrate-kitchensink-test.sh bash googlemaps-demo.sh diff --git a/maven/integration-tests/android-native-interface-test.sh b/maven/integration-tests/android-native-interface-test.sh index 741cfc0d013..d7ed5ceb7f8 100644 --- a/maven/integration-tests/android-native-interface-test.sh +++ b/maven/integration-tests/android-native-interface-test.sh @@ -52,6 +52,7 @@ mkdir nativeinterfacestest2 cd nativeinterfacestest2 mvn archetype:generate \ + -DplatformModules=all -DprojectType=app-with-backend \ -DarchetypeArtifactId=cn1app-archetype \ -DarchetypeGroupId=com.codenameone \ -DarchetypeVersion=$CN1_VERSION \ diff --git a/maven/integration-tests/bare-bones-kotlin-test.sh b/maven/integration-tests/bare-bones-kotlin-test.sh index 46e07e388d2..97fbc65879d 100644 --- a/maven/integration-tests/bare-bones-kotlin-test.sh +++ b/maven/integration-tests/bare-bones-kotlin-test.sh @@ -10,6 +10,7 @@ curl -fL --retry 5 --retry-all-errors https://github.com/shannah/cn1app-archetyp unzip master.zip rm master.zip mvn com.codenameone:codenameone-maven-plugin:${CN1_VERSION}:generate-app-project \ + -DplatformModules=all -DprojectType=app-with-backend \ -DarchetypeGroupId=com.codenameone \ -DarchetypeArtifactId=cn1app-archetype \ -DarchetypeVersion=${CN1_VERSION} \ diff --git a/maven/integration-tests/build-hint-annotations-test.sh b/maven/integration-tests/build-hint-annotations-test.sh index 0a3337b3d1a..070e74dbdce 100755 --- a/maven/integration-tests/build-hint-annotations-test.sh +++ b/maven/integration-tests/build-hint-annotations-test.sh @@ -16,6 +16,7 @@ source $SCRIPTPATH/inc/env.sh cd $SCRIPTPATH/build rm -rf myapphints mvn archetype:generate \ + -DplatformModules=all -DprojectType=app-with-backend \ -DarchetypeArtifactId=cn1app-archetype \ -DarchetypeGroupId=com.codenameone \ -DarchetypeVersion=$CN1_VERSION \ diff --git a/maven/integration-tests/cn1app-archetype-test.sh b/maven/integration-tests/cn1app-archetype-test.sh index f269d41b126..7edd16f7361 100644 --- a/maven/integration-tests/cn1app-archetype-test.sh +++ b/maven/integration-tests/cn1app-archetype-test.sh @@ -16,6 +16,7 @@ if [ -d myapp1 ]; then rm -rf myapp1 fi mvn archetype:generate \ + -DplatformModules=all -DprojectType=app-with-backend \ -DarchetypeArtifactId=cn1app-archetype \ -DarchetypeGroupId=com.codenameone \ -DarchetypeVersion=$CN1_VERSION \ diff --git a/maven/integration-tests/cn1app-desktop-build-test.sh b/maven/integration-tests/cn1app-desktop-build-test.sh index 0552ef5f8ca..a41304dfef0 100644 --- a/maven/integration-tests/cn1app-desktop-build-test.sh +++ b/maven/integration-tests/cn1app-desktop-build-test.sh @@ -46,6 +46,7 @@ if [ -d myappdesktop ]; then rm -rf myappdesktop fi mvn archetype:generate \ + -DplatformModules=all -DprojectType=app-with-backend \ -DarchetypeArtifactId=cn1app-archetype \ -DarchetypeGroupId=com.codenameone \ -DarchetypeVersion=$CN1_VERSION \ diff --git a/maven/integration-tests/cn1app-minimal-layout-test.sh b/maven/integration-tests/cn1app-minimal-layout-test.sh new file mode 100755 index 00000000000..419028855bc --- /dev/null +++ b/maven/integration-tests/cn1app-minimal-layout-test.sh @@ -0,0 +1,227 @@ +#!/bin/bash +# The archetype's default Maven layouts, end to end: +# +# 1. The minimal app: a root pom and common/, no platform modules. common does +# each missing module's work (the cn1-host-* profiles and the hosted goals of +# the codenameone-maven-plugin), so this checks every path a module used to +# own: +# * a native interface whose stubs land in /src/main/... with no +# pom beside them; +# * cn1:test (-Ptest) and JUnit (mvn test) run from common and reach the +# JavaSE native code, which compile-javase-natives builds into +# target/cn1-javase/classes; +# * the simulator's argument file carries that directory; +# * -Pexecutable-jar writes the desktop jar the javase module used to; +# * every device target stages, from common, exactly the native files of +# its own platform, and the Android upload carries no JavaSE classes; +# * adding a platform module later moves that platform's build into it, +# with nothing left in common. +# 2. The backend-only project: one module at the root that builds its router and +# entry point, and packages into a native server that answers /healthz. +# +# Nothing is submitted and nothing opens a window: device builds stop at +# -Dcodename1.stageOnly, and the simulator is only prepared, never launched. +SCRIPTPATH="$( cd "$(dirname "$0")" ; pwd -P )" +set -e +source $SCRIPTPATH/inc/env.sh + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +generate() { + local name=$1 + shift + rm -rf "$name" + mvn -B -ntp archetype:generate \ + -DarchetypeArtifactId=cn1app-archetype \ + -DarchetypeGroupId=com.codenameone \ + -DarchetypeVersion=$CN1_VERSION \ + -DartifactId=$name \ + -DgroupId=com.example \ + -Dpackage=com.example.$name \ + -Dversion=1.0-SNAPSHOT \ + -DmainName=MyApp \ + -DinteractiveMode=false "$@" +} + +cd $SCRIPTPATH/build + +echo "== The minimal app layout" +generate minimal +cd minimal +for module in javase android ios javascript win linux backend; do + [ ! -e "$module" ] || fail "the minimal layout generated $module/" +done +[ -f common/pom.xml ] || fail "no common/pom.xml" +grep -q 'compile-javase-natives' common/pom.xml || fail "common/pom.xml has no hosted profiles" +if grep -q 'true' pom.xml; then + fail "the root pom still activates the missing javase module by default" +fi + +mkdir -p common/src/main/java/com/example/minimal +cat > common/src/main/java/com/example/minimal/Hello.java <<'EOF' +package com.example.minimal; + +import com.codename1.system.NativeInterface; + +public interface Hello extends NativeInterface { + String hello(); +} +EOF +mvn -B -ntp compile cn1:generate-native-interfaces +JAVASE_IMPL=javase/src/main/java/com/example/minimal/HelloImpl.java +ANDROID_IMPL=android/src/main/java/com/example/minimal/HelloImpl.java +[ -f "$JAVASE_IMPL" ] || fail "no JavaSE stub at $JAVASE_IMPL" +[ -f "$ANDROID_IMPL" ] || fail "no Android stub at $ANDROID_IMPL" +for module in javase android ios; do + [ ! -e "$module/pom.xml" ] || fail "generate-native-interfaces created $module/pom.xml" +done +# A JavaSE implementation that only a JDK can compile, so a natives classpath that +# fell back to the bytecode-compliance-checked compile would fail here. +perl -0pi -e 's/return null;/return "hi from " + javax.swing.UIManager.class.getSimpleName();/' "$JAVASE_IMPL" + +cat > common/src/test/java/com/example/minimal/NativeCallTest.java <<'EOF' +package com.example.minimal; + +import com.codename1.system.NativeLookup; +import com.codename1.testing.AbstractTest; + +public class NativeCallTest extends AbstractTest { + @Override + public boolean runTest() throws Exception { + Hello hello = NativeLookup.create(Hello.class); + String said = hello == null ? null : hello.hello(); + System.out.println("NATIVE SAID: " + said); + return "hi from UIManager".equals(said); + } +} +EOF + +echo "== cn1:test from common" +mvn -B -ntp verify -Ptest -Dcodename1.platform=javase | tee ../minimal-test.log +grep -q 'NATIVE SAID: hi from UIManager' ../minimal-test.log || fail "the JavaSE native was not reached from cn1:test" +grep -q 'NativeCallTest passed' ../minimal-test.log || fail "NativeCallTest did not pass" +[ -f common/target/cn1-javase/classes/com/example/minimal/HelloImpl.class ] \ + || fail "compile-javase-natives did not compile $JAVASE_IMPL" +[ ! -f common/target/classes/com/example/minimal/HelloImpl.class ] \ + || fail "the JavaSE native was compiled into common's own classes, which every device build uploads" + +echo "== JUnit from common" +cat > common/src/test/java/com/example/minimal/NativesJUnitTest.java <<'EOF' +package com.example.minimal; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class NativesJUnitTest { + @Test + public void theJavaseNativeIsOnTheTestClasspath() throws Exception { + Object impl = Class.forName("com.example.minimal.HelloImpl").getDeclaredConstructor().newInstance(); + assertEquals("hi from UIManager", impl.getClass().getMethod("hello").invoke(impl)); + } +} +EOF +mvn -B -ntp test +REPORT=common/target/surefire-reports/TEST-com.example.minimal.NativesJUnitTest.xml +[ -f "$REPORT" ] || fail "Surefire did not run the JUnit test" +grep -q 'tests="1"' "$REPORT" || fail "the JUnit test did not run exactly once" +grep -q 'failures="0"' "$REPORT" || fail "the JUnit test failed" + +echo "== The simulator classpath" +mvn -B -ntp initialize -Psimulator -Dcodename1.platform=javase +ARGS=common/target/codenameone/simulator-classpath.args +[ -f "$ARGS" ] || fail "prepare-simulator-classpath wrote no $ARGS" +grep -q 'cn1-javase' "$ARGS" || fail "the simulator classpath has no JavaSE natives" + +echo "== The desktop jar" +mvn -B -ntp package -Pexecutable-jar -DskipTests -Dcodename1.platform=javase +JAR=common/target/minimal-javase-1.0-SNAPSHOT.jar +[ -f "$JAR" ] || fail "no desktop jar at $JAR" +[ -f common/target/minimal-javase-1.0-SNAPSHOT.zip ] || fail "no desktop zip" +unzip -p "$JAR" META-INF/MANIFEST.MF | grep -q 'Main-Class: com.example.minimal.MyAppStub' \ + || fail "the desktop jar has the wrong Main-Class" +for entry in com/example/minimal/MyAppStub.class com/example/minimal/HelloImpl.class \ + com/example/minimal/MyApp.class NativeTheme.res codenameone-desktop.properties applicationIconImage_64x64.png; do + unzip -l "$JAR" | grep -q " $entry\$" || fail "the desktop jar has no $entry" +done +[ -f common/target/libs/codenameone-javase-$CN1_VERSION.jar ] || fail "the desktop jar's libs/ has no JavaSE port" + +echo "== Every device target, staged from common" +stage() { + local platform=$1 target=$2 + mvn -B -ntp package -DskipTests -Dcodename1.platform=$platform -Dcodename1.buildTarget=$target \ + -Dcodename1.stageOnly=true > ../stage-$target.log 2>&1 || { tail -40 ../stage-$target.log; fail "staging $target"; } + STAGED=common/target/minimal-common-1.0-SNAPSHOT-$target-jar-with-dependencies.jar + [ -f "$STAGED" ] || fail "nothing staged for $target at $STAGED" + unzip -l "$STAGED" | grep -q ' com/example/minimal/MyApp.class$' || fail "$target: the app is missing" +} +stage android android-device +unzip -l "$STAGED" | grep -q ' com/example/minimal/HelloImpl.java$' || fail "android: no native source" +if unzip -l "$STAGED" | grep -q 'HelloImpl.class$'; then + fail "android: the JavaSE native leaked into the upload" +fi +stage ios ios-device +unzip -l "$STAGED" | grep -q ' com_example_minimal_HelloImpl.m$' || fail "ios: no native source" +stage javase mac-os-x-desktop +unzip -l "$STAGED" | grep -q ' com/example/minimal/HelloImpl.class$' || fail "desktop: no compiled JavaSE native" +stage win windows-device +stage linux linux-device +stage javascript javascript + +echo "== A platform module added later takes over" +( cd "$SCRIPTPATH/build" && generate minimal-full -DplatformModules=android ) +sed 's/minimal-full/minimal/g' "$SCRIPTPATH/build/minimal-full/android/pom.xml" > android/pom.xml +rm -f common/target/*android-device-jar-with-dependencies.jar +mvn -B -ntp package -DskipTests -Dcodename1.platform=android -Dcodename1.buildTarget=android-device \ + -Dcodename1.stageOnly=true +[ -f android/target/minimal-android-1.0-SNAPSHOT-android-device-jar-with-dependencies.jar ] \ + || fail "the added android module did not stage the build" +if ls common/target/*android-device-jar-with-dependencies.jar >/dev/null 2>&1; then + fail "common built android too, beside the module" +fi +cd .. + +echo "== The backend-only layout" +generate server -DprojectType=backend-only +cd server +for entry in common javase android backend run.sh build.sh; do + [ ! -e "$entry" ] || fail "the backend-only project has $entry" +done +[ -f application.properties ] || fail "no application.properties at the root" +if grep -rqi gradle pom.xml application.properties src; then + fail "the backend-only project mentions Gradle" +fi +mvn -B -ntp process-classes +MAIN_CLASS_FILE=target/classes/META-INF/cn1-backend-main +[ -f "$MAIN_CLASS_FILE" ] || fail "no generated entry point" +GENERATED_MAIN="$(cat "$MAIN_CLASS_FILE")" +[ -f "target/classes/$(echo "$GENERATED_MAIN" | tr '.' '/').class" ] || fail "$GENERATED_MAIN was not compiled" +[ -f target/classes/com/example/server/ApiRouter.class ] || fail "no router for the @RestController" +# The native binary, from the root with no module or profile -- what the project's +# README tells its developer to run. +if ! command -v clang >/dev/null 2>&1; then + [ "${CN1_BACKEND_PACKAGE_REQUIRED:-0}" != "1" ] || fail "clang is required to package a backend" + echo "NOTE skipping cn1:backend-package: no clang on PATH" +else + mvn -B -ntp cn1:backend-package + [ -x target/server ] || fail "cn1:backend-package produced no executable at target/server" + SERVER_PORT="${CN1_BACKEND_PACKAGE_PORT:-18081}" + CN1_PROFILE=dev PORT="$SERVER_PORT" ./target/server > target/server-run.log 2>&1 & + SERVER_PID=$! + trap 'kill -9 $SERVER_PID 2>/dev/null || true' EXIT + HEALTH="" + for attempt in $(seq 1 60); do + HEALTH="$(curl -s -m 1 "http://127.0.0.1:$SERVER_PORT/healthz" || true)" + [ "$HEALTH" != "ok" ] || break + kill -0 $SERVER_PID 2>/dev/null || { cat target/server-run.log >&2; fail "the packaged server exited"; } + sleep 1 + done + kill -9 $SERVER_PID 2>/dev/null || true + trap - EXIT + [ "$HEALTH" = "ok" ] || { cat target/server-run.log >&2; fail "the packaged server did not answer /healthz"; } +fi +cd .. + +echo "PASS: the minimal and backend-only Maven layouts" diff --git a/maven/integration-tests/cn1app-staged-jar-test.sh b/maven/integration-tests/cn1app-staged-jar-test.sh index a2d5b56c5e3..4ca2a1c8ad3 100755 --- a/maven/integration-tests/cn1app-staged-jar-test.sh +++ b/maven/integration-tests/cn1app-staged-jar-test.sh @@ -53,6 +53,7 @@ if [ -d $APP ]; then rm -rf $APP fi mvn -B -ntp archetype:generate \ + -DplatformModules=all -DprojectType=app-with-backend \ -DarchetypeArtifactId=cn1app-archetype \ -DarchetypeGroupId=com.codenameone \ -DarchetypeVersion=$CN1_VERSION \ diff --git a/maven/integration-tests/cssfonts.sh b/maven/integration-tests/cssfonts.sh index f839f6d073f..9f8424f0c6d 100644 --- a/maven/integration-tests/cssfonts.sh +++ b/maven/integration-tests/cssfonts.sh @@ -11,6 +11,7 @@ curl -fL --retry 5 --retry-all-errors https://github.com/shannah/cn1-css-fonts-t unzip master.zip rm master.zip mvn com.codenameone:codenameone-maven-plugin:${CN1_VERSION}:generate-app-project \ + -DplatformModules=all -DprojectType=app-with-backend \ -DarchetypeGroupId=com.codename1 \ -DarchetypeArtifactId=cn1app-archetype \ -DarchetypeVersion=${CN1_VERSION} \ diff --git a/maven/integration-tests/googlemaps-demo.sh b/maven/integration-tests/googlemaps-demo.sh index 41f8a98664a..1b6d25c52fe 100644 --- a/maven/integration-tests/googlemaps-demo.sh +++ b/maven/integration-tests/googlemaps-demo.sh @@ -11,6 +11,7 @@ curl -fL --retry 5 --retry-all-errors https://github.com/shannah/googlemaps-mave unzip master.zip rm master.zip mvn -e com.codenameone:codenameone-maven-plugin:${CN1_VERSION}:generate-app-project \ + -DplatformModules=all -DprojectType=app-with-backend \ -DarchetypeGroupId=com.codename1 \ -DarchetypeArtifactId=cn1app-archetype \ -DarchetypeVersion=${CN1_VERSION} \ diff --git a/maven/integration-tests/gradle-maven-parity-test.sh b/maven/integration-tests/gradle-maven-parity-test.sh index 8be29183677..791cb873a51 100755 --- a/maven/integration-tests/gradle-maven-parity-test.sh +++ b/maven/integration-tests/gradle-maven-parity-test.sh @@ -23,6 +23,7 @@ cd "$WORKDIR" PKG=com.acme.parity PKG_PATH=com/acme/parity mvn_local archetype:generate -DarchetypeArtifactId=cn1app-archetype -DarchetypeGroupId=com.codenameone \ + -DplatformModules=all -DprojectType=app-with-backend \ -DarchetypeVersion="$CN1_VERSION" -DartifactId=parity -DgroupId=$PKG -Dpackage=$PKG \ -Dversion=1.0-SNAPSHOT -DmainName=Parity -DjavaVersion=17 -DinteractiveMode=false > "$WORKDIR/archetype.log" 2>&1 \ || { tail -40 "$WORKDIR/archetype.log"; fail "archetype:generate"; } diff --git a/maven/integration-tests/gradle-migrate-test.sh b/maven/integration-tests/gradle-migrate-test.sh index 5a435358d17..158897cae7e 100755 --- a/maven/integration-tests/gradle-migrate-test.sh +++ b/maven/integration-tests/gradle-migrate-test.sh @@ -161,6 +161,7 @@ echo " Ant app converted, built with its Kotlin, staged, and its Kotlin test r echo "== Maven to Gradle, with a backend of its own" rm -rf mvnapp mvn_local archetype:generate -DarchetypeArtifactId=cn1app-archetype -DarchetypeGroupId=com.codenameone \ + -DplatformModules=all -DprojectType=app-with-backend \ -DarchetypeVersion="$CN1_VERSION" -DartifactId=mvnapp -DgroupId=com.acme.mvnapp -Dpackage=com.acme.mvnapp \ -Dversion=1.0-SNAPSHOT -DmainName=MvnApp -DjavaVersion=17 -DinteractiveMode=false > "$WORKDIR/archetype.log" 2>&1 \ || { tail -40 "$WORKDIR/archetype.log"; fail "archetype:generate"; } diff --git a/maven/integration-tests/initializr-roundtrip-test.sh b/maven/integration-tests/initializr-roundtrip-test.sh index cc1bd31f6fb..9dbdbbab028 100644 --- a/maven/integration-tests/initializr-roundtrip-test.sh +++ b/maven/integration-tests/initializr-roundtrip-test.sh @@ -20,6 +20,7 @@ mkdir -p "$SOURCE_WORKDIR" "$GENERATED_WORKDIR" cd "$SOURCE_WORKDIR" mvn archetype:generate \ + -DplatformModules=all -DprojectType=app-with-backend \ -DarchetypeArtifactId=cn1app-archetype \ -DarchetypeGroupId=com.codenameone \ -DarchetypeVersion="$CN1_VERSION" \ @@ -37,6 +38,7 @@ EOF cd "$GENERATED_WORKDIR" mvn "com.codenameone:codenameone-maven-plugin:${CN1_VERSION}:generate-app-project" \ + -DplatformModules=all -DprojectType=app-with-backend \ -DarchetypeGroupId=com.codenameone \ -DarchetypeArtifactId=cn1app-archetype \ -DarchetypeVersion="${CN1_VERSION}" \ diff --git a/maven/integration-tests/migrate-googlemapsdemo-test.sh b/maven/integration-tests/migrate-googlemapsdemo-test.sh index f0a6b330182..398f6aff1c5 100644 --- a/maven/integration-tests/migrate-googlemapsdemo-test.sh +++ b/maven/integration-tests/migrate-googlemapsdemo-test.sh @@ -11,6 +11,7 @@ curl -fL --retry 5 --retry-all-errors https://github.com/codenameone/codenameone unzip master.zip rm master.zip mvn com.codenameone:codenameone-maven-plugin:${CN1_VERSION}:generate-app-project \ + -DplatformModules=all -DprojectType=app-with-backend \ -DarchetypeGroupId=com.codename1 \ -DarchetypeArtifactId=cn1app-archetype \ -DarchetypeVersion=${CN1_VERSION} \ diff --git a/maven/integration-tests/migrate-kitchensink-test.sh b/maven/integration-tests/migrate-kitchensink-test.sh index a9e4396e293..eee1e8e972a 100644 --- a/maven/integration-tests/migrate-kitchensink-test.sh +++ b/maven/integration-tests/migrate-kitchensink-test.sh @@ -11,6 +11,7 @@ curl -fL --retry 5 --retry-all-errors https://github.com/codenameone/KitchenSink unzip master.zip rm master.zip mvn com.codenameone:codenameone-maven-plugin:${CN1_VERSION}:generate-app-project \ + -DplatformModules=all -DprojectType=app-with-backend \ -DarchetypeGroupId=com.codename1 \ -DarchetypeArtifactId=cn1app-archetype \ -DarchetypeVersion=${CN1_VERSION} \ diff --git a/maven/integration-tests/native-interfaces.sh b/maven/integration-tests/native-interfaces.sh index 116163e4186..9260c88f86d 100644 --- a/maven/integration-tests/native-interfaces.sh +++ b/maven/integration-tests/native-interfaces.sh @@ -11,6 +11,7 @@ curl -fL --retry 5 --retry-all-errors https://github.com/shannah/cn1-native-inte unzip master.zip rm master.zip mvn com.codenameone:codenameone-maven-plugin:${CN1_VERSION}:generate-app-project \ + -DplatformModules=all -DprojectType=app-with-backend \ -DarchetypeGroupId=com.codename1 \ -DarchetypeArtifactId=cn1app-archetype \ -DarchetypeVersion=${CN1_VERSION} \ diff --git a/maven/integration-tests/validate_generated_repositories.py b/maven/integration-tests/validate_generated_repositories.py index d5871432ec8..b856ee066cc 100644 --- a/maven/integration-tests/validate_generated_repositories.py +++ b/maven/integration-tests/validate_generated_repositories.py @@ -37,11 +37,13 @@ codenameone-maven-plugin, which reads as a corrupt install rather than a missing repository. -The three scaffolds must agree; they are the only ways a user gets a new project: +The scaffolds must agree; they are the only ways a user gets a new project: - 1. cn1app-archetype -- mvn archetype:generate / cn1:generate-app-project - 2. cn1lib-archetype -- library projects - 3. common.zip -- the Initializr root pom (start.codenameone.com) + 1. cn1app-archetype -- mvn archetype:generate / cn1:generate-app-project + 2. cn1lib-archetype -- library projects + 3. common.zip -- the Initializr root pom (start.codenameone.com) + 4. backend-only-pom.xml -- the root pom of a backend-only project, from both the + Initializr and the app archetype """ from pathlib import Path @@ -64,6 +66,7 @@ LIB_ARCHETYPE_POM = "maven/cn1lib-archetype/src/main/resources/archetype-resources/pom.xml" INITIALIZR_ZIP = "scripts/initializr/common/src/main/resources/common.zip" INITIALIZR_ZIP_POM = "pom.xml" +BACKEND_ONLY_POM = "scripts/initializr/common/src/main/resources/backend-only-pom.xml" def fail(message): @@ -105,10 +108,10 @@ def validate_pom(label, data): def main(): repo_root = Path(__file__).resolve().parents[2] - for relative_path in (APP_ARCHETYPE_POM, LIB_ARCHETYPE_POM): + for relative_path in (APP_ARCHETYPE_POM, LIB_ARCHETYPE_POM, BACKEND_ONLY_POM): pom_path = repo_root / relative_path if not pom_path.is_file(): - fail("archetype POM not found: " + str(pom_path)) + fail("scaffold POM not found: " + str(pom_path)) validate_pom(relative_path, pom_path.read_bytes()) archive_path = repo_root / INITIALIZR_ZIP diff --git a/maven/project-model/src/main/java/com/codename1/project/ProjectLayout.java b/maven/project-model/src/main/java/com/codename1/project/ProjectLayout.java index 721e4c64abb..1c26bbfd601 100644 --- a/maven/project-model/src/main/java/com/codename1/project/ProjectLayout.java +++ b/maven/project-model/src/main/java/com/codename1/project/ProjectLayout.java @@ -45,6 +45,10 @@ /// | CSS | `css/` | `common/src/main/css` | `src/main/css` | /// | native Android | `native/android` | `android/src/main/java` | `src/android/java` | /// | build output | `build/` | `common/target/` | `build/` | +/// +/// A Maven application's platform modules (`android/`, `ios/`, `javase/`, ...) +/// are optional: see [hasPlatformModule(NativePlatform)]. A Maven backend-only +/// project is a single module whose [rootDir()] and [projectDir()] are the same. public final class ProjectLayout { /// The settings file of an application. public static final String SETTINGS_FILE = "codenameone_settings.properties"; @@ -340,6 +344,46 @@ public File nativeSourceDir(NativePlatform platform) { } } + /// The directory of `platform`'s own resources, beside its native sources: + /// `//src/main/resources` under Maven, `src//resources` + /// under Gradle, and the native directory itself under Ant. Returned whether + /// or not it exists. + public File nativeResourcesDir(NativePlatform platform) { + switch (buildSystem) { + case ANT: + return nativeSourceDir(platform); + case MAVEN: + return file(rootDir, platform.id(), "src", "main", "resources"); + default: + return file(projectDir, "src", platform.id(), "resources"); + } + } + + /// The directories of `platform`'s sources that ride a build's upload as + /// resources, exactly as a Maven platform module packages them: the native + /// sources and the platform's resources. Both are returned whether or not + /// they exist yet, so one created later is not missed; a consumer skips the + /// ones that do not. + public List platformUploadDirs(NativePlatform platform) { + List out = new ArrayList(); + out.add(nativeSourceDir(platform)); + File resources = nativeResourcesDir(platform); + if (!out.contains(resources)) { + out.add(resources); + } + return out; + } + + /// Whether `platform` is built by a module of its own: under Maven, whether + /// `//pom.xml` exists. The module is optional. When it is + /// absent the application's `common` module builds that platform itself and + /// picks up whatever native sources sit in [nativeSourceDir(NativePlatform)], + /// which can exist without a pom -- `generate-native-interfaces` creates it on + /// demand. Always false for Gradle and Ant, which have no platform modules. + public boolean hasPlatformModule(NativePlatform platform) { + return buildSystem == BuildSystem.MAVEN && file(rootDir, platform.id(), "pom.xml").isFile(); + } + /// [nativeSourceDir(NativePlatform)], created if missing. public File ensureNativeSourceDir(NativePlatform platform) { File dir = nativeSourceDir(platform); diff --git a/maven/project-model/src/main/java/com/codename1/project/ProjectLayouts.java b/maven/project-model/src/main/java/com/codename1/project/ProjectLayouts.java index 85d29b7d6af..b094d02f450 100644 --- a/maven/project-model/src/main/java/com/codename1/project/ProjectLayouts.java +++ b/maven/project-model/src/main/java/com/codename1/project/ProjectLayouts.java @@ -147,6 +147,18 @@ private static ProjectLayout detectAt(File dir, File origin) { return new ProjectLayout(BuildSystem.MAVEN, settings.isFile() ? ProjectKind.APP : ProjectKind.LIB, dir, dir, null); } + if (isMavenBackend(dir)) { + File parent = dir.getParentFile(); + if (parent != null && new File(parent, "pom.xml").isFile() + && (new File(parent, "common/" + ProjectLayout.SETTINGS_FILE).isFile() + || new File(parent, "common/" + ProjectLayout.LIBRARY_SETTINGS_FILE).isFile())) { + // The backend module of an application: the walk continues to + // the application root, where mavenLayout() answers BACKEND. + return null; + } + // A backend-only project: one module, the backend itself, at the root. + return new ProjectLayout(BuildSystem.MAVEN, ProjectKind.BACKEND, dir, dir, null); + } return null; } @@ -173,6 +185,15 @@ private static ProjectLayout mavenLayout(File root, File common, File origin) { return new ProjectLayout(BuildSystem.MAVEN, kind, root, common, null); } + /// Whether `dir` is a Codename One backend built by Maven: its configuration + /// file beside a pom that depends on the backend runtime. The dependency is + /// what tells it apart from any other server that keeps an + /// `application.properties`, a Spring Boot project for one. + private static boolean isMavenBackend(File dir) { + return new File(dir, ProjectLayout.BACKEND_SETTINGS_FILE).isFile() + && readQuietly(new File(dir, "pom.xml")).contains("codenameone-backend"); + } + private static ProjectLayout gradleLayout(File root, File origin) { File backend = new File(root, "backend"); if (isInside(origin, backend) && new File(backend, ProjectLayout.BACKEND_SETTINGS_FILE).isFile()) { diff --git a/maven/project-model/src/test/java/com/codename1/project/ProjectLayoutsTest.java b/maven/project-model/src/test/java/com/codename1/project/ProjectLayoutsTest.java index 8a1f26d1c4e..4f7257ef9c2 100644 --- a/maven/project-model/src/test/java/com/codename1/project/ProjectLayoutsTest.java +++ b/maven/project-model/src/test/java/com/codename1/project/ProjectLayoutsTest.java @@ -130,6 +130,81 @@ void mavenPathsMatchTheArchetype() throws IOException { assertEquals(new File(common, "target/css/theme.css.merged"), l.cssMergeFile(l.themeCss())); } + private static final String BACKEND_POM = "" + + "com.codenameonecodenameone-backend" + + ""; + + @Test + void minimalMavenAppIsAnAppAndHostsEveryPlatform() throws IOException { + File root = new File(tmp, "minimal"); + touch(root, "pom.xml"); + touch(root, "common/pom.xml"); + touch(root, "common/codenameone_settings.properties"); + dir(root, "common/src/main/java/com/example"); + ProjectLayout l = ProjectLayouts.detect(new File(root, "common/src/main/java/com/example")); + assertEquals(BuildSystem.MAVEN, l.buildSystem()); + assertEquals(ProjectKind.APP, l.kind()); + assertEquals(new File(root, "common").getCanonicalFile(), l.projectDir()); + for (NativePlatform p : NativePlatform.values()) { + assertFalse(l.hasPlatformModule(p), p.id()); + } + // Native sources written later without a pom are still sources common hosts, + // not a module. + dir(root, "android/src/main/java"); + assertFalse(l.hasPlatformModule(NativePlatform.ANDROID)); + assertEquals(Arrays.asList(new File(root, "android/src/main/java").getCanonicalFile(), + new File(root, "android/src/main/resources").getCanonicalFile()), + l.platformUploadDirs(NativePlatform.ANDROID)); + touch(root, "android/pom.xml"); + assertTrue(l.hasPlatformModule(NativePlatform.ANDROID)); + } + + @Test + void fullMavenAppHasItsModulesAndGradleHasNone() throws IOException { + ProjectLayout maven = ProjectLayouts.detect(mavenApp()); + assertTrue(maven.hasPlatformModule(NativePlatform.ANDROID)); + assertFalse(maven.hasPlatformModule(NativePlatform.IOS)); + ProjectLayout gradle = ProjectLayouts.detect(gradleApp()); + assertFalse(gradle.hasPlatformModule(NativePlatform.ANDROID)); + assertEquals(new File(gradle.projectDir(), "src/android/resources"), + gradle.nativeResourcesDir(NativePlatform.ANDROID)); + } + + @Test + void mavenBackendOnlyRootIsABackend() throws IOException { + File root = new File(tmp, "server"); + touch(root, "pom.xml", BACKEND_POM); + touch(root, "application.properties"); + dir(root, "src/main/java/com/example"); + ProjectLayout l = ProjectLayouts.detect(new File(root, "src/main/java/com/example")); + assertNotNull(l); + assertEquals(BuildSystem.MAVEN, l.buildSystem()); + assertEquals(ProjectKind.BACKEND, l.kind()); + assertEquals(root.getCanonicalFile(), l.rootDir()); + assertEquals(root.getCanonicalFile(), l.projectDir()); + assertEquals(new File(root, "application.properties").getCanonicalFile(), l.settingsFile()); + assertEquals(root.getCanonicalFile(), l.backendDir()); + } + + @Test + void aBackendModuleWithItsOwnDependencyStillBelongsToTheApp() throws IOException { + File root = mavenApp(); + touch(root, "backend/pom.xml", BACKEND_POM); + ProjectLayout l = ProjectLayouts.detect(new File(root, "backend/src/main/java")); + assertEquals(ProjectKind.BACKEND, l.kind()); + assertEquals(root.getCanonicalFile(), l.rootDir()); + assertEquals(new File(root, "backend").getCanonicalFile(), l.projectDir()); + } + + @Test + void aServerThatIsNotOursIsNotClaimed() throws IOException { + File root = new File(tmp, "spring"); + touch(root, "pom.xml", "spring-boot-starter-web"); + touch(root, "application.properties"); + dir(root, "src/main/java"); + assertNull(ProjectLayouts.detect(new File(root, "src/main/java"))); + } + @Test void gradleAppIsNotMistakenForAnt() throws IOException { File root = gradleApp().getCanonicalFile(); diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java index 08c3e5dcb50..80553233353 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java @@ -118,6 +118,7 @@ public void runApp() { final ProjectOptions.JavaVersion[] javaVersion = new ProjectOptions.JavaVersion[]{ProjectOptions.JavaVersion.JAVA_17}; final ProjectOptions.BuildTool[] buildTool = new ProjectOptions.BuildTool[]{ProjectOptions.BuildTool.MAVEN}; final ProjectOptions.ProjectType[] projectType = new ProjectOptions.ProjectType[]{ProjectOptions.ProjectType.APP}; + final boolean[] allPlatformModules = new boolean[]{false}; // {Java 17, Java 8}: the build panel disables Java 8 while Gradle is selected. final RadioButton[] javaButtons = new RadioButton[2]; final SpanLabel summaryLabel = new SpanLabel(); @@ -146,7 +147,8 @@ public void runApp() { final Runnable refresh = new Runnable() { public void run() { ProjectOptions options = currentOptions(includeLocalizationBundles, previewLanguage, javaVersion) - .withBuild(buildTool[0], projectType[0]); + .withBuild(buildTool[0], projectType[0]) + .withPlatformModules(allPlatformModules[0]); previewPanel.setTemplate(selectedTemplate[0]); previewPanel.setOptions(options); @@ -156,6 +158,7 @@ public void run() { : "No bundles"); javaSubtitle.setText(javaVersion[0].label); buildSubtitle.setText(buildTool[0] == ProjectOptions.BuildTool.GRADLE + || GeneratorModel.isMavenLayoutChoiceOffered() ? buildTool[0].label + " . " + projectType[0].label : buildTool[0].label); @@ -181,7 +184,8 @@ public void run() { Container javaPanel = makePanel("Java Version", javaSubtitle, true, false, createJavaOptionsPanel(javaVersion, javaButtons, refresh), form); Container buildPanel = makePanel("Build Tool", buildSubtitle, true, false, - createBuildToolPanel(buildTool, projectType, javaVersion, javaButtons, refresh), form); + createBuildToolPanel(buildTool, projectType, allPlatformModules, javaVersion, javaButtons, refresh), + form); Container settingsPanel = makePanel("Current Settings", panelSubtitle("Generated artifacts"), true, true, BoxLayout.encloseY(summaryLabel), form); @@ -189,9 +193,11 @@ public void run() { Container previewWrap = createPreviewWrap(previewPanel); // Gradle is offered once the plugin is published at the version the - // generated projects use; until then the Maven default stands alone. - buildPanel.setHidden(!GeneratorModel.isGradleOffered()); - buildPanel.setVisible(GeneratorModel.isGradleOffered()); + // generated projects use, and the Maven project types once that plugin can + // build them; with neither, the Maven default stands alone. + boolean buildChoices = GeneratorModel.isGradleOffered() || GeneratorModel.isMavenLayoutChoiceOffered(); + buildPanel.setHidden(!buildChoices); + buildPanel.setVisible(buildChoices); Container column = BoxLayout.encloseY(hero, essentials, idePanel, buildPanel, localePanel, javaPanel, settingsPanel, previewWrap); column.setUIID("InitializrColumn"); @@ -217,7 +223,8 @@ public void run() { String appName = appNameField.getText() == null ? "" : appNameField.getText().trim(); String packageName = packageField.getText() == null ? "" : packageField.getText().trim(); ProjectOptions options = downloadOptions(includeLocalizationBundles, previewLanguage, javaVersion) - .withBuild(buildTool[0], projectType[0]); + .withBuild(buildTool[0], projectType[0]) + .withPlatformModules(allPlatformModules[0]); GeneratorModel.create(selectedIde[0], selectedTemplate[0], appName, packageName, options).generate(); }); @@ -456,18 +463,42 @@ private Container createJavaOptionsPanel(ProjectOptions.JavaVersion[] javaVersio return selector; } - /// Maven or Gradle, and for Gradle what the project holds. Gradle projects are - /// Java 17 only, so choosing Gradle selects Java 17 and disables Java 8. A - /// Maven project always carries the backend module behind a profile, so the - /// project type is only offered for Gradle. + /// Maven or Gradle, and what the project holds. Gradle projects are Java 17 + /// only, so choosing Gradle selects Java 17 and disables Java 8. A Maven project + /// gets the project type, and the choice of every platform module over the + /// minimal layout, once the plugin it is generated against can build them + /// ([GeneratorModel#isMavenLayoutChoiceOffered()]); before that it always + /// carries the backend module behind a profile and the type is Gradle's alone. private Container createBuildToolPanel(ProjectOptions.BuildTool[] buildTool, ProjectOptions.ProjectType[] projectType, + boolean[] allPlatformModules, ProjectOptions.JavaVersion[] javaVersion, RadioButton[] javaButtons, Runnable onSelectionChanged) { - Container tools = new Container(new GridLayout(1, 2)); + final boolean mavenLayouts = GeneratorModel.isMavenLayoutChoiceOffered(); + Container tools = new Container(new GridLayout(1, GeneratorModel.isGradleOffered() ? 2 : 1)); tools.setUIID("InitializrChoicesGrid"); ButtonGroup toolGroup = new ButtonGroup(); + final CheckBox allModules = new CheckBox("Include all platform modules (javase/, android/, ios/, ...)"); + allModules.setUIID("InitializrChoice"); + allModules.setSelected(allPlatformModules[0]); + allModules.addActionListener(evt -> { + allPlatformModules[0] = allModules.isSelected(); + onSelectionChanged.run(); + }); + final Container modulesSection = BoxLayout.encloseY(allModules); + final Runnable updateModules = () -> { + boolean show = mavenLayouts && buildTool[0] == ProjectOptions.BuildTool.MAVEN + && projectType[0] != ProjectOptions.ProjectType.BACKEND_ONLY; + modulesSection.setHidden(!show); + modulesSection.setVisible(show); + if (!show && allPlatformModules[0] && projectType[0] == ProjectOptions.ProjectType.BACKEND_ONLY) { + // A backend-only project has no platform modules to include. + allPlatformModules[0] = false; + allModules.setSelected(false); + } + }; + Container types = new Container(new GridLayout(3, 1)); types.setUIID("InitializrChoicesGrid"); ButtonGroup typeGroup = new ButtonGroup(); @@ -485,19 +516,28 @@ private Container createBuildToolPanel(ProjectOptions.BuildTool[] buildTool, button.addActionListener(evt -> { if (button.isSelected()) { projectType[0] = type; + updateModules.run(); onSelectionChanged.run(); } }); } - SpanLabel hint = new SpanLabel("Gradle projects target Java 17. Every Maven project already " - + "includes an optional backend module."); + SpanLabel hint = new SpanLabel(mavenLayouts + ? "Gradle projects target Java 17. A Maven app builds every platform from common/ " + + "unless you include all platform modules." + : "Gradle projects target Java 17. Every Maven project already " + + "includes an optional backend module."); hint.setUIID("InitializrTip"); hint.setTextUIID("InitializrTip"); final Container typeSection = BoxLayout.encloseY(labeledField("Project Type", types)); - typeSection.setHidden(true); - typeSection.setVisible(false); + boolean typeShown = buildTool[0] == ProjectOptions.BuildTool.GRADLE || mavenLayouts; + typeSection.setHidden(!typeShown); + typeSection.setVisible(typeShown); + updateModules.run(); for (ProjectOptions.BuildTool tool : ProjectOptions.BuildTool.values()) { + if (tool == ProjectOptions.BuildTool.GRADLE && !GeneratorModel.isGradleOffered()) { + continue; + } RadioButton button = new RadioButton(tool.label); button.setToggle(true); button.setUIID("InitializrChoice"); @@ -512,8 +552,8 @@ private Container createBuildToolPanel(ProjectOptions.BuildTool[] buildTool, } buildTool[0] = tool; boolean gradle = tool == ProjectOptions.BuildTool.GRADLE; - typeSection.setHidden(!gradle); - typeSection.setVisible(gradle); + typeSection.setHidden(!gradle && !mavenLayouts); + typeSection.setVisible(gradle || mavenLayouts); if (gradle) { javaVersion[0] = ProjectOptions.JavaVersion.JAVA_17; if (javaButtons[0] != null) { @@ -523,17 +563,18 @@ private Container createBuildToolPanel(ProjectOptions.BuildTool[] buildTool, if (javaButtons[1] != null) { javaButtons[1].setEnabled(!gradle); } - if (!gradle && projectType[0] == ProjectOptions.ProjectType.BACKEND_ONLY) { + if (!gradle && !mavenLayouts && projectType[0] == ProjectOptions.ProjectType.BACKEND_ONLY) { // Maven has no backend-only project, and the type is hidden // now, so a stale choice would fail generation with no way to // correct it. Every Maven project carries the backend anyway. projectType[0] = ProjectOptions.ProjectType.APP; typeGroup.setSelected(typeButtons[ProjectOptions.ProjectType.APP.ordinal()]); } + updateModules.run(); onSelectionChanged.run(); }); } - return BoxLayout.encloseY(tools, typeSection, hint); + return BoxLayout.encloseY(tools, typeSection, modulesSection, hint); } private Container createPreviewWrap(TemplatePreviewPanel previewPanel) { @@ -771,7 +812,12 @@ private String createSummary(String appName, String packageName, Template templa + "Language " + (template.IS_KOTLIN ? "KOTLIN" : "JAVA") + "\n" + "IDE " + ide.name() + "\n" + "Build " + options.buildTool.label - + (options.isGradle() ? " (" + options.projectType.label + ")" : "") + "\n" + + (options.isGradle() || GeneratorModel.isMavenLayoutChoiceOffered() + ? " (" + options.projectType.label + + (!options.isGradle() && options.allPlatformModules + && options.projectType != ProjectOptions.ProjectType.BACKEND_ONLY + ? ", all modules" : "") + ")" + : "") + "\n" + "Java " + options.javaVersion.label + "\n" + "Bundles " + (options.includeLocalizationBundles ? "INCLUDED" : "NONE") + "\n" + "Preview " + options.previewLanguage.label; diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java index 42b66ea684c..cb7723c1c27 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java @@ -56,6 +56,64 @@ public class GeneratorModel { public static boolean isGradleOffered() { return GRADLE_PLUGIN_PUBLISHED; } + + /// The first codenameone-maven-plugin that builds an app without its platform + /// modules (common/pom.xml binds its compile-javase-natives and hosted-platform + /// goals) and runs a backend that is the whole project. A Maven download made + /// against an older plugin is the full multi-module layout it always was, so + /// the new layouts light up when update-cn1-version.sh moves + /// [CN1_PLUGIN_VERSION] to this release, with no other change. + static final String MAVEN_LAYOUTS_SINCE = "7.0.275"; + + /// Whether a Maven project gets the layout choices -- the minimal app, the + /// optional backend module, a backend-only project -- at [CN1_PLUGIN_VERSION]. + public static boolean isMavenLayoutChoiceOffered() { + return isVersionAtLeast(CN1_PLUGIN_VERSION, MAVEN_LAYOUTS_SINCE); + } + + /// The plugin version a download is generated against. + static String cn1PluginVersion() { + return CN1_PLUGIN_VERSION; + } + + /// Whether `version` is `min` or newer, comparing up to four numeric parts and + /// stopping at the first character that is neither a digit nor a dot, so + /// `8.0-SNAPSHOT` reads as 8.0. Written by hand: the Codename One runtime this + /// runs on has no version parser, and split() would mean a regex. + static boolean isVersionAtLeast(String version, String min) { + int[] a = parseVersion(version); + int[] b = parseVersion(min); + for (int i = 0; i < a.length; i++) { + if (a[i] != b[i]) { + return a[i] > b[i]; + } + } + return true; + } + + private static int[] parseVersion(String version) { + int[] out = new int[4]; + if (version == null) { + return out; + } + int part = 0; + int current = 0; + for (int i = 0; i < version.length() && part < out.length; i++) { + char c = version.charAt(i); + if (c >= '0' && c <= '9') { + current = current * 10 + (c - '0'); + } else if (c == '.') { + out[part++] = current; + current = 0; + } else { + break; + } + } + if (part < out.length) { + out[part] = current; + } + return out; + } /// The Kotlin version a Gradle Kotlin project builds with, for both the Kotlin /// Gradle plugin and kotlin-stdlib (the plugin adds its own stdlib anyway, so /// the two cannot usefully differ). @@ -123,19 +181,73 @@ public static boolean isGradleOffered() { // maven/cn1app-archetype stages agent-skill-agents-md.md verbatim, so a token // there would reach every archetype-generated Maven project unrendered. private static final String AGENTS_MD_GRADLE_RESOURCE = "/agent-skill-agents-md-gradle.md"; + // Both shared with maven/cn1app-archetype, which stages them from this directory. + private static final String HOSTED_PROFILES_RESOURCE = "/common-hosted-platform-profiles.xml"; + private static final String BACKEND_ONLY_POM_RESOURCE = "/backend-only-pom.xml"; private final IDE ide; private final Template template; private final String appName; private final String packageName; private final ProjectOptions options; + private final String pluginVersion; GeneratorModel(IDE ide, Template template, String appName, String packageName, ProjectOptions options) { + this(ide, template, appName, packageName, options, CN1_PLUGIN_VERSION); + } + + private GeneratorModel(IDE ide, Template template, String appName, String packageName, ProjectOptions options, + String pluginVersion) { this.ide = ide; this.template = template; this.appName = appName; this.packageName = packageName; this.options = options == null ? ProjectOptions.defaults() : options; + this.pluginVersion = pluginVersion; + } + + /// A model that generates against `pluginVersion` rather than [CN1_PLUGIN_VERSION]: + /// how the tests and fixtures see both sides of [MAVEN_LAYOUTS_SINCE] whatever + /// release the initializr is on. + static GeneratorModel createForPluginVersion(IDE ide, Template template, String appName, String packageName, + ProjectOptions options, String pluginVersion) { + return new GeneratorModel(ide, template, appName, packageName, options, pluginVersion); + } + + /// Whether this Maven download gets the layouts of [MAVEN_LAYOUTS_SINCE]. + /// `content` without the lines that contain `marker`. + static String removeLinesContaining(String content, String marker) { + StringBuilder out = new StringBuilder(content.length()); + int start = 0; + while (start < content.length()) { + int end = content.indexOf('\n', start); + int next = end < 0 ? content.length() : end + 1; + String line = content.substring(start, next); + if (line.indexOf(marker) < 0) { + out.append(line); + } + start = next; + } + return out.toString(); + } + + private boolean mavenLayoutsEnabled() { + return !options.isGradle() && isVersionAtLeast(pluginVersion, MAVEN_LAYOUTS_SINCE); + } + + /// A Maven backend-only project: the server alone, at the root. + private boolean isMavenBackendOnly() { + return mavenLayoutsEnabled() && options.projectType == ProjectOptions.ProjectType.BACKEND_ONLY; + } + + /// Whether the Maven app carries its platform modules (javase/, android/, ...). + private boolean includesPlatformModules() { + return !mavenLayoutsEnabled() || options.allPlatformModules; + } + + /// Whether the Maven app carries the backend/ module. + private boolean includesBackendModule() { + return !mavenLayoutsEnabled() || options.projectType == ProjectOptions.ProjectType.APP_WITH_BACKEND; } public static GeneratorModel create(IDE ide, Template template, String appName, String packageName) { @@ -271,6 +383,9 @@ Map collectProjectEntries() throws IOException { if (options.isGradle()) { return collectGradleProjectEntries(); } + if (isMavenBackendOnly()) { + return collectMavenBackendOnlyEntries(); + } Map mergedEntries = new LinkedHashMap(); copyZipEntriesToMap(ide.ZIP, mergedEntries, ZipEntryType.IDE); @@ -341,6 +456,48 @@ private Map collectGradleProjectEntries() throws IOException { return entries; } + /// A Maven backend-only project: one module, the server, at the root. The pom is + /// backend-only-pom.xml; the server's files are the same templates the Gradle + /// generators use (gradle.zip), with Maven's commands; the wrapper comes from + /// common.zip. No launchers and no agent skill -- both are about the app. + private Map collectMavenBackendOnlyEntries() throws IOException { + Map entries = new LinkedHashMap(); + Map scaffold = readZipResource(GRADLE_TEMPLATE_ZIP); + addMavenBackendIdeEntries(entries); + copyZipEntriesToMap("/common.zip", entries, ZipEntryType.COMMON_ARCHIVE); + copySingleTextEntryToMap(".gitignore", GENERATED_GITIGNORE, entries, ZipEntryType.COMMON); + copySingleTextEntryToMap("pom.xml", readResourceToString(BACKEND_ONLY_POM_RESOURCE), entries, + ZipEntryType.COMMON); + copySingleTextEntryToMap("application.properties", + mavenBackendTemplate(scaffold, "backend/application.properties.txt"), entries, ZipEntryType.COMMON); + copySingleTextEntryToMap("application-dev.properties", + mavenBackendTemplate(scaffold, "backend/application-dev.properties.txt"), entries, + ZipEntryType.COMMON); + String sourceDir = "src/main/java/" + packageName.replace('.', '/') + "/"; + copySingleTextEntryToMap(sourceDir + "Api.java", mavenBackendTemplate(scaffold, "backend/Api.java.txt"), + entries, ZipEntryType.COMMON); + copySingleTextEntryToMap(sourceDir + "Greeter.java", + mavenBackendTemplate(scaffold, "backend/Greeter.java.txt"), entries, ZipEntryType.COMMON); + copySingleTextEntryToMap("README.md", buildMavenBackendReadmeMarkdown(), entries, ZipEntryType.COMMON); + validateGeneratedPomCoordinates(entries); + return entries; + } + + /// A backend template from gradle.zip with the Gradle commands it names put the + /// way Maven spells them, and its package filled in. + private String mavenBackendTemplate(Map scaffold, String name) throws IOException { + byte[] data = scaffold.get(name); + if (data == null) { + throw new IOException("Missing backend template " + name); + } + String text = StringUtil.newString(data); + text = StringUtil.replaceAll(text, "./gradlew __BACKEND__runBackend", "./mvnw cn1:backend"); + text = StringUtil.replaceAll(text, "./gradlew __BACKEND__backendPackage", "./mvnw cn1:backend-package"); + text = StringUtil.replaceAll(text, "under `runBackend`", "under `cn1:backend`"); + text = StringUtil.replaceAll(text, "${package}", packageName); + return text; + } + private void addGradleBackendEntries(Map entries, Map scaffold, String dir, String taskPrefix) throws IOException { putGradleText(entries, dir + "build.gradle.kts", @@ -370,7 +527,7 @@ private String gradleTemplate(Map scaffold, String name, String throw new IOException("Missing Gradle project template " + name); } String text = StringUtil.newString(data); - text = StringUtil.replaceAll(text, "__CN1_VERSION__", CN1_PLUGIN_VERSION); + text = StringUtil.replaceAll(text, "__CN1_VERSION__", pluginVersion); // The same name the Maven reactor gets as its artifactId (cn1app.name). text = StringUtil.replaceAll(text, "__PROJECT_NAME__", toLowerCaseInvariant(appName)); text = StringUtil.replaceAll(text, "__BACKEND__", taskPrefix); @@ -419,10 +576,10 @@ private static String insertAfter(String text, String marker, String insertion) void validateGradleProject(Map entries) throws IOException { String settings = entries.get("settings.gradle.kts") == null ? null : StringUtil.newString(entries.get("settings.gradle.kts")); - if (settings == null || settings.indexOf("id(\"com.codenameone\") version \"" + CN1_PLUGIN_VERSION + "\"") < 0 + if (settings == null || settings.indexOf("id(\"com.codenameone\") version \"" + pluginVersion + "\"") < 0 || settings.indexOf("rootProject.name = \"" + toLowerCaseInvariant(appName) + "\"") < 0) { throw new IOException("Refusing to generate project: settings.gradle.kts does not apply the " - + "Codename One plugin " + CN1_PLUGIN_VERSION + " to " + toLowerCaseInvariant(appName)); + + "Codename One plugin " + pluginVersion + " to " + toLowerCaseInvariant(appName)); } if (entries.get("build.gradle.kts") == null || entries.get("gradlew") == null) { throw new IOException("Refusing to generate project: the Gradle build script or wrapper is missing"); @@ -482,8 +639,15 @@ void validateOptions() throws IOException { return; } if (options.projectType == ProjectOptions.ProjectType.BACKEND_ONLY) { - throw new IOException("A backend-only project is generated for Gradle. Every Maven project already " - + "carries the backend module; build it with -Dcodename1.platform=backend."); + if (!mavenLayoutsEnabled()) { + throw new IOException("A backend-only Maven project needs Codename One " + MAVEN_LAYOUTS_SINCE + + " or newer; generate it for Gradle. Every Maven project already carries the backend " + + "module; build it with -Dcodename1.platform=backend."); + } + if (options.allPlatformModules) { + throw new IOException("A backend-only project has no platform modules; leave " + + "\"Include all platform modules\" off."); + } } } @@ -512,18 +676,78 @@ void validateGeneratedPomCoordinates(Map entries) throws IOExcep "cn1app.name " + rootArtifactId ); + if (isMavenBackendOnly()) { + validateBackendOnlyPom(entries, rootPom); + return; + } + validateModulePomCoordinates(entries, "common", rootArtifactId + "-common", false, version); - String[] platforms = new String[] {"android", "ios", "javase", "javascript", "linux", "win"}; - for (int i = 0; i < platforms.length; i++) { - String platform = platforms[i]; - validateModulePomCoordinates(entries, platform, rootArtifactId + "-" + platform, true, version); + for (int i = 0; i < PLATFORM_MODULES.length; i++) { + String platform = PLATFORM_MODULES[i]; + if (includesPlatformModules()) { + validateModulePomCoordinates(entries, platform, rootArtifactId + "-" + platform, true, version); + } else { + requireAbsentModule(entries, rootPom, platform); + } } // The backend module is checked for coordinates like the rest, but NOT for a // dependency on the generated common module. It must not have one: common is // compiled against codenameone-core, and a server has no display. Requiring it // here would enforce exactly the mistake the module's own comment warns against. - validateModulePomCoordinates(entries, "backend", rootArtifactId + "-backend", false, version); + if (includesBackendModule()) { + validateModulePomCoordinates(entries, "backend", rootArtifactId + "-backend", false, version); + } else { + requireAbsentModule(entries, rootPom, "backend"); + } + + if (mavenLayoutsEnabled()) { + String commonPom = normalizedPom(entries, "common/pom.xml"); + requirePomFragment("common/pom.xml", commonPom, "compile-javase-natives", + "hosted platform profiles"); + if (commonPom.indexOf("simulator") != commonPom.lastIndexOf("simulator")) { + throw new IOException("Refusing to generate project: common/pom.xml declares the simulator " + + "profile twice"); + } + if (!includesPlatformModules() && rootPom.indexOf("") >= 0) { + throw new IOException("Refusing to generate project: pom.xml activates a javase module " + + "the project does not have"); + } + } + } + + /// An optional module that was left out: no files of it in the download, and + /// the root pom adds it only once its pom exists. + private void requireAbsentModule(Map entries, String rootPom, String module) throws IOException { + for (String path : entries.keySet()) { + if (path.startsWith(module + "/")) { + throw new IOException("Refusing to generate project: " + path + " belongs to the " + module + + " module this project does not have"); + } + } + if (rootPom.indexOf("" + module + "") >= 0) { + requirePomFragment("pom.xml", rootPom, "${basedir}/" + module + "/pom.xml", + "guard that adds the " + module + " module only when it exists"); + } + } + + /// A backend-only project is one module: no parent, the backend runtime, the + /// annotation processing that generates its entry point, and nothing of an app. + private void validateBackendOnlyPom(Map entries, String rootPom) throws IOException { + if (rootPom.indexOf("") >= 0) { + throw new IOException("Refusing to generate project: a backend-only pom.xml has a parent"); + } + requirePomFragment("pom.xml", rootPom, "codenameone-backend", + "dependency on codenameone-backend"); + requirePomFragment("pom.xml", rootPom, "process-annotations", "process-annotations goal"); + if (rootPom.indexOf("-common") >= 0 || rootPom.indexOf("codenameone-core") >= 0) { + throw new IOException("Refusing to generate project: a backend-only pom.xml depends on the app"); + } + for (String path : entries.keySet()) { + if (path.startsWith("common/") || (path.endsWith("/pom.xml") && !"pom.xml".equals(path))) { + throw new IOException("Refusing to generate project: " + path + " in a backend-only project"); + } + } } private void validateModulePomCoordinates( @@ -728,6 +952,12 @@ private String mapTargetPath(String sourcePath, ZipEntryType zipType) { return null; } targetPath = sourcePath.substring("common/".length()); + } else if (zipType == ZipEntryType.COMMON_ARCHIVE && mavenLayoutsEnabled()) { + String mapped = mapMavenLayoutPath(sourcePath); + if (mapped == null) { + return null; + } + targetPath = mapped; } else if (zipType == ZipEntryType.TEMPLATE_CSS) { targetPath = appDir() + "src/main/css/" + sourcePath; } else if (zipType == ZipEntryType.TEMPLATE_SOURCE) { @@ -746,6 +976,103 @@ private String mapTargetPath(String sourcePath, ZipEntryType zipType) { return applyPathReplacements(targetPath); } + /// The platform modules common.zip carries, each optional from [MAVEN_LAYOUTS_SINCE]. + private static final String[] PLATFORM_MODULES = {"android", "ios", "javase", "javascript", "linux", "win"}; + + /// Where a common.zip entry goes in a Maven download with the layout choices, or + /// null to leave it out: a backend-only project keeps the Maven wrapper alone; an + /// app drops the platform modules unless every one was asked for, and the backend + /// module unless it was asked for. The packaged desktop app's native theme moves + /// from the javase module to common, where the desktop goals read it when there + /// is no javase module. + private String mapMavenLayoutPath(String sourcePath) { + if (isMavenBackendOnly()) { + return "mvnw".equals(sourcePath) || "mvnw.cmd".equals(sourcePath) || sourcePath.startsWith(".mvn/") + ? sourcePath : null; + } + if (!includesPlatformModules()) { + if ("javase/src/desktop/resources/NativeTheme.res".equals(sourcePath)) { + return "common/src/desktop/resources/NativeTheme.res"; + } + for (int i = 0; i < PLATFORM_MODULES.length; i++) { + if (sourcePath.startsWith(PLATFORM_MODULES[i] + "/")) { + return null; + } + } + } + if (!includesBackendModule() && sourcePath.startsWith("backend/")) { + return null; + } + return sourcePath; + } + + /// The modules the root pom adds through a profile of the same id. + private static final String[] PROFILE_MODULES = {"javascript", "ios", "win", "linux", "backend", "android", "javase"}; + + /// The root pom with every module profile also requiring the module's pom, so a + /// module that is not there is simply not in the reactor and `common` builds + /// that platform instead. Maven needs both the property and the file. Without a + /// javase module the javase profile also loses activeByDefault, which Maven + /// honours even when the profile's own conditions fail. + static String guardModuleProfiles(String pom, boolean withoutJavase) { + int profiles = pom.indexOf(""); + if (profiles < 0) { + return pom; + } + for (int i = 0; i < PROFILE_MODULES.length; i++) { + String module = PROFILE_MODULES[i]; + int id = pom.indexOf("" + module + "", profiles); + if (id < 0) { + continue; + } + int activationEnd = pom.indexOf("", id); + int property = pom.indexOf("", id); + if (activationEnd < 0 || property < 0 || property > activationEnd) { + continue; + } + String guard = "${basedir}/" + module + "/pom.xml"; + if (pom.substring(id, activationEnd).indexOf(guard) >= 0) { + continue; + } + int lineStart = pom.lastIndexOf('\n', property) + 1; + String indent = pom.substring(lineStart, property); + // common.zip's root pom indents by two. + String step = " "; + int at = property + "".length(); + pom = pom.substring(0, at) + + "\n" + indent + "\n" + indent + step + guard + "\n" + indent + "" + + pom.substring(at); + } + if (withoutJavase) { + String tag = "true"; + int pos = pom.indexOf(tag); + if (pos >= 0) { + int lineStart = pom.lastIndexOf('\n', pos); + pom = pom.substring(0, lineStart < 0 ? pos : lineStart) + pom.substring(pos + tag.length()); + } + } + return pom; + } + + /// common/pom.xml with the profiles that let common build any platform the + /// project has no module for (common-hosted-platform-profiles.xml, the file the + /// archetype injects too). They replace the template's own `simulator` profile, + /// which the shared file carries in full. + private static String injectHostedPlatformProfiles(String pom) throws IOException { + String fragment = readResourceToString(HOSTED_PROFILES_RESOURCE); + int id = pom.indexOf("simulator"); + int start = id < 0 ? -1 : pom.lastIndexOf("", id); + int end = id < 0 ? -1 : pom.indexOf("", id); + if (start < 0 || end < 0) { + throw new IOException("Refusing to generate project: common/pom.xml has no simulator profile " + + "to replace with the hosted platform profiles"); + } + int lineStart = pom.lastIndexOf('\n', start) + 1; + int lineEnd = pom.indexOf('\n', end); + lineEnd = lineEnd < 0 ? pom.length() : lineEnd + 1; + return pom.substring(0, lineStart) + fragment + pom.substring(lineEnd); + } + /// The directory the application's own files live in: the `common/` module of /// a Maven project, the root of a Gradle one. private String appDir() { @@ -793,12 +1120,24 @@ private byte[] applyDataReplacements(String targetPath, byte[] sourceData) throw if (".idea/misc.xml".equals(targetPath)) { content = normalizeIntellijMiscXml(content); } + if (!includesPlatformModules() + && (".idea/compiler.xml".equals(targetPath) || ".idea/encodings.xml".equals(targetPath))) { + // The IntelliJ files name the javase module, which this download does not have. + content = removeLinesContaining(content, "-javase\""); + content = removeLinesContaining(content, "$PROJECT_DIR$/javase/"); + } if (".idea/workspace.xml".equals(targetPath)) { content = applySimulatorJvmExportToIdeaWorkspace(content); } if ("pom.xml".equals(targetPath)) { - content = replaceTagValue(content, "cn1.plugin.version", CN1_PLUGIN_VERSION); - content = replaceTagValue(content, "cn1.version", CN1_PLUGIN_VERSION); + content = replaceTagValue(content, "cn1.plugin.version", pluginVersion); + content = replaceTagValue(content, "cn1.version", pluginVersion); + if (mavenLayoutsEnabled() && !isMavenBackendOnly()) { + content = guardModuleProfiles(content, !includesPlatformModules()); + } + } + if ("common/pom.xml".equals(targetPath) && mavenLayoutsEnabled()) { + content = injectHostedPlatformProfiles(content); } if ("android/pom.xml".equals(targetPath) || "ios/pom.xml".equals(targetPath) || "javascript/pom.xml".equals(targetPath)) { content = hardenPlatformModulePomAgainstDoubleJarAttach(content); @@ -1050,9 +1389,14 @@ private static String ensureDefaultLargeTextScale(String css) { private String buildReadmeMarkdown() { StringBuilder out = new StringBuilder(); - out.append("# Codename One Project\n\n") - .append("This is a multi-module Maven project for a Codename One app.\n") - .append("You can write the app in Java and/or Kotlin, and build for Android, iOS, desktop, and web.\n\n") + out.append("# Codename One Project\n\n"); + if (includesPlatformModules()) { + out.append("This is a multi-module Maven project for a Codename One app.\n"); + } else { + out.append("This is a Maven project for a Codename One app. The app lives in `common/`, and every ") + .append("platform is built from it: there is no per-platform module until you add one.\n"); + } + out.append("You can write the app in Java and/or Kotlin, and build for Android, iOS, desktop, and web.\n\n") .append("## Getting Started\n\n"); out.append("Use JDK ").append(options.javaVersion == ProjectOptions.JavaVersion.JAVA_17 ? "17" : "8") @@ -1072,6 +1416,19 @@ private String buildReadmeMarkdown() { appendIdeSection(out); + if (!includesPlatformModules()) { + out.append("## Native Code\n\n") + .append("Native interface implementations go in a directory per platform beside `common/`, ") + .append("for example `android/src/main/java` or `ios/src/main/objectivec`. ") + .append("`mvn cn1:generate-native-interfaces` creates them, and the build picks them up from there.\n\n"); + } + if (mavenLayoutsEnabled() && includesBackendModule()) { + out.append("## Backend\n\n") + .append("`backend/` is the app's server. Run it with ") + .append("`./mvnw -pl backend -Dcodename1.platform=backend cn1:backend` and package it as a native ") + .append("binary with `./mvnw -pl backend -Dcodename1.platform=backend cn1:backend-package`.\n\n"); + } + if (template.USES_CODERAD) { out.append("### Additional Eclipse Steps for CodeRAD Projects\n\n") .append("CodeRAD uses annotation processing, so Eclipse needs two extra settings:\n\n") @@ -1108,13 +1465,135 @@ private void appendIdeSection(StringBuilder out) { } if (ide == IDE.NETBEANS) { out.append("## NetBeans Users\n\n") - .append("This is a standard multi-module Maven project generated from an archetype.\n\n"); + .append(includesPlatformModules() + ? "This is a standard multi-module Maven project generated from an archetype.\n\n" + : "This is a standard Maven project; open the root pom.xml.\n\n"); return; } out.append("## VS Code Users\n\n") .append("Open the project folder in VS Code and make sure Java + Maven extensions are installed.\n\n"); } + /// The goals a Maven backend-only project's IDE files offer, as {label, goal}. + private static final String[][] MAVEN_BACKEND_GOALS = { + {"Run Backend", "cn1:backend"}, + {"Package Backend", "cn1:backend-package"}, + {"Update Codename One", "cn1:update"} + }; + + /// IDE files for a Maven backend-only project: every IDE imports the pom on its + /// own, so what is left is a button per goal worth one. The IDE zips are not + /// used -- their run configurations are the app's simulator and builds. + private void addMavenBackendIdeEntries(Map entries) throws IOException { + if (ide == IDE.INTELLIJ) { + for (int i = 0; i < MAVEN_BACKEND_GOALS.length; i++) { + copySingleTextEntryToMap(".idea/runConfigurations/" + fileNameFor(MAVEN_BACKEND_GOALS[i][0]) + ".xml", + intellijMavenRunConfiguration(MAVEN_BACKEND_GOALS[i][0], MAVEN_BACKEND_GOALS[i][1]), + entries, ZipEntryType.COMMON); + } + return; + } + if (ide == IDE.VS_CODE) { + copySingleTextEntryToMap(".vscode/tasks.json", vscodeTasks(MAVEN_BACKEND_GOALS, "./mvnw", ".\\\\mvnw.cmd"), + entries, ZipEntryType.COMMON); + copySingleTextEntryToMap(".vscode/extensions.json", + "{\n \"recommendations\": [\n" + + " \"vscjava.vscode-java-pack\",\n" + + " \"vscjava.vscode-maven\"\n" + + " ]\n}\n", entries, ZipEntryType.COMMON); + } + } + + private static String intellijMavenRunConfiguration(String name, String goal) { + return "\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + "\n"; + } + + /// A VS Code tasks.json running each {label, argument} through `command`. + private static String vscodeTasks(String[][] tasks, String command, String windowsCommand) { + StringBuilder json = new StringBuilder(); + json.append("{\n \"version\": \"2.0.0\",\n \"tasks\": [\n"); + for (int i = 0; i < tasks.length; i++) { + json.append(" {\n") + .append(" \"label\": \"").append(tasks[i][0]).append("\",\n") + .append(" \"type\": \"shell\",\n") + .append(" \"command\": \"").append(command).append("\",\n") + .append(" \"windows\": { \"command\": \"").append(windowsCommand).append("\" },\n") + .append(" \"args\": [\"").append(tasks[i][1]).append("\"],\n") + .append(" \"problemMatcher\": []\n") + .append(" }").append(i + 1 < tasks.length ? "," : "").append('\n'); + } + json.append(" ]\n}\n"); + return json.toString(); + } + + private String buildMavenBackendReadmeMarkdown() { + StringBuilder out = new StringBuilder(); + out.append("# Codename One Backend\n\n") + .append("This is a Maven project for a Codename One backend: a server written in Java, ") + .append("run on the JVM while you develop and packaged as a single native binary.\n\n") + .append("## Getting Started\n\n") + .append("Use JDK 17 or newer, and set JAVA_HOME for terminal builds. ") + .append("Extract the entire ZIP, then open the root pom.xml as a Maven project.\n\n") + .append("macOS/Linux:\n\n```\nCN1_PROFILE=dev ./mvnw cn1:backend\n```\n\n") + .append("Windows PowerShell:\n\n```\n$env:CN1_PROFILE=\"dev\"; .\\mvnw.cmd cn1:backend\n```\n\n") + .append("The first build downloads the dependencies and can take several minutes. ") + .append("If a download fails, check your connection or Maven proxy settings and retry.\n\n") + .append("## Goals\n\n") + .append("- `./mvnw cn1:backend` runs the server on this JVM; it starts in seconds.\n") + .append("- `CN1_PROFILE=dev ./mvnw cn1:backend` also reads `application-dev.properties` ") + .append("(an in-memory database).\n") + .append("- `./mvnw cn1:backend-package` builds a single native binary.\n\n") + .append("Routes are the annotated methods in `src/main/java/") + .append(packageName.replace('.', '/')).append("/Api.java`. Settings live in ") + .append("`application.properties` beside the pom.\n\n"); + if (ide == IDE.ECLIPSE) { + out.append("## Eclipse Users\n\n") + .append("Choose File > Import > Maven > Existing Maven Projects and select this folder. ") + .append("Run the goals above from Run As > Maven build.\n\n"); + } else if (ide == IDE.NETBEANS) { + out.append("## NetBeans Users\n\n") + .append("Open this folder as a Maven project and run the goals above from Run Maven > Goals.\n\n"); + } else if (ide == IDE.INTELLIJ) { + out.append("## IntelliJ Users\n\n") + .append("Open this folder; the Run Backend and Package Backend configurations run the goals above.\n\n"); + } else { + out.append("## VS Code Users\n\n") + .append("Open this folder; Terminal > Run Task offers the goals above.\n\n"); + } + out.append("## Help and Support\n\n") + .append("- Codename One website: https://www.codenameone.com\n") + .append("- Codename One GitHub: https://github.com/codenameone/CodenameOne\n"); + return out.toString(); + } + /// The tasks a Gradle project's IDE files offer, as {label, task path}. private String[][] gradleIdeTasks() { if (options.projectType == ProjectOptions.ProjectType.BACKEND_ONLY) { diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ProjectOptions.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ProjectOptions.java index ff3768dd53a..9edd4f7a8b3 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ProjectOptions.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ProjectOptions.java @@ -100,11 +100,15 @@ public String toString() { } } - /// What the generated project holds. Only a Gradle project has a choice: a - /// Maven download always carries the backend module behind the `backend` - /// profile (it builds only when asked for, so an app pays nothing for it), - /// which makes APP and APP_WITH_BACKEND the same download there, and there is - /// no backend-only Maven scaffold -- GeneratorModel refuses that combination. + /// What the generated project holds: an app, an app with a `backend/` server + /// beside it, or a server on its own. + /// + /// A Maven project has the choice only when the plugin it is generated against + /// can build an app without its platform modules + /// ([GeneratorModel#isMavenLayoutChoiceOffered()]). Against an older plugin a + /// Maven download is the full multi-module layout, which always carries the + /// backend module behind the `backend` profile, so APP and APP_WITH_BACKEND are + /// the same download there and GeneratorModel refuses BACKEND_ONLY. public enum ProjectType { APP("App"), APP_WITH_BACKEND("App + backend"), @@ -131,6 +135,11 @@ public String toString() { public final String customThemeCss; public final BuildTool buildTool; public final ProjectType projectType; + /// Whether a Maven app is generated with every platform module (`javase/`, + /// `android/`, `ios/`, ...) rather than the minimal layout, where `common/` + /// builds every platform itself. Ignored for Gradle and for a backend-only + /// project, and against a plugin that predates the minimal layout. + public final boolean allPlatformModules; public ProjectOptions(ThemeMode themeMode, Accent accent, boolean roundedButtons, boolean includeLocalizationBundles, PreviewLanguage previewLanguage, @@ -149,6 +158,14 @@ public ProjectOptions(ThemeMode themeMode, Accent accent, boolean roundedButtons boolean includeLocalizationBundles, PreviewLanguage previewLanguage, JavaVersion javaVersion, String customThemeCss, BuildTool buildTool, ProjectType projectType) { + this(themeMode, accent, roundedButtons, includeLocalizationBundles, previewLanguage, javaVersion, + customThemeCss, buildTool, projectType, false); + } + + public ProjectOptions(ThemeMode themeMode, Accent accent, boolean roundedButtons, + boolean includeLocalizationBundles, PreviewLanguage previewLanguage, + JavaVersion javaVersion, String customThemeCss, + BuildTool buildTool, ProjectType projectType, boolean allPlatformModules) { this.themeMode = themeMode; this.accent = accent; this.roundedButtons = roundedButtons; @@ -158,12 +175,20 @@ public ProjectOptions(ThemeMode themeMode, Accent accent, boolean roundedButtons this.customThemeCss = customThemeCss; this.buildTool = buildTool == null ? BuildTool.MAVEN : buildTool; this.projectType = projectType == null ? ProjectType.APP : projectType; + this.allPlatformModules = allPlatformModules; } /// A copy of these options with a different build tool and project type. public ProjectOptions withBuild(BuildTool buildTool, ProjectType projectType) { return new ProjectOptions(themeMode, accent, roundedButtons, includeLocalizationBundles, previewLanguage, - javaVersion, customThemeCss, buildTool, projectType); + javaVersion, customThemeCss, buildTool, projectType, allPlatformModules); + } + + /// A copy of these options that does or does not ask for every platform module; + /// see [#allPlatformModules]. + public ProjectOptions withPlatformModules(boolean all) { + return new ProjectOptions(themeMode, accent, roundedButtons, includeLocalizationBundles, previewLanguage, + javaVersion, customThemeCss, buildTool, projectType, all); } public boolean isGradle() { diff --git a/scripts/initializr/common/src/main/resources/agent-skill-agents-md.md b/scripts/initializr/common/src/main/resources/agent-skill-agents-md.md index 7d5b732c3e4..438944bf512 100644 --- a/scripts/initializr/common/src/main/resources/agent-skill-agents-md.md +++ b/scripts/initializr/common/src/main/resources/agent-skill-agents-md.md @@ -23,9 +23,10 @@ their own conventions; the canonical source of truth is `.agent-skills/`. - A bug that only reproduces on an Android phone or an iPhone is still debuggable: attach a Java debugger to the device build and drive it over MCP the same way; see `.agent-skills/codename-one/references/on-device-debugging.md`. -- Native cloud builds use `mvn -pl package -Dcodename1.platform=... -Dcodename1.buildTarget=...`. -- The server side lives in `backend/` (Spring-style `@RestController` / `@Service`, - resolved at build time). Run it with +- Native cloud builds use `mvn package -Dcodename1.platform= -Dcodename1.buildTarget=...` + from the project root. A platform with no module of its own is built from `common/`. +- The server side, when the project has one, lives in `backend/` (Spring-style + `@RestController` / `@Service`, resolved at build time). Run it with `CN1_PROFILE=dev mvn -pl backend -Dcodename1.platform=backend cn1:backend`; it then serves MCP tools at `http://127.0.0.1:8080/mcp` for inspecting and exercising it. See `.agent-skills/codename-one/references/backend.md`, and diff --git a/scripts/initializr/common/src/main/resources/backend-only-pom.xml b/scripts/initializr/common/src/main/resources/backend-only-pom.xml new file mode 100644 index 00000000000..47641ebdcbc --- /dev/null +++ b/scripts/initializr/common/src/main/resources/backend-only-pom.xml @@ -0,0 +1,117 @@ + + + 4.0.0 + com.example.myapp + myappname + 1.0-SNAPSHOT + jar + + myappname + + + + 8.0-SNAPSHOT + 8.0-SNAPSHOT + UTF-8 + + 1.8 + 1.8 + myappname + + + + + com.codenameone + codenameone-backend + ${cn1.version} + + + + org.xerial + sqlite-jdbc + 3.46.1.0 + runtime + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.0 + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + + cn1-process-annotations + process-classes + + + process-annotations + + + + + + + + + + + codenameone + https://repo.codenameone.com/maven2 + + true + + + false + + + + + + codenameone-plugins + https://repo.codenameone.com/maven2 + + true + + + false + + + + diff --git a/scripts/initializr/common/src/main/resources/common-hosted-platform-profiles.xml b/scripts/initializr/common/src/main/resources/common-hosted-platform-profiles.xml new file mode 100644 index 00000000000..7b36d106928 --- /dev/null +++ b/scripts/initializr/common/src/main/resources/common-hosted-platform-profiles.xml @@ -0,0 +1,889 @@ + + + + cn1-javase-natives + + + ${basedir}/../javase/pom.xml + + + + + + com.codenameone + codenameone-maven-plugin + + + compile-javase-natives + process-classes + + compile-javase-natives + + + javase + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + + org.apache.maven.surefire + surefire-junit-platform + 3.0.0-M5 + + + + ${skipTests} + + ${project.build.directory}/cn1-javase/classes + + + + + + + + + cn1-host-javase + + + codename1.platform + javase + + + ${basedir}/../javase/pom.xml + + + + javase + javase + none + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-javase + validate + + hosted-platform + + + javase + + + + build-javase + package + + build + + + javase + + + + + + + + + + cn1-host-android + + + codename1.platform + android + + + ${basedir}/../android/pom.xml + + + + android + android + android-device + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-android + validate + + hosted-platform + + + android + + + + build-android + package + + build + + + android + + + + + + + + + + cn1-host-ios + + + codename1.platform + ios + + + ${basedir}/../ios/pom.xml + + + + ios + ios + ios-device + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-ios + validate + + hosted-platform + + + ios + + + + build-ios + package + + build + + + ios + + + + + + + + + + cn1-host-javascript + + + codename1.platform + javascript + + + ${basedir}/../javascript/pom.xml + + + + javascript + javascript + local-javascript + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-javascript + validate + + hosted-platform + + + javascript + + + + build-javascript + package + + build + + + javascript + + + + + + + + + + cn1-host-win + + + codename1.platform + win + + + ${basedir}/../win/pom.xml + + + + win + win + windows-device + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-win + validate + + hosted-platform + + + win + + + + build-windows + package + + build + + + win + + + + + + + + + + cn1-host-linux + + + codename1.platform + linux + + + ${basedir}/../linux/pom.xml + + + + linux + linux + linux-device + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-linux + validate + + hosted-platform + + + linux + + + + build-linux + package + + build + + + linux + + + + + + + + + + simulator + + javase + com.codename1.impl.javase.Simulator + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-in-simulator + verify + + exec + + + ${cn1.hosted.javase.skip} + ${basedir} + java + + -Xmx1024M + -Dcodename1.designer.jar=${codename1.designer.jar} + -Dcodename1.css.compiler.args.input=${codename1.css.compiler.args.input} + -Dcodename1.css.compiler.args.output=${codename1.css.compiler.args.output} + -Dcodename1.css.compiler.args.merge=${codename1.css.compiler.args.merge} + ${codename1.exec.args.debug} + ${codename1.exec.args.runjdwp.transport} + + @${cn1.hosted.classpathArgFile} + ${exec.mainClass} + ${codename1.mainClass} + + + + + + + + + + + idea-simulator + + com.codename1.impl.javase.Simulator + true + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-in-simulator-idea + verify + + java + + + ${cn1.hosted.javase.skip} + + ${project.build.directory}/cn1-javase/classes + + + ${codename1.mainClass} + + + + codename1.designer.jar + ${codename1.designer.jar} + + + codename1.css.compiler.args.input + ${codename1.css.compiler.args.input} + + + codename1.css.compiler.args.output + ${codename1.css.compiler.args.output} + + + codename1.css.compiler.args.merge + ${codename1.css.compiler.args.merge} + + + + cn1.class.path + ${cn1.class.path} + + + + + + + + + + + + debug-simulator + + com.codename1.impl.javase.Simulator + true + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-in-simulator + verify + + exec + + + ${cn1.hosted.javase.skip} + ${basedir} + java + + -Xdebug + -Xrunjdwp:transport=dt_socket,server=n,address=${jpda.address} + -Xmx1024M + -Dcodename1.designer.jar=${codename1.designer.jar} + -Dcodename1.css.compiler.args.input=${codename1.css.compiler.args.input} + -Dcodename1.css.compiler.args.output=${codename1.css.compiler.args.output} + -Dcodename1.css.compiler.args.merge=${codename1.css.compiler.args.merge} + @${cn1.hosted.classpathArgFile} + ${exec.mainClass} + ${codename1.mainClass} + + + + + + + + + + + debug-eclipse + + com.codename1.impl.javase.Simulator + true + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-in-simulator + verify + + exec + + + ${cn1.hosted.javase.skip} + ${basedir} + java + + -Xdebug + -Xrunjdwp:transport=dt_socket,server=y,address=${jpda.address},suspend=y + -Xmx1024M + -Dcodename1.designer.jar=${codename1.designer.jar} + -Dcodename1.css.compiler.args.input=${codename1.css.compiler.args.input} + -Dcodename1.css.compiler.args.output=${codename1.css.compiler.args.output} + -Dcodename1.css.compiler.args.merge=${codename1.css.compiler.args.merge} + @${cn1.hosted.classpathArgFile} + ${exec.mainClass} + ${codename1.mainClass} + + + + + + + + + + + run-desktop + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + generate-icons + generate-sources + + generate-desktop-app-wrapper + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-desktop + verify + + java + + + ${cn1.hosted.javase.skip} + ${codename1.packageName}.${codename1.mainName}Stub + + + ${project.build.directory}/cn1-javase/classes + ${project.build.directory}/cn1-javase/desktop + ${basedir}/src/desktop/resources + ${basedir}/../javase/src/desktop/resources + + + + + + + + + + + executable-jar + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + generate-icons + generate-sources + + generate-desktop-app-wrapper + + + javase + + + + javase-executable-jar + package + + javase-executable-jar + + + javase + + + + + + + + + + test + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + cn1-tests + test + + test + + + javase + + + + + + + + + + run-android + + + + com.codenameone + codenameone-maven-plugin + + + + run-android-hosted + validate + + hosted-platform + + + android + + + + + + maven-antrun-plugin + + + adb-install + verify + + run + + + ${cn1.hosted.android.skip} + + Running adb install + + + + + + Trying to start app on device using adb + + + + + + + + + + + + + + + + diff --git a/scripts/initializr/common/src/main/resources/common.zip b/scripts/initializr/common/src/main/resources/common.zip index 54a6d2a95a69807cbce41e440ae403d38e84f7a7..783e9d78ed3bd8308ccebf73efa61ccb0a52a525 100644 GIT binary patch delta 4516 zcmY*c1yGe+*M{@bdFT|7mX1SrH%bVif|MYQht4BXf;8d_TpDRsim*Gkf;ME^+-naRnO(-qtD=_fH};w6T;#ehx~+gUh@qwvWwgQ?Vwu&P6saJ-@Dy8XBFujG(@?u2f>DaBAQJGMoR1Wmd_a$C3 zMgh4Nd4v)A{U$soYPV7O(tYD~?LVJxGBg&P!7P{jH749)gN+Ede*atX?3NIA6_N=F zI>OTFv{9Tu0yyLA7QgG&Q@NvZ)lMk~l1@0qBjc5{09U1>!a`olMMxY*xD<7f&9-sE znnwQzK{8+4zZ<+~OsFw4)P)YK#Z*gsG)v5_oM5TZvd?}x)2c?%(Z1l0^7N7j*XS?M zmJCnflOf2rsV_lLnbGP=Xk7WHxlE5GF=kKYiJfP^J;n=_U3tFxBq%wPjqFta;J~BT z;#~*$tAG)I4E=#=Ju@r=yLxa-Lz;YnaV_iduqCd>haF%Ms#0yWP%rRuT5#Bv2?q3Ky|Lgm6T#+6i$?9LM0o?S3X0 ztM0A`47QM9D0@<2MP(**^PJm;*j6&VXSU69A{t)4Ir%J1p27Y_U~ItQvBdWyyjORh z@Y=hUWz3%{K2fh~dq^~)hGK12v$oz+hL5+?q-2oBLp9l9QRcpK&%Gs1?Z6U#WG!lH3k=^84ZIpa|)7|EW;@{zb_Mp=`vgJhvamqq@b$?wq0G(`LCl zoE!VWQQ_#!m&(GHmxCh$O`=7r3ZrmJ3xRwE0jOivB7$H1L&d>KSYhatB^e$gb8QX{ z;l&6s6rn`r-~#6h>`3?7$bFTE3vahIE0WTXm5uOxY0Hr!`yNvvar`Nh3STpcwfTCm zGb^+_;(bnX>pMpE^6afJ_{gvtTsG-KA&xV=&F0E}p^$>yXvWCISCDoq^qei~O}5<( z_md;bYayLw^hB)`Q-@3Ev3ZB@Y=A;2ojn~Q(0RL363K6!CDsQTj`8op zo~LhS<8eiTr@M)J!A@gt#v_IG8+RdH*WDx&s#Qh}V&tvt+7w9dFzcJ4;h(1t`?@sG^i)k zpKSFVn;SeCX>IqX#*ffukhGY&o=hu7Xq&?i_{0|ap_lOb=uGGW@@ufnC${>UvJ%Wc zkK>>N;o7*;x}xIjmiFCF-PL`D7W-;6`$xCyv(<@*`ZOJ1CG}#%7I^}L&sF1HB3Z3Z z-hUMk>FN*Cq8J&+OXb?#SI#y@cZ!pbdiR?jO3AkjHTXqRcxaSSZUt=@x8d|f9M8F5 zu6PWD`5xa?2F=L)m{xL#xnKNpH!nxge(@+=x28*e3tB4f6&0Sikujgnlzp7xXzJ{}pleY>jk!RM- z@HL}V%(yF6?!qpMw!Y}xgi_TP1d@KBt~tvt?5NX;`!UsfPu)A^g(G6L`OzRcHTNin z+m%pGGbRpik=NqVbuFQ_bm65qU#zBI+!FbpKXzDw8{*Vd6r}iotI_~F?$Mrk^8}Wc z|MsJUQmAJwLV?6)p1QKS4V}c%oKMv&hH_5lfX(pTbSAgHyY6pdQc)p)gd2{PT3cE5 z4tXwii<;q;JZaDRqeMADTKw{c-(E-MOKI_SmDYOX_aD>F_jKdt#)3GhzZrdK(Ea8c z6getBDs1G_uuH(i^>=_#?>YLaVr7#Cu&a_C$0K?&?z|JM#rTTLp8@)6w1Yo|l{rON zf0uN1A7RqlzE%$Q8{#)gBG{PEY(u#4ojoE8mu}d44BmG4Zcc5m1#y{r4`1RArONuL z)Wjxmw$4Atfw!$S#&Mjab=UCB_pQrc2JGSU9yUrx=4f~@2x`yWnN2dU)ZD93f!ZSI=1;Govys zxZ2m$3wwF1?sR&xLY&09BAD`(FicShPR27X+-ETdTRoMILqvA9zCAS*2t;1__-h7# z5Pm$I8EA+;e|uxsNV9dxpMkjS?9Rq==)+G>RoQ%Mg_EG&TjIt4>4oBdIf6?ej!~P8uoGTFQx9CCBDd}aZ;7#t~UHdzx$!=d$pe0*`{qQxyuRnaY)-pp135B zeR|vXN}IBHG>w=G zDTAhWaV>71`51<+VAYO5_w+b-ahyNW`)GHeMc&?PU%B?($lzJ~=M_)mym^aiitF+u zXIX`J%-#02a~6sOeE3`ex7?4oG?miZ7iQPs?3Vu@ZSRTtH4LML;Mws*50$+Z`*#GdXilO2Y2$qv?!UAf(Q z3cgz^AIHkd(yEBEIn$;}s!zneS;-skU@5}meW-p#tupm(6x60UcR(-a#IO50F{C6L z>@S`>CYtLx79SFWaRh8ttZl^#Bq4Dq>T~L{WLFEYTXuYSl+8wHy~a5 zvHWa%ymrRSq}EFhvx9*rQb!`mFgi&Csa5IMcu(3hl$GzcfPUDv!X86L;9BRU+~I{A z+Io^JFl6BO`Du`$u+HeONg3VdSi%c&k64591e6HXi8Z(s_t3F_1L!t8b`(8TVIR%S z|s<~vVScz#_na!V8F{bMX zlnd3J+w1IGO+n--WNVeL|7ziGyfuel?7UFE8(-2oMg;WuC)v8& zb7gOp2|uaLPNOVj$l)1FNPbkKyNdJU+W2F5g8Vq=`C?nr-pjIbMu&9lfTh+*SdIPK zwLj$UTUVnQ#xS5skF%D<(;%KYT?DJ!ZRb_&tzdgo+?h|$9~$qqa=)(`tJyXl%9s6=$IJZ2lAIG0xH`#>nQsDc<3fA~)a-+mN=!%DG;%&Pg{T_v<8R2BxHkOmqT2rI z;!!U!D*EJJvC&>;toKr5_BFz&%VCyVdBPG~m$8<~g4;5k*vvrYXzS)rd*yGLnea&M z#uMUb6AveOMEm@We;EGK!LQ=2*`3DlmV^Rf`;t0G2~lEeRi)d!fh;N7w^uqf8;SIb zRicAVo|KJ}4g7Zs@pJW(>RX*zyu&p++2do3XET?Bni09aSA@j53^@qwp^>^6GqxL~ zZR0pIZ{K3E-bvxX_C+Bgp{|p|M{|7dT*t}3ZuhI)#k~If?{Cg3%@{%tkKjo>(Ta?j zG$9F`8~v+DU%{*_uB<(3JM9hiD)MSlasNJVZ5F}`6`%t5# zdgSjm^3pX9gRKP8BP_yEVkM6|2+OrWKeIh_s@YyOw*V1z?b){j->y6Qf0lg5e_Ja1nIWhSb4D z0K)43`6Gi7+kiHNP86Kk2E-uBqM+UmKmoGt09+7{e-#Z##lMmfY}^4PfYNSskYooS z2l00Se)L~p`z}BR>h7Y#NqRuvT|gC2qX!+$-on$u?JXF43UEU){zdZ70Yy7d@b3XJ z_W%Lx3{;eziyIhs0_evm754#72zx*1xDNvs0$V6ep$?!1k2e7vFzN_kg3#83Wk)Fd)=dB!pOfcT6mk&z7?m%) z38XqkQCHOjfCNndJLq$aq6@nf%sWQGgtdZe$AA#-i&g;5(azb!`+q3;+yUyH05C{p z2N-?=NZ}hI0W=F|Yq*P@HMqPDJO&R>Q27Iq;Qa|811UvMx!|ZDxIMac@2Oc&QSdq4FKR+Kk7EnAOQmK zb`PKh?@{~!8~?Slo}*}I9t2;V0|Jo7IWYPhFo%>ZgGc9pCM0C-A4vf|y8sxm0@ndl Gss974$cL-| delta 4277 zcmYjUcQo8xv}JxmwCE*>UPAOfYK$_7E{GtacL~u$CR+3)O8m4SM2S!Dq6-p6Cq$w~ zpD~Qljqv1q-&^b5yY9MY?{n9=`|k7CZQiCjL{U}q36Q-ORb^zjK|oNSo+>85h(JGK zm7~4P?Tip0+T^MZNNs%~zv{@~mh#&~+Kr%f=Ea>&8{LP`ZJSmfngf@+a_YW;EmRow zbVsOVA*H9U{3=Dv{z-DJi|X7pl@=$`;{t3bZZHJOCG5vNLk@1FE{Yu5BTDKZZKT zZ$L5ISw!gh8ibhG4;5`aHfARt|GcWj^PJDO^lbtvC(*O8d8N`lM^}OTMea}@jU+iY zczS7OGU~KAB-+4btVhR4cFx%8jO5i_q1u^?gCxw4a~@?fc)GHUZkOLd@D#;i8K-E* zcm-iB)tO%P&QTBdo{ixTeotnPGF_e?`Z_PNBvdDu9ayWaY)^WG+za-iu_3=p%eK&^ zEvw!+b)UA6f?2AP7dh1TSyy?nAt;Ui#`CISchL$mB9B62}t%{3c!8k1cQ?fjlu ztca_n^3f%I@dH_iO{LOMh<6P5`@#jh87@7teoj`deuCjDO%#~lX-mnv6@hsY$e zx)H~Cn5>zAUXpvQZ)BufH*vyGQps12jVI>`bOmQVjQ8Xgz!6<11ZS&*K|FiZj3Sa~ z_)kjzvyCbLk{tba(R-N~netj{@q&x=Q(ftOK5|Q#WJQF`?IG zVd?tt*wkOB(Xzc!-P+p7Tda&=er}Kls+^r_0p1Agr6(0OHd~(v!Vm> zlI1Mt$ZMo8E~S6$bk&@c)eqe=6t?o z`L4~WLWj}d;@}!2nYgprQXHrG%`b~;GE^5G$!SbMXqVzXhbUds)0#1e(^g^GXn>%i zpX|BLq-vs9*7eyC5Pl}^c9Wg?e7TtiNc}SWLj9R@;g13N&xs!~Kg{$rOF|j$3G&W& zpd84iybr0Uw=5mt{RQ8*aFL(-q zZjTm}c$X!eBA$GDVpZ^NS0OFacg8|`&79f>UV#^lVac}2sUozjcXmkge$e5qA0-oP zp#Ud3mJxV|Om;bCdS+*9t0`~wePnM?S93{Z5BiC7t8aP_>k+pRz>3{F3RpGhtse9D zs5a_`DX>x0iaVKUmWFu(k@p`<$pGgBtO<0lTvzD*eAU4i-%ZLpq1G1cMcf9OfXeiR zQ`b`jZTC=W^7CKEl|f>l@>5jFM%!^ve|{{BZ}XW)JpagGE2eS(<*NmV(8?n%O&g18 zQwfHDTC`2{P{YM4N;!#V&w3aa=Gle^r{5-x&&8NIgv}fso+lW)3n5}MrUP#>Kk{AwvEMK9O-)O)(bk5%WEvzkPezeTb> ztsuHk&8t6|bD!xU|0I_5%$~zbXzP?@D5mCZ?B)l$qxXg*f=D8Mg(9}w@kzC8hFDTH zXaJk1ny}jVSta8LRe00`B}d&QiJR5>8Z+^&WiH?DKiWJI{1&JZyk|dQdq*~QDj~%A z7@@caOt9*x(CcV_nH08jk2M2C-z^rcNjpuo&_q{4e&&Z$k8kI330t=beD3Eln!g#; z$VS=?S;;77?)I2hj+haFBrP490#$HVuc&F3vt!ev-4}Nv(2A7V?s4Hs>S@b!wY^rJ zHypK4HZxn<$mQA$j^ya%{WhI!T4(MmO6xZe<2!eZnb6=zE8L|i^2qLE$- z-9v8jn3m7sRyptd@7Hfc#=FUu`;V$__iVH995KpsM(7`Y*<2WhwS0<os!!HfgK=CCUDD z+B}k8Mx|vYJ!@f{a;y1)4f}`bTOh^s!6HTldWfW^p zQC~RCfnIx1S;Jc|yQp#tc8zYCC^bEkd1*rAc12824Bb|bne=wZ0D?c)?k1_+EZU66vw{kre$boNgzO_$5X8y~-jryPlYb#FSWxY2`6+UPu; zJaaF^E+;+(*EF{6#4+_Hp#2tgWZL1Muy%V=q?C_xFo&4cq7^syKbUR5U7I&COQ%A;&a)QRLuBZ?C0-@1wbe|#K@ZPE4mLs>Lf0qig zt#=%&qPLbv?|opPN_7gsEvKD{#nyX2Ug)n4F0@OW+feteO>1{}!#8X&n=@b$p_-DX z2S5I1xKV1JTey=R%yP|kcf4+Gk&x=Ix~w!c`X&V-I5=u!bZEar(q9r^2tO3c{W?_JBCvgzY@H^b- z@&^PF=9GVNJzq}pCgFiEqK+ZyX$XVC?wEl}_a}8UCIcG_HBXbae~t6#WrGj-Y^o$? zti(d6JG6#;dfD)sZoO>xczMsG9=al2L4F==XuA>Jw(E`_bldRh* z5#>rV?q~ZB75n+4_`uxa^fy7W9B@@ZYz0&JpO>^G5kZ0pBTdErLVXo&J|0w(p2?o8 z&XYtO(aDBO7*1rRb8*Un!WxXG(IA?~hgHw+z_g=0N`c4XE%$~U@8{nJS>F}~qh>n= zj7BkK_*m`mwzoZPA)zZ_jr&`hElwPUla`njRI9P{(<}+iOz*;c2xcf$qN-XDH`h~Sf z#j=erN(fbj=@(09Mk#`F7r`FpHmu?*SlJHtB!QUAKgdzid4C{y)#c+^N-Rkwo-#tKMfXM_h+{$FRbe52hL{Ghjy-~&!1sjy+|aR z+8#mPU*2fZWx?l01`_9TPBcrNAZi{_XqS?0hG?MGdHV;0P?s9@S-lxx{*RW*@5|N*>~rW1)16KlWJp!30U z;DliQ5**+Q6aWQKy@aWDvh?5x3J@dY1TRp4GQ^+{R7L|@kXL4_fa7Vi1e3VAB3o17Cgx`}YAANX#He zd~j8zeh?f#02o2bgR868{{z_vfHWaDICgN=d0QmNeh7#|PG&))L*Oy6xd4D4eqWWH oTLj4eDkcNx4gpS}d--3UhtcTXHR8WL(z==-X$S~fSAZ+@KfW&)mjD0& diff --git a/scripts/initializr/common/src/main/resources/skill/SKILL.md b/scripts/initializr/common/src/main/resources/skill/SKILL.md index 75ba2b06a90..2962219690f 100644 --- a/scripts/initializr/common/src/main/resources/skill/SKILL.md +++ b/scripts/initializr/common/src/main/resources/skill/SKILL.md @@ -66,10 +66,10 @@ Every path in this skill and its references is written relative to the **app dir | Run the `AbstractTest` runner | `mvn -pl common cn1:test` | `./gradlew cn1Test` | | Run JUnit tests | `mvn test` | `./gradlew test` | | Compile the CSS theme | `mvn -pl common cn1:css` (runs with every build) | `./gradlew cn1Css` | -| Android cloud build | `mvn -pl android package -Dcodename1.platform=android -Dcodename1.buildTarget=android-device` | `./gradlew buildAndroid` | -| iOS cloud build (debug / App Store) | `mvn -pl ios package -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-device` (`ios-device-release`) | `./gradlew buildIos` / `./gradlew buildIosRelease` | +| Android cloud build | `mvn package -Dcodename1.platform=android -Dcodename1.buildTarget=android-device` | `./gradlew buildAndroid` | +| iOS cloud build (debug / App Store) | `mvn package -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-device` (`ios-device-release`) | `./gradlew buildIos` / `./gradlew buildIosRelease` | | Xcode / Android Studio project, locally | `-Dcodename1.buildTarget=ios-source` / `android-source` | `./gradlew buildIosXcodeProject` / `./gradlew buildAndroidGradleProject` | -| Web app, locally / in the cloud | `mvn -pl javascript package -Dcodename1.platform=javascript -Dcodename1.buildTarget=local-javascript` (`javascript`) | `./gradlew buildJavascriptLocal` / `./gradlew buildJavascript` | +| Web app, locally / in the cloud | `mvn package -Dcodename1.platform=javascript -Dcodename1.buildTarget=local-javascript` (`javascript`) | `./gradlew buildJavascriptLocal` / `./gradlew buildJavascript` | | Desktop app (JVM) | `-Dcodename1.buildTarget=mac-os-x-desktop` / `windows-desktop` | `./gradlew buildMacDesktop` / `./gradlew buildWindowsDesktop` | | Native desktop | `windows-device` / `linux-device` / `mac-os-x-native` | `./gradlew buildWindowsDevice` / `buildLinuxDevice` / `buildMacNative` | | Any other build target | `-Dcodename1.platform=

-Dcodename1.buildTarget=` | `./gradlew cn1Build -Pcodename1.platform=

-Pcodename1.buildTarget=` | @@ -107,7 +107,7 @@ my-app/ Gradle projects always target Java 17; the plugin sets the compiler release itself. -### Maven (multi-module) +### Maven ``` my-app/ @@ -120,16 +120,16 @@ my-app/ │ ├── css/theme.css # CN1 CSS (NOT regular web CSS - see references/css.md) │ ├── l10n/ # i18n bundles (NOT src/main/resources!) │ └── guibuilder/ # Optional GUI builder XML -├── javase/ # Desktop simulator port -├── android/ # Android wrapper (built via build server or local Gradle) -├── ios/ # iOS wrapper (ParparVM) -├── javascript/ # TeaVM-based web port +├── javase/ # Optional: desktop simulator port +├── android/ # Optional: Android wrapper (built via build server or local Gradle) +├── ios/ # Optional: iOS wrapper (ParparVM) +├── javascript/ # Optional: web port └── backend/ # Optional server side (see references/backend.md) ├── application.properties # Server settings: in the MODULE ROOT, not src/main/resources └── src/main/java// # @RestController / @Service classes (Java 8 level, no UI classes) ``` -**Only edit `common/`** for the app, and `backend/` for the server. The platform modules are thin wrappers — touching them is almost always wrong unless you are intentionally writing a native interface. +**Only edit `common/`** for the app, and `backend/` for the server. The platform modules are optional thin wrappers: a project without them builds every platform from `common/`, and native interface implementations still go in `android/src/main/java`, `ios/src/main/objectivec` and so on, with or without a `pom.xml` beside them. Touching a platform module is almost always wrong unless you are intentionally writing a native interface. ## Java version and language features @@ -329,16 +329,17 @@ mvn -pl common cn1:debug mvn -pl common cn1:test # Cloud build for Android/iOS (requires CN1 build server creds) -mvn -pl android package -Dcodename1.platform=android -Dcodename1.buildTarget=android-device -mvn -pl ios package -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-device +mvn package -Dcodename1.platform=android -Dcodename1.buildTarget=android-device +mvn package -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-device # JavaScript / web bundle, built locally via the ParparVM to JavaScript translator. # Use -Dcodename1.buildTarget=javascript instead for the cloud builder; set # javascript.port=teavm only when the legacy compatibility fallback is needed. -mvn -pl javascript package -Dcodename1.platform=javascript -Dcodename1.buildTarget=local-javascript +mvn package -Dcodename1.platform=javascript -Dcodename1.buildTarget=local-javascript # The backend: run it on this JVM (dev profile = in-memory database + MCP dev tools at /mcp), # or package it as a native server binary. -Dcodename1.platform=backend is required. +# (Only in a project with a backend/ module.) CN1_PROFILE=dev mvn -pl backend -Dcodename1.platform=backend cn1:backend mvn -pl backend -Dcodename1.platform=backend cn1:backend-package ``` diff --git a/scripts/initializr/common/src/main/resources/skill/references/build-and-run.md b/scripts/initializr/common/src/main/resources/skill/references/build-and-run.md index 5356ce402ad..18070d38458 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/build-and-run.md +++ b/scripts/initializr/common/src/main/resources/skill/references/build-and-run.md @@ -10,12 +10,12 @@ A Codename One project can produce four kinds of artifacts. Some build entirely | --- | --- | --- | --- | | Desktop simulator | Local (your machine, JVM only) | `mvn -pl common cn1:run` / `cn1:debug` | `./gradlew run` / `debug` | | Unit tests | Local (the CN1 test runner inside a JVM) | `mvn -pl common cn1:test` | `./gradlew cn1Test` | -| Standalone desktop app (`.jar` + bundled JRE for Mac/Win/Linux) | Cloud (build server packages a JRE for each OS) | `mvn -pl javase package -Dcodename1.platform=javase -Dcodename1.buildTarget=mac-os-x-desktop` (or `windows-desktop`, `linux-desktop`) | `./gradlew buildMacDesktop` / `buildWindowsDesktop` | -| Android APK / AAB | Cloud by default; **also** locally if you run `cn1:install-android-sdk` and use the local Android build path | `mvn -pl android package -Dcodename1.platform=android -Dcodename1.buildTarget=android-device` | `./gradlew buildAndroid` (local project: `buildAndroidGradleProject`) | -| iOS app | Cloud, **or** locally as an Xcode project via `ios-source` | `mvn -pl ios package -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-device` (cloud) or `…-Dcodename1.buildTarget=ios-source` (local Xcode project) | `./gradlew buildIos` / `buildIosRelease` (local project: `buildIosXcodeProject`) | -| Mac Native app (AOT-compiled, same pipeline as iOS) | Cloud, **or** locally as an Xcode project via `mac-source` | `mvn -pl ios package -Dcodename1.platform=ios -Dcodename1.buildTarget=mac-os-x-native` (cloud) or `…-Dcodename1.buildTarget=mac-source` (local Xcode project) | `./gradlew buildMacNative` | +| Standalone desktop app (`.jar` + bundled JRE for Mac/Win/Linux) | Cloud (build server packages a JRE for each OS) | `mvn package -Dcodename1.platform=javase -Dcodename1.buildTarget=mac-os-x-desktop` (or `windows-desktop`, `linux-desktop`) | `./gradlew buildMacDesktop` / `buildWindowsDesktop` | +| Android APK / AAB | Cloud by default; **also** locally if you run `cn1:install-android-sdk` and use the local Android build path | `mvn package -Dcodename1.platform=android -Dcodename1.buildTarget=android-device` | `./gradlew buildAndroid` (local project: `buildAndroidGradleProject`) | +| iOS app | Cloud, **or** locally as an Xcode project via `ios-source` | `mvn package -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-device` (cloud) or `…-Dcodename1.buildTarget=ios-source` (local Xcode project) | `./gradlew buildIos` / `buildIosRelease` (local project: `buildIosXcodeProject`) | +| Mac Native app (AOT-compiled, same pipeline as iOS) | Cloud, **or** locally as an Xcode project via `mac-source` | `mvn package -Dcodename1.platform=ios -Dcodename1.buildTarget=mac-os-x-native` (cloud) or `…-Dcodename1.buildTarget=mac-source` (local Xcode project) | `./gradlew buildMacNative` | | Native Windows `.exe` (`win32`, ParparVM → clang-cl, no JVM) | Cloud (Linux build server cross-compiles); **also** locally on Windows, or as a project via `windows-source` | `mvn -pl common package -Dcodename1.platform=windows -Dcodename1.buildTarget=windows-device` (cloud) or `…-Dcodename1.buildTarget=local-windows-device` (local). A regular build returns x64 + arm64 release exes; add the `windows.debug` build hint for a single x64 debug exe. | `./gradlew buildWindowsDevice` | -| JavaScript / web bundle | Local (ParparVM to JavaScript translator, available to all users). Cloud builds use ParparVM by default and accept `javascript.port=teavm` as a compatibility fallback. | `mvn -pl javascript package -Dcodename1.platform=javascript -Dcodename1.buildTarget=local-javascript` | `./gradlew buildJavascriptLocal` (cloud: `buildJavascript`) | +| JavaScript / web bundle | Local (ParparVM to JavaScript translator, available to all users). Cloud builds use ParparVM by default and accept `javascript.port=teavm` as a compatibility fallback. | `mvn package -Dcodename1.platform=javascript -Dcodename1.buildTarget=local-javascript` | `./gradlew buildJavascriptLocal` (cloud: `buildJavascript`) | Any target without its own Gradle task (`linux-desktop`, `mac-source`, `local-windows-device`, ...) is `./gradlew cn1Build -Pcodename1.platform= -Pcodename1.buildTarget=`. @@ -56,7 +56,8 @@ mvn -pl common cn1:test mvn -pl common compile # Generate stubs for any com.codename1.system.NativeInterface in common/ -# (one per platform under android/, ios/, javase/, javascript/). +# (one per platform under android/, ios/, javase/, javascript/; the directory is +# created when needed, and is built from common/ when it has no pom.xml). mvn -pl common cn1:generate-native-interfaces # Generate a typed REST client from an OpenAPI 3.x spec. Writes @@ -90,30 +91,30 @@ mvn -pl common cn1:generate-graphql \ # --- Cloud builds (need a Codename One account; some need Enterprise tier) --- # Native iOS app (.ipa). Cloud-built. -mvn -pl ios package -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-device +mvn package -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-device # Local Xcode project, no cloud. See Xcode prerequisites below. -mvn -pl ios package -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-source +mvn package -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-source # Native Mac app (AOT-compiled, shares the iOS pipeline so the Mac slice # is rendered + compiled the same way as the iOS one). Cloud-built. -mvn -pl ios package -Dcodename1.platform=ios -Dcodename1.buildTarget=mac-os-x-native +mvn package -Dcodename1.platform=ios -Dcodename1.buildTarget=mac-os-x-native # Local Xcode project for the Mac slice. Open the project in Xcode and # select the Mac Catalyst destination to run. -mvn -pl ios package -Dcodename1.platform=ios -Dcodename1.buildTarget=mac-source +mvn package -Dcodename1.platform=ios -Dcodename1.buildTarget=mac-source # Native Android APK/AAB. Cloud-built by default. -mvn -pl android package -Dcodename1.platform=android -Dcodename1.buildTarget=android-device +mvn package -Dcodename1.platform=android -Dcodename1.buildTarget=android-device # JavaScript / web bundle. Built locally via the ParparVM to JavaScript translator. # Append -Dcodename1.buildTarget=javascript instead to use the cloud builder. -mvn -pl javascript package -Dcodename1.platform=javascript -Dcodename1.buildTarget=local-javascript +mvn package -Dcodename1.platform=javascript -Dcodename1.buildTarget=local-javascript # Standalone Mac / Windows / Linux desktop app. Cloud-built. -mvn -pl javase package -Dcodename1.platform=javase -Dcodename1.buildTarget=mac-os-x-desktop -mvn -pl javase package -Dcodename1.platform=javase -Dcodename1.buildTarget=windows-desktop -mvn -pl javase package -Dcodename1.platform=javase -Dcodename1.buildTarget=linux-desktop +mvn package -Dcodename1.platform=javase -Dcodename1.buildTarget=mac-os-x-desktop +mvn package -Dcodename1.platform=javase -Dcodename1.buildTarget=windows-desktop +mvn package -Dcodename1.platform=javase -Dcodename1.buildTarget=linux-desktop ``` ## Gradle task cheat sheet @@ -195,8 +196,8 @@ You only need the cloud build path when you want to **produce a native artifact* `-Dautomated=true` on a cloud `package` goal switches the build into non-interactive mode: no browser prompt, no credential dialog, build failure becomes a non-zero exit code, and `result.zip` is downloaded directly into `target/`. ```bash -mvn -pl ios package -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-device -Dautomated=true -mvn -pl android package -Dcodename1.platform=android -Dcodename1.buildTarget=android-device -Dautomated=true +mvn package -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-device -Dautomated=true +mvn package -Dcodename1.platform=android -Dcodename1.buildTarget=android-device -Dautomated=true # Gradle ./gradlew buildIos -Pautomated=true diff --git a/scripts/initializr/common/src/main/resources/skill/references/junit-testing.md b/scripts/initializr/common/src/main/resources/skill/references/junit-testing.md index 1c0c7e6a0e3..7d36f0bdde4 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/junit-testing.md +++ b/scripts/initializr/common/src/main/resources/skill/references/junit-testing.md @@ -42,7 +42,7 @@ The cn1app archetype generates a `common/pom.xml` and `javase/pom.xml` that alre ``` -The `common` module has Surefire skipped (`true`) — JUnit tests actually execute from the `javase` module, which mounts `common/src/test/java` via ``. That avoids running each test twice. +In a project with a `javase` module, the `common` module has Surefire skipped (`true`) — JUnit tests execute from the `javase` module, which mounts `common/src/test/java` via ``, so each test runs once. A project without a `javase` module runs them in `common` itself (its `cn1-javase-natives` profile), with the JavaSE native code on the test classpath. ## A minimal JUnit test @@ -74,7 +74,7 @@ class GreetingFormTest { } ``` -Run it with `mvn -pl javase test` (or `mvn test` from the project root). Filter to one method with `mvn -pl javase test -Dtest=GreetingFormTest#formShowsExpectedTitle`. +Run it with `mvn test` from the project root. Filter to one method with `mvn test -Dtest=GreetingFormTest#formShowsExpectedTitle`. ## Annotation reference @@ -254,12 +254,12 @@ Both runners discover their own classes in the same `common/src/test/java` tree: - `cn1:test` looks for classes that `implements com.codename1.testing.UnitTest` (the AbstractTest interface). - Surefire (JUnit Jupiter) looks for `@Test`-annotated methods. -They don't trip over each other. `mvn install` runs both — Surefire during `test`, then `cn1:test` from the javase module's test profile. If you want only one or the other: +They don't trip over each other. `mvn install` runs both — Surefire during `test`, then `cn1:test` from the `test` profile. If you want only one or the other: ```bash -mvn -pl javase test # only Surefire / JUnit -mvn -pl javase test -DskipTests # neither -mvn -pl javase verify -Dtest=NoMatchingTest # only cn1:test (skip Surefire by filtering it to nothing) +mvn test # only Surefire / JUnit +mvn test -DskipTests # neither +mvn verify -Dtest=NoMatchingTest # only cn1:test (skip Surefire by filtering it to nothing) ``` ## Side-by-side example diff --git a/scripts/initializr/common/src/main/resources/skill/references/native-interfaces.md b/scripts/initializr/common/src/main/resources/skill/references/native-interfaces.md index b07f58190d3..f3dace59f40 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/native-interfaces.md +++ b/scripts/initializr/common/src/main/resources/skill/references/native-interfaces.md @@ -68,13 +68,13 @@ Before you write any platform code, run the target you care about so you can see ```bash # iOS — produces an Xcode project under ios/target/codenameone/ios/dist/. Open it and # confirm the stub files appear and that the project builds (no missing-symbol errors). -mvn -pl ios package -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-source +mvn package -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-source # Android — cloud builds emit logs that show the stubs being compiled into the APK. -mvn -pl android package -Dcodename1.platform=android -Dcodename1.buildTarget=android-device -Dautomated=true +mvn package -Dcodename1.platform=android -Dcodename1.buildTarget=android-device -Dautomated=true # JavaScript — produces a web bundle; open dev tools and confirm the JS impl is included. -mvn -pl javascript package -Dcodename1.platform=javascript -Dcodename1.buildTarget=local-javascript +mvn package -Dcodename1.platform=javascript -Dcodename1.buildTarget=local-javascript # Desktop simulator — just run cn1:run and observe the bridge boots without errors. mvn -pl common cn1:run diff --git a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelIntegrationBuildTest.java b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelIntegrationBuildTest.java index 0a7909d1227..b3b0d03d36c 100644 --- a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelIntegrationBuildTest.java +++ b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelIntegrationBuildTest.java @@ -68,6 +68,8 @@ public boolean runTest() throws Exception { return true; } + buildGeneratedMavenLayouts(java17, buildClient); + if (java8Or11 == null) { System.out.println("[WARN] Skipping Java 8/11 integration build check. No JDK 8 or 11 found."); } else { @@ -171,6 +173,97 @@ private void buildGeneratedGradleProjects(Path java17) throws Exception { } } + /// Builds the Maven layouts a download gets once the plugin is 7.0.275 or newer -- + /// the minimal app, which builds every platform from common, and the backend-only + /// project -- against the plugin named by CN1_MAVEN_PLUGIN_VERSION (typically a + /// locally installed SNAPSHOT), resolved from CN1_MAVEN_REPO_LOCAL when set. The + /// generated poms name a released version that may not exist yet, so without the + /// variable the check is skipped, as the Gradle half is. + private void buildGeneratedMavenLayouts(Path java17, Path buildClient) throws Exception { + String version = System.getenv("CN1_MAVEN_PLUGIN_VERSION"); + if (version == null || version.length() == 0) { + System.out.println("[WARN] Skipping Maven layout build checks. Set CN1_MAVEN_PLUGIN_VERSION to a " + + "codenameone-maven-plugin version the build can resolve."); + return; + } + if (java17 == null) { + System.out.println("[WARN] Skipping Maven layout build checks. No JDK 17 found."); + return; + } + String repoLocal = System.getenv("CN1_MAVEN_REPO_LOCAL"); + Path homeDir = Files.createTempDirectory("initializr-home-layouts-"); + ensureCodenameOneHome(homeDir, buildClient); + + ProjectOptions backendOnly = layoutOptions(ProjectOptions.ProjectType.BACKEND_ONLY); + Path server = generateLayoutProject(backendOnly, "LayoutServerApp", "com.acme.initializr.server", version); + int exit = runMaven(server, homeDir, java17, repoLocal, "process-classes"); + assertTrue(exit == 0, "The backend-only Maven project should build | exitCode=" + exit); + assertTrue(Files.isRegularFile(server.resolve("target/classes/META-INF/cn1-backend-main")), + "The backend-only build should generate its entry point"); + + Path app = generateLayoutProject(layoutOptions(ProjectOptions.ProjectType.APP), "LayoutMinimalApp", + "com.acme.initializr.minimal", version); + assertTrue(!Files.exists(app.resolve("javase")) && !Files.exists(app.resolve("android")), + "The minimal layout should have no platform modules"); + exit = runMaven(app, homeDir, java17, repoLocal, "package", "-DskipTests=true", + "-Dcodename1.platform=android", "-Dcodename1.buildTarget=android-device", + "-Dcodename1.stageOnly=true"); + assertTrue(exit == 0, "The minimal app should stage an Android build from common | exitCode=" + exit); + File[] staged = app.resolve("common/target").toFile().listFiles( + (d, n) -> n.endsWith("-android-device-jar-with-dependencies.jar")); + assertTrue(staged != null && staged.length == 1, "common should stage the Android upload"); + exit = runMaven(app, homeDir, java17, repoLocal, "package", "-DskipTests=true", "-Pexecutable-jar", + "-Dcodename1.platform=javase"); + assertTrue(exit == 0, "The minimal app should package the desktop jar from common | exitCode=" + exit); + File[] desktop = app.resolve("common/target").toFile().listFiles( + (d, n) -> n.endsWith(".jar") && n.indexOf("-javase-") > 0); + assertTrue(desktop != null && desktop.length == 1, "common should write the desktop jar"); + } + + private ProjectOptions layoutOptions(ProjectOptions.ProjectType type) { + return new ProjectOptions(ProjectOptions.ThemeMode.LIGHT, ProjectOptions.Accent.DEFAULT, + true, false, ProjectOptions.PreviewLanguage.ENGLISH, ProjectOptions.JavaVersion.JAVA_17, null, + ProjectOptions.BuildTool.MAVEN, type); + } + + /// A project generated on the layouts side of the gate, with the poms pointed at `version`. + private Path generateLayoutProject(ProjectOptions options, String appName, String packageName, String version) + throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + GeneratorModel.createForPluginVersion(IDE.INTELLIJ, Template.BAREBONES, appName, packageName, options, + GeneratorModel.MAVEN_LAYOUTS_SINCE).writeProjectZip(output); + Path dir = Files.createTempDirectory("initializr-layout-" + appName + "-"); + unzipProject(output.toByteArray(), dir); + Path pom = dir.resolve("pom.xml"); + String text = new String(Files.readAllBytes(pom), "UTF-8"); + text = text.replaceAll("[^<]*", + "" + version + ""); + text = text.replaceAll("[^<]*", "" + version + ""); + Files.write(pom, text.getBytes("UTF-8")); + return dir; + } + + private int runMaven(Path projectDir, Path homeDir, Path javaHome, String repoLocal, String... args) + throws Exception { + List command = new ArrayList(); + command.add("mvn"); + command.add("-B"); + command.add("-Duser.home=" + homeDir.toString()); + if (repoLocal != null && repoLocal.length() > 0) { + command.add("-Dmaven.repo.local=" + repoLocal); + } + for (String a : args) { + command.add(a); + } + ProcessBuilder pb = new ProcessBuilder(command); + pb.directory(projectDir.toFile()); + pb.redirectErrorStream(true); + Map env = pb.environment(); + env.put("JAVA_HOME", javaHome.toString()); + env.put("PATH", javaHome.resolve("bin") + File.pathSeparator + env.get("PATH")); + return runAndReport(pb); + } + /// Puts `repoDir` first in the copy's pluginManagement repositories, so the /// plugin resolves from it; -Pcodename1.repository does the same for the /// framework the plugin adds. diff --git a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java index 1a3e0cbe7f1..a6eb725dc7a 100644 --- a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java +++ b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java @@ -49,7 +49,19 @@ public boolean runTest() throws Exception { validateCoordinateGuardRejectsBrokenArtifacts(); validateLocaleIndependentArtifactIds(); validateGradleRefusesUnsupportedCombinations(); - validateMavenIgnoresAppProjectType(); + validateVersionGate(); + validateMavenProjectTypeBeforeTheGate(); + validateMavenRefusals(); + for (Template template : Template.values()) { + for (ProjectOptions.ProjectType type : ProjectOptions.ProjectType.values()) { + for (IDE ide : IDE.values()) { + validateMavenLayoutCombination(template, type, false, ide); + if (type != ProjectOptions.ProjectType.BACKEND_ONLY) { + validateMavenLayoutCombination(template, type, true, ide); + } + } + } + } validateGradleTemplateDependencies(); validateAgentsMdMatchesBuildTool(); validateGradleLocalization(); @@ -117,7 +129,7 @@ private void validateGradleCombination(Template template, ProjectOptions.Project assertTrue(unixMode(zipData, "gradlew.bat") == 0100644, label + "gradlew.bat is not a Unix executable"); String settings = getText(entries, "settings.gradle.kts"); - assertContains(settings, "id(\"com.codenameone\") version \"7.0.273\"", + assertContains(settings, "id(\"com.codenameone\") version \"" + GeneratorModel.cn1PluginVersion() + "\"", label + "settings.gradle.kts should apply the Codename One plugin at the generated version"); assertContains(settings, "rootProject.name = \"" + GeneratorModel.toLowerCaseInvariant(mainClassName) + "\"", label + "settings.gradle.kts should name the project like the Maven artifactId"); @@ -256,9 +268,18 @@ private void validateGradleRefusesUnsupportedCombinations() throws Exception { gradleOptions(ProjectOptions.ProjectType.APP)), template.GRADLE_UNSUPPORTED_REASON, template + " cannot be generated for Gradle and must say why"); } - assertRefused(GeneratorModel.create(IDE.INTELLIJ, Template.BAREBONES, "NoMavenBackend", "com.acme.nomavenbackend", - ProjectOptions.defaults().withBuild(ProjectOptions.BuildTool.MAVEN, ProjectOptions.ProjectType.BACKEND_ONLY)), - "backend-only", "Maven has no backend-only scaffold"); + } + + /// Maven refusals on each side of the version gate. + private void validateMavenRefusals() { + ProjectOptions backendOnly = ProjectOptions.defaults().withBuild(ProjectOptions.BuildTool.MAVEN, + ProjectOptions.ProjectType.BACKEND_ONLY); + assertRefused(GeneratorModel.createForPluginVersion(IDE.INTELLIJ, Template.BAREBONES, "NoMavenBackend", + "com.acme.nomavenbackend", backendOnly, LEGACY_VERSION), + "backend-only", "Before the gate Maven has no backend-only scaffold"); + assertRefused(GeneratorModel.createForPluginVersion(IDE.INTELLIJ, Template.BAREBONES, "NoMavenBackend", + "com.acme.nomavenbackend", backendOnly.withPlatformModules(true), LAYOUTS_VERSION), + "platform modules", "A backend-only project with every platform module makes no sense"); } private void assertRefused(GeneratorModel model, String expectedReason, String message) { @@ -272,16 +293,213 @@ private void assertRefused(GeneratorModel model, String expectedReason, String m assertContains(reason, expectedReason, message); } - /// Maven projects always carry the backend module, so "App" and "App + backend" - /// are the same Maven download. - private void validateMavenIgnoresAppProjectType() throws Exception { - Map app = GeneratorModel.create(IDE.INTELLIJ, Template.BAREBONES, "SameApp", "com.acme.same", - ProjectOptions.defaults()).collectProjectEntries(); - Map withBackend = GeneratorModel.create(IDE.INTELLIJ, Template.BAREBONES, "SameApp", "com.acme.same", - ProjectOptions.defaults().withBuild(ProjectOptions.BuildTool.MAVEN, - ProjectOptions.ProjectType.APP_WITH_BACKEND)).collectProjectEntries(); + /// The last plugin without the Maven layouts, and the first with them. Fixed + /// rather than read from GeneratorModel so both sides stay covered whatever + /// release the initializr generates against. + private static final String LEGACY_VERSION = "7.0.274"; + private static final String LAYOUTS_VERSION = "7.0.275"; + + private static ProjectOptions mavenOptions(ProjectOptions.ProjectType type, boolean allModules) { + return ProjectOptions.defaults().withBuild(ProjectOptions.BuildTool.MAVEN, type).withPlatformModules(allModules); + } + + /// The full multi-module layout. Tests that read a platform module's pom ask for + /// it, so they hold on both sides of the version gate. + private static ProjectOptions fullMaven(ProjectOptions options) { + return options.withBuild(ProjectOptions.BuildTool.MAVEN, ProjectOptions.ProjectType.APP_WITH_BACKEND) + .withPlatformModules(true); + } + + private static Map mavenEntries(String pluginVersion, Template template, IDE ide, String appName, + String packageName, ProjectOptions options) throws IOException { + return readZipEntries(mavenZip(pluginVersion, template, ide, appName, packageName, options)); + } + + private static byte[] mavenZip(String pluginVersion, Template template, IDE ide, String appName, + String packageName, ProjectOptions options) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + GeneratorModel.createForPluginVersion(ide, template, appName, packageName, options, pluginVersion) + .writeProjectZip(output); + return output.toByteArray(); + } + + private void validateVersionGate() throws Exception { + assertTrue(GeneratorModel.isVersionAtLeast("7.0.275", "7.0.275"), "the gate's own release is in"); + assertFalse(GeneratorModel.isVersionAtLeast("7.0.274", "7.0.275"), "the release before it is not"); + assertTrue(GeneratorModel.isVersionAtLeast("7.0.1000", "7.0.275"), "parts compare as numbers"); + assertFalse(GeneratorModel.isVersionAtLeast("7.0.99", "7.0.275"), "parts compare as numbers, not text"); + assertTrue(GeneratorModel.isVersionAtLeast("7.1", "7.0.275"), "a newer minor is in"); + assertTrue(GeneratorModel.isVersionAtLeast("8.0-SNAPSHOT", "7.0.275"), "a snapshot reads as its number"); + assertFalse(GeneratorModel.isVersionAtLeast("", "7.0.275"), "no version is not newer"); + assertEqual(Boolean.valueOf(GeneratorModel.isVersionAtLeast(GeneratorModel.cn1PluginVersion(), LAYOUTS_VERSION)), + Boolean.valueOf(GeneratorModel.isMavenLayoutChoiceOffered()), + "the UI offers the layouts exactly when the current plugin has them"); + + // Before the gate a download is exactly today's, whatever the options say. + byte[] defaults = mavenZip(LEGACY_VERSION, Template.BAREBONES, IDE.INTELLIJ, "GateApp", "com.acme.gate", + ProjectOptions.defaults()); + byte[] minimalAsked = mavenZip(LEGACY_VERSION, Template.BAREBONES, IDE.INTELLIJ, "GateApp", "com.acme.gate", + mavenOptions(ProjectOptions.ProjectType.APP, false)); + byte[] fullAsked = mavenZip(LEGACY_VERSION, Template.BAREBONES, IDE.INTELLIJ, "GateApp", "com.acme.gate", + mavenOptions(ProjectOptions.ProjectType.APP_WITH_BACKEND, true)); + assertTrue(java.util.Arrays.equals(defaults, minimalAsked) && java.util.Arrays.equals(defaults, fullAsked), + "Before the gate the project type and modules choice must not change a Maven download"); + Map legacy = readZipEntries(defaults); + assertNotNull(legacy.get("javase/pom.xml"), "Before the gate a Maven app keeps its platform modules"); + assertNotNull(legacy.get("backend/pom.xml"), "Before the gate a Maven app keeps its backend module"); + String legacyRoot = getText(legacy, "pom.xml"); + assertFalse(legacyRoot.indexOf("${basedir}/javase/pom.xml") >= 0, + "Before the gate the root pom is not touched"); + assertContains(legacyRoot, "true", "Before the gate javase stays the default"); + String legacyCommon = getText(legacy, "common/pom.xml"); + assertFalse(legacyCommon.indexOf("compile-javase-natives") >= 0, + "Before the gate common/pom.xml must not bind goals the plugin does not have"); + assertNull(legacy.get("common/src/desktop/resources/NativeTheme.res"), "Before the gate nothing moves"); + + // From the gate on the default is the minimal layout. + Map minimal = mavenEntries(LAYOUTS_VERSION, Template.BAREBONES, IDE.INTELLIJ, "GateApp", + "com.acme.gate", ProjectOptions.defaults()); + assertNull(minimal.get("javase/pom.xml"), "From the gate a Maven app is minimal by default"); + assertNull(minimal.get("backend/pom.xml"), "From the gate the backend module is opt-in"); + assertContains(getText(minimal, "common/pom.xml"), "compile-javase-natives", + "From the gate common/pom.xml hosts the missing platforms"); + } + + /// Before the gate Maven projects always carry the backend module, so "App" and + /// "App + backend" are the same Maven download. + private void validateMavenProjectTypeBeforeTheGate() throws Exception { + Map app = mavenEntries(LEGACY_VERSION, Template.BAREBONES, IDE.INTELLIJ, "SameApp", + "com.acme.same", ProjectOptions.defaults()); + Map withBackend = mavenEntries(LEGACY_VERSION, Template.BAREBONES, IDE.INTELLIJ, "SameApp", + "com.acme.same", mavenOptions(ProjectOptions.ProjectType.APP_WITH_BACKEND, false)); assertEqual(app.keySet(), withBackend.keySet(), "Maven App and App + backend should be the same download"); assertNotNull(app.get("backend/pom.xml"), "Maven projects keep the backend module"); + + Map minimal = mavenEntries(LAYOUTS_VERSION, Template.BAREBONES, IDE.INTELLIJ, "SameApp", + "com.acme.same", ProjectOptions.defaults()); + Map minimalWithBackend = mavenEntries(LAYOUTS_VERSION, Template.BAREBONES, IDE.INTELLIJ, + "SameApp", "com.acme.same", mavenOptions(ProjectOptions.ProjectType.APP_WITH_BACKEND, false)); + for (String path : minimalWithBackend.keySet()) { + if (!minimal.containsKey(path)) { + assertTrue(path.startsWith("backend/"), "From the gate App + backend adds only backend/: " + path); + } + } + assertNotNull(minimalWithBackend.get("backend/pom.xml"), "From the gate App + backend has the backend module"); + } + + private static final String[] PLATFORM_DIRS = {"android/", "ios/", "javase/", "javascript/", "linux/", "win/"}; + + /// Every Maven download from the gate on, per template, project type, layout and IDE. + private void validateMavenLayoutCombination(Template template, ProjectOptions.ProjectType type, boolean allModules, + IDE ide) throws Exception { + String mainClassName = "Mvn" + template.ordinal() + type.ordinal() + (allModules ? 1 : 0) + ide.ordinal() + "App"; + String packageName = "com.acme.m" + template.ordinal() + ".t" + type.ordinal() + ".i" + ide.ordinal(); + String label = template + "/" + type + "/" + (allModules ? "full" : "minimal") + "/" + ide + ": "; + byte[] zipData = mavenZip(LAYOUTS_VERSION, template, ide, mainClassName, packageName, + mavenOptions(type, allModules)); + Map entries = readZipEntries(zipData); + String rootPom = getText(entries, "pom.xml"); + assertContains(rootPom, "" + LAYOUTS_VERSION + "", + label + "the root pom should name the plugin the download was generated against"); + assertCodenameOneRepository(rootPom, label); + assertNotNull(entries.get("mvnw"), label + "missing mvnw"); + assertTrue(unixMode(zipData, "mvnw") == 0100755, label + "mvnw must extract executable"); + for (String path : entries.keySet()) { + assertFalse(path.indexOf("com/example/myapp") >= 0, label + "unrefactored placeholder path: " + path); + assertFalse(path.startsWith("gradle") || path.endsWith(".kts"), label + "Gradle file leaked: " + path); + } + + if (type == ProjectOptions.ProjectType.BACKEND_ONLY) { + validateMavenBackendOnly(entries, ide, packageName, label); + return; + } + + for (String path : entries.keySet()) { + for (int i = 0; i < PLATFORM_DIRS.length; i++) { + if (!allModules) { + assertFalse(path.startsWith(PLATFORM_DIRS[i]), label + "minimal app has a platform module: " + path); + } + } + if (type != ProjectOptions.ProjectType.APP_WITH_BACKEND) { + assertFalse(path.startsWith("backend/"), label + "backend module without asking: " + path); + } + } + if (allModules) { + assertGeneratedPomCoordinates(entries, packageName, mainClassName); + assertContains(rootPom, "true", label + "full keeps javase as the default"); + } else { + assertFalse(rootPom.indexOf("") >= 0, + label + "a minimal root pom must not activate a javase module it does not have"); + assertNotNull(entries.get("common/src/desktop/resources/NativeTheme.res"), + label + "the desktop native theme moves to common"); + assertContains(getText(entries, "README.md"), "every platform is built from it", + label + "README should describe the minimal layout"); + for (String ideFile : new String[] {".idea/compiler.xml", ".idea/encodings.xml"}) { + if (entries.containsKey(ideFile)) { + assertFalse(getText(entries, ideFile).indexOf("javase") >= 0, + label + ideFile + " names a javase module the project does not have"); + } + } + } + if (type == ProjectOptions.ProjectType.APP_WITH_BACKEND) { + assertNotNull(entries.get("backend/pom.xml"), label + "App + backend has the backend module"); + assertContains(getText(entries, "README.md"), "## Backend", label + "README should explain the backend"); + } + String[] modules = {"javascript", "ios", "win", "linux", "backend", "android", "javase"}; + for (int i = 0; i < modules.length; i++) { + assertContains(removeWhitespace(rootPom), "${basedir}/" + modules[i] + "/pom.xml", + label + "the " + modules[i] + " profile should add the module only when it exists"); + } + String commonPom = getText(entries, "common/pom.xml"); + assertContains(commonPom, "compile-javase-natives", label + "common should host javase"); + assertContains(commonPom, "cn1-host-android", label + "common should host android"); + assertTrue(commonPom.indexOf("simulator") == commonPom.lastIndexOf("simulator"), + label + "common/pom.xml must declare the simulator profile once"); + assertCommonPom(entries, template, packageName, mainClassName, true); + assertNotNull(entries.get("build.sh"), label + "an app keeps its launchers"); + assertNotNull(entries.get("common/codenameone_settings.properties"), label + "missing app settings"); + } + + private void validateMavenBackendOnly(Map entries, IDE ide, String packageName, String label) { + String packagePath = packageName.replace('.', '/'); + for (String path : entries.keySet()) { + assertFalse(path.startsWith("common/") || path.startsWith("backend/"), + label + "a backend-only project is the server alone: " + path); + assertFalse(path.endsWith("/pom.xml"), label + "a backend-only project has one pom: " + path); + assertFalse(path.equals("build.sh") || path.equals("run.sh") || path.equals("build.bat") + || path.equals("run.bat") || path.equals("AGENTS.md") || path.startsWith(".agent-skills/"), + label + "app file leaked into a backend-only project: " + path); + if (path.endsWith(".java") || path.endsWith(".properties") || path.endsWith(".md") + || path.endsWith(".xml") || path.endsWith(".json")) { + String text = getText(entries, path); + assertFalse(text.indexOf("gradle") >= 0 || text.indexOf("Gradle") >= 0, + label + path + " mentions Gradle"); + assertFalse(text.indexOf("__BACKEND__") >= 0 || text.indexOf("${package}") >= 0, + label + path + " has an unrendered token"); + } + } + String pom = removeWhitespace(getText(entries, "pom.xml")); + assertFalse(pom.indexOf("") >= 0, label + "a backend-only pom has no parent"); + assertContains(pom, "codenameone-backend", label + "the backend runtime"); + assertContains(pom, "process-annotations", label + "the generated entry point"); + assertContains(pom, "" + packageName + "", label + "the chosen groupId"); + assertNotNull(entries.get("application.properties"), label + "settings at the root"); + assertNotNull(entries.get("application-dev.properties"), label + "dev profile at the root"); + String api = getText(entries, "src/main/java/" + packagePath + "/Api.java"); + assertContains(api, "package " + packageName + ";", label + "Api.java in the chosen package"); + assertContains(api, "./mvnw cn1:backend", label + "Api.java should give the Maven command"); + assertNotNull(entries.get("src/main/java/" + packagePath + "/Greeter.java"), label + "missing Greeter.java"); + assertContains(getText(entries, "README.md"), "./mvnw cn1:backend-package", label + "README should give the goals"); + assertNotNull(entries.get(".gitignore"), label + "missing .gitignore"); + if (ide == IDE.INTELLIJ) { + assertContains(getText(entries, ".idea/runConfigurations/Run_Backend.xml"), " diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/AbstractCN1Mojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/AbstractCN1Mojo.java index e28f916f26b..4a0794e3ede 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/AbstractCN1Mojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/AbstractCN1Mojo.java @@ -627,6 +627,10 @@ public void execute() throws MojoExecutionException, MojoFailureException { } } overlayCommandLineBuildHints(properties); + } else if (project.getBasedir() != null + && new File(project.getBasedir(), com.codename1.project.ProjectLayout.BACKEND_SETTINGS_FILE).isFile()) { + // A backend: it has application.properties, never an app's settings file. + getLog().debug("A backend project; there is no codenameone_settings.properties to load"); } else { getLog().warn("Failed to find CN1 Project directory. codenameone_settings.properties will not be loaded"); if (project.getCompileSourceRoots() != null && !project.getCompileSourceRoots().isEmpty()) { diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CompileJavaSENativesMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CompileJavaSENativesMojo.java index 8642952ef06..d8223ff675f 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CompileJavaSENativesMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CompileJavaSENativesMojo.java @@ -79,6 +79,11 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException List classpath = new ArrayList(); File cn1libSources = new File(buildDir, path("generated-sources", "cn1libs-javase")); + // Rebuilt from the current archives every time, so a library that was upgraded + // or removed leaves none of its old native sources behind to be compiled. The + // archives keep their entries' timestamps, so an unchanged library still reads + // as unchanged below. + FileUtils.deleteQuietly(cn1libSources); try { String testOutput = new File(project.getBuild().getTestOutputDirectory()).getAbsolutePath(); for (String element : project.getTestClasspathElements()) { @@ -118,7 +123,10 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException String[] level = sourceLevel(); File stamp = new File(out.getParentFile(), "inputs.txt"); - String inputs = describeInputs(sources, classpath, level); + List resourceFiles = new ArrayList(); + collectFiles(resources, resourceFiles); + Collections.sort(resourceFiles); + String inputs = describeInputs(sources, resourceFiles, classpath, level); if (isUpToDate(stamp, inputs, sources, resources)) { getLog().debug("JavaSE native code is up to date at " + out); return; @@ -221,7 +229,12 @@ private static String valueOf(Xpp3Dom config, String name, String fallback) { return v.length() == 0 || v.startsWith("${") ? fallback : v; } - static String describeInputs(List sources, List classpath, String[] level) { + /** + * Everything the compile read, by name: the level, the classpath, the sources and the + * resources. Names catch what timestamps cannot, a file that was deleted. + */ + static String describeInputs(List sources, List resources, List classpath, + String[] level) { StringBuilder sb = new StringBuilder(); sb.append("level=").append(level[0]).append('/').append(level[1]).append('/').append(level[2]).append('\n'); for (String element : classpath) { @@ -230,6 +243,9 @@ static String describeInputs(List sources, List classpath, String[ for (File source : sources) { sb.append("src=").append(source.getAbsolutePath()).append('\n'); } + for (File resource : resources) { + sb.append("res=").append(resource.getAbsolutePath()).append('\n'); + } return sb.toString(); } @@ -277,6 +293,20 @@ private void extract(File zip, File dest) { unzip.execute(); } + private static void collectFiles(File dir, List out) { + File[] children = dir.listFiles(); + if (children == null) { + return; + } + for (File child : children) { + if (child.isDirectory()) { + collectFiles(child, out); + } else { + out.add(child); + } + } + } + private static void collectJavaFiles(File dir, List out) { File[] children = dir.listFiles(); if (children == null) { diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/UpdateCodenameOneMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/UpdateCodenameOneMojo.java index ac31fc18c69..24866991a4d 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/UpdateCodenameOneMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/UpdateCodenameOneMojo.java @@ -74,10 +74,16 @@ public class UpdateCodenameOneMojo extends AbstractCN1Mojo { @Override protected void executeImpl() throws MojoExecutionException, MojoFailureException { - if (!isCN1ProjectDir()) { + // A backend-only project is a single module at the root, with no app module to + // run from: its own pom carries the versions, and there are no app tools + // (designer, GUI builder) to refresh. + boolean backendRoot = isBackendOnlyRoot(project.getBasedir()); + if (!backendRoot && !isCN1ProjectDir()) { return; } - updateCodenameOne(true); + if (!backendRoot) { + updateCodenameOne(true); + } String existingCn1Version = project.getModel().getProperties().getProperty("cn1.version"); String existingCn1PluginVersion = project.getModel().getProperties().getProperty("cn1.plugin.version"); @@ -155,7 +161,8 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException Model model = null; ModelETL modelETL; - File pomFile = new File(project.getParent().getBasedir(), "pom.xml"); + File pomFile = backendRoot ? new File(project.getBasedir(), "pom.xml") + : new File(project.getParent().getBasedir(), "pom.xml"); /* try (FileInputStream fis = new FileInputStream(pomFile)) { model = pomReader.read(new InputStreamReader(fis, "UTF-8"), false); @@ -304,6 +311,26 @@ private static boolean wouldDowngrade(String candidate, String current) { return new ComparableVersion(candidate).compareTo(new ComparableVersion(current)) < 0; } + /** + * Whether {@code dir} is a backend-only project: the backend itself at the root of + * the build, not the backend module of an application. + */ + static boolean isBackendOnlyRoot(File dir) { + com.codename1.project.ProjectLayout layout = dir == null ? null + : com.codename1.project.ProjectLayouts.detect(dir); + return layout != null && layout.kind() == com.codename1.project.ProjectKind.BACKEND + && layout.buildSystem() == com.codename1.project.BuildSystem.MAVEN + && canonical(layout.rootDir()).equals(canonical(dir)); + } + + private static File canonical(File f) { + try { + return f.getCanonicalFile(); + } catch (IOException ex) { + return f.getAbsoluteFile(); + } + } + /** The first plugin release with the goals a minimal project's common pom binds. */ static final String HOSTED_PLATFORMS_SINCE = "7.0.275"; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HostedPlatformsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HostedPlatformsTest.java index c516b377a3f..d75615cc731 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HostedPlatformsTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HostedPlatformsTest.java @@ -116,6 +116,24 @@ void anUpdateBelowTheHostedGoalsIsRefusedForAProjectThatUsesThem() { "hosted-platform")); } + @Test + void updateRecognisesABackendOnlyRootButNotAnAppsBackendModule() throws IOException { + String backendPom = "codenameone-backend" + + ""; + File server = new File(tmp, "server"); + write(new File(server, "pom.xml"), backendPom); + write(new File(server, "application.properties"), ""); + assertTrue(UpdateCodenameOneMojo.isBackendOnlyRoot(server)); + + File root = app(); + File backend = new File(root, "backend"); + write(new File(backend, "pom.xml"), backendPom); + write(new File(backend, "application.properties"), ""); + assertFalse(UpdateCodenameOneMojo.isBackendOnlyRoot(backend), + "an app's backend module is updated through the app's root pom"); + assertFalse(UpdateCodenameOneMojo.isBackendOnlyRoot(new File(root, "common"))); + } + @Test void theDesktopJarKeepsTheJavaseModulesName() { assertEquals("myapp-javase-1.0", JavaSEExecutableJarMojo.javaseFinalName("myapp-common", "1.0")); @@ -148,7 +166,10 @@ void nativesRecompileWhenAnythingTheyReadChanges() throws Exception { cp.mkdirs(); File resources = new File(tmp, "resources"); List sources = Collections.singletonList(src); - String inputs = CompileJavaSENativesMojo.describeInputs(sources, + File resource = new File(resources, "native.properties"); + write(resource, "x=1"); + List resourceFiles = Collections.singletonList(resource); + String inputs = CompileJavaSENativesMojo.describeInputs(sources, resourceFiles, Collections.singletonList(cp.getAbsolutePath()), new String[] {null, "1.8", "1.8"}); File stamp = new File(tmp, "inputs.txt"); assertFalse(CompileJavaSENativesMojo.isUpToDate(stamp, inputs, sources, resources), "never compiled"); @@ -157,12 +178,18 @@ void nativesRecompileWhenAnythingTheyReadChanges() throws Exception { stamp.setLastModified(now); src.setLastModified(now - 10000); cp.setLastModified(now - 10000); + resource.setLastModified(now - 10000); + resources.setLastModified(now - 10000); assertTrue(CompileJavaSENativesMojo.isUpToDate(stamp, inputs, sources, resources)); src.setLastModified(now + 10000); assertFalse(CompileJavaSENativesMojo.isUpToDate(stamp, inputs, sources, resources), "an edited source"); src.setLastModified(now - 10000); - String moreSources = CompileJavaSENativesMojo.describeInputs(Arrays.asList(src, new File(tmp, "B.java")), + String noResources = CompileJavaSENativesMojo.describeInputs(sources, Collections.emptyList(), Collections.singletonList(cp.getAbsolutePath()), new String[] {null, "1.8", "1.8"}); + assertFalse(CompileJavaSENativesMojo.isUpToDate(stamp, noResources, sources, resources), + "a deleted resource, which no timestamp shows, must still rebuild the output"); + String moreSources = CompileJavaSENativesMojo.describeInputs(Arrays.asList(src, new File(tmp, "B.java")), + resourceFiles, Collections.singletonList(cp.getAbsolutePath()), new String[] {null, "1.8", "1.8"}); assertFalse(CompileJavaSENativesMojo.isUpToDate(stamp, moreSources, sources, resources), "a new source"); File appClass = new File(cp, "App.class"); write(appClass, "x"); @@ -233,6 +260,64 @@ void theHostedProfilesStepAsideForAModule() throws Exception { assertFalse(common.contains("simulator")); } + /** + * Every dependency in the fragment resolves under BOTH root poms that carry it: the + * archetype's and the initializr's (common.zip). A dependency without a version needs + * a dependencyManagement entry in each, and the initializr's root pom manages fewer + * artifacts than the archetype's -- cn1-binaries-javase without a version broke the + * effective model of every initializr project's simulator profile. + */ + @Test + void everyFragmentDependencyResolvesUnderBothRootPoms() throws Exception { + File fragment = new File("../../scripts/initializr/common/src/main/resources/common-hosted-platform-profiles.xml"); + String xml = new String(Files.readAllBytes(fragment.toPath()), StandardCharsets.UTF_8); + Element profiles = parse("" + xml + ""); + + Set archetypeManaged = managedArtifactIds(parse(new String(Files.readAllBytes(new File( + "../cn1app-archetype/src/main/resources/archetype-resources/pom.xml").toPath()), StandardCharsets.UTF_8))); + Set initializrManaged; + try (java.util.zip.ZipFile zip = new java.util.zip.ZipFile( + "../../scripts/initializr/common/src/main/resources/common.zip")) { + java.io.InputStream in = zip.getInputStream(zip.getEntry("pom.xml")); + initializrManaged = managedArtifactIds(DocumentBuilderFactory.newInstance().newDocumentBuilder() + .parse(in).getDocumentElement()); + } + int checked = 0; + NodeList deps = profiles.getElementsByTagName("dependency"); + for (int i = 0; i < deps.getLength(); i++) { + Element dep = (Element) deps.item(i); + if (!"dependencies".equals(((Element) dep.getParentNode()).getTagName()) + || !"profile".equals(((Element) dep.getParentNode().getParentNode()).getTagName())) { + continue; // a plugin's own dependency + } + checked++; + String artifact = text(dep, "artifactId"); + if (text(dep, "version") != null) { + continue; + } + assertTrue(archetypeManaged.contains(artifact), artifact + " has no version and the archetype's root pom does not manage it"); + assertTrue(initializrManaged.contains(artifact), artifact + " has no version and the initializr's root pom does not manage it"); + } + assertTrue(checked > 0, "found no profile dependencies to check"); + } + + private static Element parse(String xml) throws Exception { + return DocumentBuilderFactory.newInstance().newDocumentBuilder() + .parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))).getDocumentElement(); + } + + private static Set managedArtifactIds(Element project) { + Set out = new HashSet(); + Element management = child(project, "dependencyManagement"); + Element deps = management == null ? null : child(management, "dependencies"); + if (deps != null) { + for (Element dep : children(deps, "dependency")) { + out.add(text(dep, "artifactId")); + } + } + return out; + } + private static List children(Element parent, String name) { List out = new ArrayList(); NodeList nodes = parent.getChildNodes(); diff --git a/scripts/initializr/common/src/main/resources/common-hosted-platform-profiles.xml b/scripts/initializr/common/src/main/resources/common-hosted-platform-profiles.xml index 7b36d106928..eb6e7ad8394 100644 --- a/scripts/initializr/common/src/main/resources/common-hosted-platform-profiles.xml +++ b/scripts/initializr/common/src/main/resources/common-hosted-platform-profiles.xml @@ -7,6 +7,10 @@ later never builds a platform twice. Requires codenameone-maven-plugin 7.0.275 or newer. + Each dependency here either has a version or is one the root pom manages + (codenameone-core, codenameone-javase); not every root pom manages + cn1-binaries-javase, so it carries ${cn1.version} itself. + Native interface implementations go where they always have, for example ../android/src/main/java - cn1:generate-native-interfaces creates the directory - and are picked up from there whether or not a pom sits beside @@ -380,6 +384,7 @@ com.codenameone cn1-binaries-javase + ${cn1.version} pom runtime @@ -457,6 +462,7 @@ com.codenameone cn1-binaries-javase + ${cn1.version} pom runtime @@ -549,6 +555,7 @@ com.codenameone cn1-binaries-javase + ${cn1.version} pom runtime @@ -625,6 +632,7 @@ com.codenameone cn1-binaries-javase + ${cn1.version} pom runtime @@ -697,6 +705,7 @@ com.codenameone cn1-binaries-javase + ${cn1.version} pom runtime diff --git a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelIntegrationBuildTest.java b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelIntegrationBuildTest.java index b3b0d03d36c..104ea95bf3b 100644 --- a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelIntegrationBuildTest.java +++ b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelIntegrationBuildTest.java @@ -218,6 +218,12 @@ private void buildGeneratedMavenLayouts(Path java17, Path buildClient) throws Ex File[] desktop = app.resolve("common/target").toFile().listFiles( (d, n) -> n.endsWith(".jar") && n.indexOf("-javase-") > 0); assertTrue(desktop != null && desktop.length == 1, "common should write the desktop jar"); + // The simulator profile's model: every dependency it adds must resolve under this + // download's own root pom, which manages fewer artifacts than the archetype's. + exit = runMaven(app, homeDir, java17, repoLocal, "initialize", "-Psimulator", "-Dcodename1.platform=javase"); + assertTrue(exit == 0, "The minimal app's simulator profile should resolve | exitCode=" + exit); + assertTrue(Files.isRegularFile(app.resolve("common/target/codenameone/simulator-classpath.args")), + "The simulator should be prepared from common"); } private ProjectOptions layoutOptions(ProjectOptions.ProjectType type) { From 912632d11778df73eedb3be0d197d9988a497cb1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:11:14 +0300 Subject: [PATCH 3/5] Address the second review round - The hosted simulator refuses a JDK older than 11 up front, as cn1:run and cn1:debug do, instead of letting a Java 8 launcher read its argument file as a class name. The simulator needs JDK 11+ at runtime whatever the project's source level; the integration test prepares it on the newer JDK CI names. - Desktop profiles that run JavaSE code from common, executable-jar and test included, ship cn1-binaries-javase like the javase module, so the packaged desktop app keeps the bundled FFmpeg binaries. - compile-javase-natives keys its cache on each cn1lib archive's content hash. - cn1:update fails with a clear message when a project lacks the cn1.version or cn1.plugin.version property, instead of an NPE. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../maven/CompileJavaSENativesMojo.java | 37 ++++++++++-- .../maven/PrepareSimulatorClasspathMojo.java | 13 ++++- .../maven/UpdateCodenameOneMojo.java | 9 +++ .../codename1/maven/HostedPlatformsTest.java | 56 ++++++++++++++++++- .../cn1app-minimal-layout-test.sh | 7 ++- .../common-hosted-platform-profiles.xml | 14 +++++ 6 files changed, 126 insertions(+), 10 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CompileJavaSENativesMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CompileJavaSENativesMojo.java index d8223ff675f..feaca9d8860 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CompileJavaSENativesMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CompileJavaSENativesMojo.java @@ -78,6 +78,7 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException File buildDir = new File(project.getBuild().getDirectory()); List classpath = new ArrayList(); + List archives = new ArrayList(); File cn1libSources = new File(buildDir, path("generated-sources", "cn1libs-javase")); // Rebuilt from the current archives every time, so a library that was upgraded // or removed leaves none of its old native sources behind to be compiled. The @@ -93,6 +94,9 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException } if ("nativese.zip".equals(f.getName())) { extract(f, cn1libSources); + // By content, not timestamps: a reproducible archive of a new library + // version can carry the same entry names and dates as the old one. + archives.add(f.getAbsolutePath() + "#" + sha256(f)); continue; } classpath.add(element); @@ -126,7 +130,7 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException List resourceFiles = new ArrayList(); collectFiles(resources, resourceFiles); Collections.sort(resourceFiles); - String inputs = describeInputs(sources, resourceFiles, classpath, level); + String inputs = describeInputs(sources, resourceFiles, archives, classpath, level); if (isUpToDate(stamp, inputs, sources, resources)) { getLog().debug("JavaSE native code is up to date at " + out); return; @@ -230,11 +234,13 @@ private static String valueOf(Xpp3Dom config, String name, String fallback) { } /** - * Everything the compile read, by name: the level, the classpath, the sources and the - * resources. Names catch what timestamps cannot, a file that was deleted. + * Everything the compile read, by name: the level, the classpath, the sources, the + * resources, and each cn1lib native archive with its content hash. Names catch what + * timestamps cannot, a file that was deleted; the hash, a rebuilt archive whose + * entries kept their names and dates. */ - static String describeInputs(List sources, List resources, List classpath, - String[] level) { + static String describeInputs(List sources, List resources, List archives, + List classpath, String[] level) { StringBuilder sb = new StringBuilder(); sb.append("level=").append(level[0]).append('/').append(level[1]).append('/').append(level[2]).append('\n'); for (String element : classpath) { @@ -246,6 +252,9 @@ static String describeInputs(List sources, List resources, List 0) { + digest.update(buf, 0, n); + } + StringBuilder sb = new StringBuilder(); + for (byte b : digest.digest()) { + sb.append(Character.forDigit((b >> 4) & 0xf, 16)).append(Character.forDigit(b & 0xf, 16)); + } + return sb.toString(); + } catch (IOException | java.security.NoSuchAlgorithmException ex) { + throw new MojoExecutionException("Failed to read " + f, ex); + } + } + private void extract(File zip, File dest) { Expand unzip = (Expand) antProject.createTask("unzip"); unzip.setSrc(zip); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/PrepareSimulatorClasspathMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/PrepareSimulatorClasspathMojo.java index 51d9d774f2a..91398445397 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/PrepareSimulatorClasspathMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/PrepareSimulatorClasspathMojo.java @@ -152,7 +152,18 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException } if (isHosting()) { // exec:exec's cannot be extended with the natives directory, so a - // hosted simulator is launched with `java @` instead. + // hosted simulator is launched with `java @` instead. Argument files + // need the Java 9 launcher, which costs nothing: the simulator itself requires + // JDK 11 or newer at runtime, whatever source level the project compiles at + // (a "Java 8" project is a source level, not a simulator JDK), and on an older + // JDK it fails anyway, less clearly. Say so here, as cn1:run and cn1:debug do, + // rather than let the launcher read "@file" as a class name. + try { + JavaVersionUtil.requireRuntimeJavaVersion(JavaVersionUtil.MIN_RUNTIME_JAVA_VERSION, + "run the Codename One simulator"); + } catch (com.codename1.build.BuildFailureException ex) { + throw new MojoFailureException(ex.getMessage(), ex); + } File argFile = new File(project.getBuild().getDirectory(), path("codenameone", "simulator-classpath.args")); try { diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/UpdateCodenameOneMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/UpdateCodenameOneMojo.java index 24866991a4d..abbf02e18d9 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/UpdateCodenameOneMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/UpdateCodenameOneMojo.java @@ -87,6 +87,15 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException String existingCn1Version = project.getModel().getProperties().getProperty("cn1.version"); String existingCn1PluginVersion = project.getModel().getProperties().getProperty("cn1.plugin.version"); + if (existingCn1Version == null || existingCn1PluginVersion == null) { + // Generated projects declare both; one edited to hard-code the versions in its + // dependencies has nothing for this goal to rewrite. + throw new MojoFailureException("cn1:update rewrites the cn1.version and cn1.plugin.version " + + "properties, and this project does not declare " + + (existingCn1Version == null ? "cn1.version" : "cn1.plugin.version") + + ". Add them to " + (backendRoot ? "its pom.xml" : "the root pom.xml") + + " and use them for the Codename One dependency and plugin versions."); + } boolean isAutoVersion = false; if (newVersion == null || newVersion.isEmpty()) { if (!existingCn1Version.endsWith("-SNAPSHOT")) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HostedPlatformsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HostedPlatformsTest.java index d75615cc731..6e4e1884a83 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HostedPlatformsTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HostedPlatformsTest.java @@ -169,7 +169,7 @@ void nativesRecompileWhenAnythingTheyReadChanges() throws Exception { File resource = new File(resources, "native.properties"); write(resource, "x=1"); List resourceFiles = Collections.singletonList(resource); - String inputs = CompileJavaSENativesMojo.describeInputs(sources, resourceFiles, + String inputs = CompileJavaSENativesMojo.describeInputs(sources, resourceFiles, Collections.emptyList(), Collections.singletonList(cp.getAbsolutePath()), new String[] {null, "1.8", "1.8"}); File stamp = new File(tmp, "inputs.txt"); assertFalse(CompileJavaSENativesMojo.isUpToDate(stamp, inputs, sources, resources), "never compiled"); @@ -184,12 +184,13 @@ void nativesRecompileWhenAnythingTheyReadChanges() throws Exception { src.setLastModified(now + 10000); assertFalse(CompileJavaSENativesMojo.isUpToDate(stamp, inputs, sources, resources), "an edited source"); src.setLastModified(now - 10000); - String noResources = CompileJavaSENativesMojo.describeInputs(sources, Collections.emptyList(), + String noResources = CompileJavaSENativesMojo.describeInputs(sources, Collections.emptyList(), Collections.emptyList(), Collections.singletonList(cp.getAbsolutePath()), new String[] {null, "1.8", "1.8"}); assertFalse(CompileJavaSENativesMojo.isUpToDate(stamp, noResources, sources, resources), "a deleted resource, which no timestamp shows, must still rebuild the output"); String moreSources = CompileJavaSENativesMojo.describeInputs(Arrays.asList(src, new File(tmp, "B.java")), - resourceFiles, Collections.singletonList(cp.getAbsolutePath()), new String[] {null, "1.8", "1.8"}); + resourceFiles, Collections.emptyList(), Collections.singletonList(cp.getAbsolutePath()), + new String[] {null, "1.8", "1.8"}); assertFalse(CompileJavaSENativesMojo.isUpToDate(stamp, moreSources, sources, resources), "a new source"); File appClass = new File(cp, "App.class"); write(appClass, "x"); @@ -301,6 +302,55 @@ void everyFragmentDependencyResolvesUnderBothRootPoms() throws Exception { assertTrue(checked > 0, "found no profile dependencies to check"); } + /** + * Every profile that runs JavaSE code from common carries the desktop runtime binaries, + * as the javase module does: without them the bundled FFmpeg media implementation is + * missing from the simulator, the tests and the packaged desktop app. + */ + @Test + void everyDesktopProfileShipsTheDesktopBinaries() throws Exception { + File fragment = new File("../../scripts/initializr/common/src/main/resources/common-hosted-platform-profiles.xml"); + Element profiles = parse("" + new String(Files.readAllBytes(fragment.toPath()), + StandardCharsets.UTF_8) + ""); + Set desktop = new HashSet(Arrays.asList("simulator", "idea-simulator", "debug-simulator", + "debug-eclipse", "run-desktop", "executable-jar", "test")); + Set seen = new HashSet(); + for (Element profile : children(profiles, "profile")) { + String id = text(profile, "id"); + if (!desktop.contains(id)) { + continue; + } + seen.add(id); + boolean found = false; + Element deps = child(profile, "dependencies"); + for (Element dep : deps == null ? Collections.emptyList() : children(deps, "dependency")) { + found |= "cn1-binaries-javase".equals(text(dep, "artifactId")) && "runtime".equals(text(dep, "scope")); + } + assertTrue(found, id + " runs JavaSE code without the desktop runtime binaries"); + } + assertEquals(desktop, seen); + } + + @Test + void aNativeArchiveIsKeyedByItsContent() throws Exception { + File zip = new File(tmp, "nativese.zip"); + write(zip, "first"); + String before = CompileJavaSENativesMojo.sha256(zip); + long stamp = zip.lastModified(); + write(zip, "other"); + zip.setLastModified(stamp); + assertFalse(before.equals(CompileJavaSENativesMojo.sha256(zip)), + "a rebuilt archive with the same names and dates must still recompile"); + List none = Collections.emptyList(); + String[] level = {null, "1.8", "1.8"}; + assertFalse(CompileJavaSENativesMojo.describeInputs(none, none, + Collections.singletonList(zip.getAbsolutePath() + "#" + before), Collections.emptyList(), level) + .equals(CompileJavaSENativesMojo.describeInputs(none, none, + Collections.singletonList(zip.getAbsolutePath() + "#" + CompileJavaSENativesMojo.sha256(zip)), + Collections.emptyList(), level)), + "the archive hash is part of what the cache compares"); + } + private static Element parse(String xml) throws Exception { return DocumentBuilderFactory.newInstance().newDocumentBuilder() .parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))).getDocumentElement(); diff --git a/maven/integration-tests/cn1app-minimal-layout-test.sh b/maven/integration-tests/cn1app-minimal-layout-test.sh index 419028855bc..b22538490f7 100755 --- a/maven/integration-tests/cn1app-minimal-layout-test.sh +++ b/maven/integration-tests/cn1app-minimal-layout-test.sh @@ -130,7 +130,10 @@ grep -q 'tests="1"' "$REPORT" || fail "the JUnit test did not run exactly once" grep -q 'failures="0"' "$REPORT" || fail "the JUnit test failed" echo "== The simulator classpath" -mvn -B -ntp initialize -Psimulator -Dcodename1.platform=javase +# The simulator needs JDK 11 or newer, and preparing it says so on an older one; CI +# runs this script on JDK 8 and names a newer JDK for the native backend step. +SIMULATOR_JDK="${CN1_BACKEND_PACKAGE_JDK:-$JAVA_HOME}" +JAVA_HOME="$SIMULATOR_JDK" mvn -B -ntp initialize -Psimulator -Dcodename1.platform=javase ARGS=common/target/codenameone/simulator-classpath.args [ -f "$ARGS" ] || fail "prepare-simulator-classpath wrote no $ARGS" grep -q 'cn1-javase' "$ARGS" || fail "the simulator classpath has no JavaSE natives" @@ -147,6 +150,8 @@ for entry in com/example/minimal/MyAppStub.class com/example/minimal/HelloImpl.c unzip -l "$JAR" | grep -q " $entry\$" || fail "the desktop jar has no $entry" done [ -f common/target/libs/codenameone-javase-$CN1_VERSION.jar ] || fail "the desktop jar's libs/ has no JavaSE port" +# The bundled FFmpeg media implementation, which the javase module's desktop app ships. +ls common/target/libs/ffmpeg-*.jar >/dev/null 2>&1 || fail "the desktop jar's libs/ has no FFmpeg binaries" echo "== Every device target, staged from common" stage() { diff --git a/scripts/initializr/common/src/main/resources/common-hosted-platform-profiles.xml b/scripts/initializr/common/src/main/resources/common-hosted-platform-profiles.xml index eb6e7ad8394..ce02a30273c 100644 --- a/scripts/initializr/common/src/main/resources/common-hosted-platform-profiles.xml +++ b/scripts/initializr/common/src/main/resources/common-hosted-platform-profiles.xml @@ -770,6 +770,13 @@ codenameone-javase runtime + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + @@ -816,6 +823,13 @@ codenameone-javase runtime + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + From a04a7737c7e03b2b475d4c1a6f385dd4f2512911 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:36:53 +0300 Subject: [PATCH 4/5] Minimal-layout test: check the simulator's JDK refusal on JDK 8 The Java CI leg runs integration-tests/all.sh on JDK 8 with no newer JDK named, so preparing the simulator there now refuses (it needs JDK 11+). Assert that refusal and its message on an older JDK; prepare and check the classpath on a newer one. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cn1app-minimal-layout-test.sh | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/maven/integration-tests/cn1app-minimal-layout-test.sh b/maven/integration-tests/cn1app-minimal-layout-test.sh index b22538490f7..16d4a9b2c81 100755 --- a/maven/integration-tests/cn1app-minimal-layout-test.sh +++ b/maven/integration-tests/cn1app-minimal-layout-test.sh @@ -130,13 +130,25 @@ grep -q 'tests="1"' "$REPORT" || fail "the JUnit test did not run exactly once" grep -q 'failures="0"' "$REPORT" || fail "the JUnit test failed" echo "== The simulator classpath" -# The simulator needs JDK 11 or newer, and preparing it says so on an older one; CI -# runs this script on JDK 8 and names a newer JDK for the native backend step. +# The simulator needs JDK 11 or newer. A leg that names a newer JDK for the native +# backend step prepares it on that one; on an older JDK the check is that preparing +# it refuses, with the reason, instead of launching something that cannot start. SIMULATOR_JDK="${CN1_BACKEND_PACKAGE_JDK:-$JAVA_HOME}" -JAVA_HOME="$SIMULATOR_JDK" mvn -B -ntp initialize -Psimulator -Dcodename1.platform=javase +SIMULATOR_JAVA_MAJOR="$("$SIMULATOR_JDK/bin/java" -version 2>&1 | sed -n 's/.*version "\(1\.\)\{0,1\}\([0-9]*\).*/\2/p' | head -1)" ARGS=common/target/codenameone/simulator-classpath.args -[ -f "$ARGS" ] || fail "prepare-simulator-classpath wrote no $ARGS" -grep -q 'cn1-javase' "$ARGS" || fail "the simulator classpath has no JavaSE natives" +if [ "${SIMULATOR_JAVA_MAJOR:-0}" -lt 11 ]; then + if JAVA_HOME="$SIMULATOR_JDK" mvn -B -ntp initialize -Psimulator -Dcodename1.platform=javase \ + > ../simulator-old-jdk.log 2>&1; then + fail "preparing the simulator on Java $SIMULATOR_JAVA_MAJOR should refuse" + fi + grep -q 'supports JDK 11' ../simulator-old-jdk.log \ + || { tail -30 ../simulator-old-jdk.log; fail "the simulator refused Java $SIMULATOR_JAVA_MAJOR without saying why"; } + echo "NOTE the simulator needs JDK 11+; Java $SIMULATOR_JAVA_MAJOR was refused with the reason" +else + JAVA_HOME="$SIMULATOR_JDK" mvn -B -ntp initialize -Psimulator -Dcodename1.platform=javase + [ -f "$ARGS" ] || fail "prepare-simulator-classpath wrote no $ARGS" + grep -q 'cn1-javase' "$ARGS" || fail "the simulator classpath has no JavaSE natives" +fi echo "== The desktop jar" mvn -B -ntp package -Pexecutable-jar -DskipTests -Dcodename1.platform=javase From 1b7453c3a88bb6403d340e04a0d0af8a2ba1adb9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:18:32 +0300 Subject: [PATCH 5/5] Address the third review round - generate-desktop-app-wrapper deletes a stub it generated earlier once a custom javase/src/desktop/java stub appears, so the two are never compiled together. - compile-javase-natives skips builds for a platform other than javase: a device build uploads none of it, and a desktop-only compile error must not stop it. Builds that name no platform (mvn test) still compile it. - Its cache requires the output directory to exist, and the stamp goes with the output when there is nothing left to compile. - The hosted desktop jar takes the javase natives, resources and desktop resources before common's classes, the order a full layout's classpath resolves them in. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../maven/CompileJavaSENativesMojo.java | 29 +++++++++++++++++-- .../maven/GenerateDesktopAppWrapperMojo.java | 12 ++++++-- .../maven/JavaSEExecutableJarMojo.java | 7 +++-- .../codename1/maven/HostedPlatformsTest.java | 18 ++++++++---- 4 files changed, 52 insertions(+), 14 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CompileJavaSENativesMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CompileJavaSENativesMojo.java index feaca9d8860..3cd0a62f900 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CompileJavaSENativesMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CompileJavaSENativesMojo.java @@ -73,6 +73,14 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException } return; } + String platform = requestedPlatform(); + if (platform != null && !"javase".equals(platform)) { + // A device build uploads none of this, and a desktop-only compile error must + // not stop it. The simulator, the tests and the desktop builds name javase, + // and a build that names no platform (plain `mvn test`) compiles it too. + getLog().debug("Not compiling the JavaSE natives for a " + platform + " build"); + return; + } ProjectLayout layout = projectHost().layout(); File out = hostedNativesDir(); File buildDir = new File(project.getBuild().getDirectory()); @@ -121,6 +129,8 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException if (out.exists()) { FileUtils.deleteQuietly(out); } + // With the output gone, its stamp would vouch for classes that no longer exist. + FileUtils.deleteQuietly(new File(out.getParentFile(), "inputs.txt")); getLog().debug("No JavaSE native sources to compile"); return; } @@ -131,7 +141,7 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException collectFiles(resources, resourceFiles); Collections.sort(resourceFiles); String inputs = describeInputs(sources, resourceFiles, archives, classpath, level); - if (isUpToDate(stamp, inputs, sources, resources)) { + if (isUpToDate(stamp, out, inputs, sources, resources)) { getLog().debug("JavaSE native code is up to date at " + out); return; } @@ -263,8 +273,8 @@ static String describeInputs(List sources, List resources, List sources, File resources) { - if (!stamp.isFile()) { + static boolean isUpToDate(File stamp, File out, String inputs, List sources, File resources) { + if (!stamp.isFile() || !out.isDirectory()) { return false; } try { @@ -295,6 +305,19 @@ static boolean isUpToDate(File stamp, String inputs, List sources, File re return true; } + /** + * The platform this build is for: the command line's {@code -Dcodename1.platform}, else + * the one a profile set, else null when the build names none. + */ + private String requestedPlatform() { + java.util.Properties user = userProperties(); + String p = user == null ? null : user.getProperty("codename1.platform"); + if (p == null || p.trim().length() == 0) { + p = project.getProperties().getProperty("codename1.platform"); + } + return p == null || p.trim().length() == 0 ? null : p.trim(); + } + static String sha256(File f) throws MojoExecutionException { try (java.io.InputStream in = new java.io.FileInputStream(f)) { java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateDesktopAppWrapperMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateDesktopAppWrapperMojo.java index c831a3fc404..9330ec46d58 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateDesktopAppWrapperMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateDesktopAppWrapperMojo.java @@ -181,13 +181,19 @@ private void generateStub() throws MojoExecutionException { // registerCustomStubSourceRoot adds will pick it up. String packagePath = packageName.replace('.', File.separatorChar); File customStub = new File(customStubSourceRoot(), path(packagePath, mainName + "Stub.java")); + File generatedRoot = new File(project.getBuild().getDirectory(), + path("generated-sources", GENERATED_SOURCES_DIR)); if (customStub.exists()) { getLog().info("Custom desktop stub found at " + customStub.getAbsolutePath() + " - skipping generation."); + // A stub generated before the custom one appeared would otherwise still be + // compiled beside it (compile-javase-natives reads the generated directory), + // two definitions of one class, until a clean. + File stale = new File(generatedRoot, path(packagePath, mainName + "Stub.java")); + if (stale.isFile() && !stale.delete()) { + throw new MojoExecutionException("Could not delete the obsolete generated stub " + stale); + } return; } - - File generatedRoot = new File(project.getBuild().getDirectory(), - path("generated-sources", GENERATED_SOURCES_DIR)); File generatedPkgDir = new File(generatedRoot, packagePath); generatedPkgDir.mkdirs(); File generatedStub = new File(generatedPkgDir, mainName + "Stub.java"); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/JavaSEExecutableJarMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/JavaSEExecutableJarMojo.java index 85f1aa82def..047f9bb48fd 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/JavaSEExecutableJarMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/JavaSEExecutableJarMojo.java @@ -104,11 +104,14 @@ protected void executeImpl() throws MojoExecutionException, MojoFailureException File root = getCN1ProjectDir().getParentFile(); List contents = new ArrayList(); - contents.add(new File(project.getBuild().getOutputDirectory())); + // Earlier wins, in the order a full layout's desktop app resolves them: the javase + // module's jar (its natives, resources, generated icons and desktop resources) + // comes before the common jar on its classpath. contents.add(hostedNativesDir()); contents.add(hostedDesktopResourcesDir()); - contents.add(new File(getCN1ProjectDir(), path("src", "desktop", "resources"))); contents.add(new File(root, path("javase", "src", "desktop", "resources"))); + contents.add(new File(getCN1ProjectDir(), path("src", "desktop", "resources"))); + contents.add(new File(project.getBuild().getOutputDirectory())); Manifest manifest = new Manifest(); Attributes attributes = manifest.getMainAttributes(); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HostedPlatformsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HostedPlatformsTest.java index 6e4e1884a83..7cd3c09b899 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HostedPlatformsTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/HostedPlatformsTest.java @@ -172,7 +172,9 @@ void nativesRecompileWhenAnythingTheyReadChanges() throws Exception { String inputs = CompileJavaSENativesMojo.describeInputs(sources, resourceFiles, Collections.emptyList(), Collections.singletonList(cp.getAbsolutePath()), new String[] {null, "1.8", "1.8"}); File stamp = new File(tmp, "inputs.txt"); - assertFalse(CompileJavaSENativesMojo.isUpToDate(stamp, inputs, sources, resources), "never compiled"); + File out = new File(tmp, "out"); + out.mkdirs(); + assertFalse(CompileJavaSENativesMojo.isUpToDate(stamp, out, inputs, sources, resources), "never compiled"); write(stamp, inputs); long now = System.currentTimeMillis(); stamp.setLastModified(now); @@ -180,22 +182,26 @@ void nativesRecompileWhenAnythingTheyReadChanges() throws Exception { cp.setLastModified(now - 10000); resource.setLastModified(now - 10000); resources.setLastModified(now - 10000); - assertTrue(CompileJavaSENativesMojo.isUpToDate(stamp, inputs, sources, resources)); + assertTrue(CompileJavaSENativesMojo.isUpToDate(stamp, out, inputs, sources, resources)); + out.delete(); + assertFalse(CompileJavaSENativesMojo.isUpToDate(stamp, out, inputs, sources, resources), + "a stamp is no proof of output that is not there"); + out.mkdirs(); src.setLastModified(now + 10000); - assertFalse(CompileJavaSENativesMojo.isUpToDate(stamp, inputs, sources, resources), "an edited source"); + assertFalse(CompileJavaSENativesMojo.isUpToDate(stamp, out, inputs, sources, resources), "an edited source"); src.setLastModified(now - 10000); String noResources = CompileJavaSENativesMojo.describeInputs(sources, Collections.emptyList(), Collections.emptyList(), Collections.singletonList(cp.getAbsolutePath()), new String[] {null, "1.8", "1.8"}); - assertFalse(CompileJavaSENativesMojo.isUpToDate(stamp, noResources, sources, resources), + assertFalse(CompileJavaSENativesMojo.isUpToDate(stamp, out, noResources, sources, resources), "a deleted resource, which no timestamp shows, must still rebuild the output"); String moreSources = CompileJavaSENativesMojo.describeInputs(Arrays.asList(src, new File(tmp, "B.java")), resourceFiles, Collections.emptyList(), Collections.singletonList(cp.getAbsolutePath()), new String[] {null, "1.8", "1.8"}); - assertFalse(CompileJavaSENativesMojo.isUpToDate(stamp, moreSources, sources, resources), "a new source"); + assertFalse(CompileJavaSENativesMojo.isUpToDate(stamp, out, moreSources, sources, resources), "a new source"); File appClass = new File(cp, "App.class"); write(appClass, "x"); appClass.setLastModified(now + 10000); - assertFalse(CompileJavaSENativesMojo.isUpToDate(stamp, inputs, sources, resources), + assertFalse(CompileJavaSENativesMojo.isUpToDate(stamp, out, inputs, sources, resources), "the application classes the natives link against changed"); }