Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/archetype-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ on:
- 'maven/integration-tests/cn1app-archetype-test.sh'
- 'maven/integration-tests/cn1app-staged-jar-test.sh'
- 'maven/integration-tests/cn1app-minimal-layout-test.sh'
- 'scripts/check-first-build-footprint.py'
# Any module's dependencies can land on the class path a first build
# downloads; the footprint gate has to see the change that adds one.
- 'maven/*/pom.xml'
- 'maven/integration-tests/backend-cn1lib-test.sh'
- 'scripts/initializr/common/src/main/resources/common-hosted-platform-profiles.xml'
- 'scripts/initializr/common/src/main/resources/backend-only-pom.xml'
Expand Down Expand Up @@ -55,6 +59,10 @@ on:
- 'maven/integration-tests/cn1app-archetype-test.sh'
- 'maven/integration-tests/cn1app-staged-jar-test.sh'
- 'maven/integration-tests/cn1app-minimal-layout-test.sh'
- 'scripts/check-first-build-footprint.py'
# Any module's dependencies can land on the class path a first build
# downloads; the footprint gate has to see the change that adds one.
- 'maven/*/pom.xml'
- 'maven/integration-tests/backend-cn1lib-test.sh'
- 'scripts/initializr/common/src/main/resources/common-hosted-platform-profiles.xml'
- 'scripts/initializr/common/src/main/resources/backend-only-pom.xml'
Expand Down Expand Up @@ -135,6 +143,19 @@ jobs:
cd maven
mvn archetype:update-local-catalog -Plocal-dev-javase
mvn archetype:crawl -Plocal-dev-javase
# What `./run.sh` downloads on a machine that has never built anything.
# The plugin's class path is resolved whole before its first goal, so one
# dependency added to it (or to build-engine) is megabytes on every new
# developer's first run and changes no test anywhere. The script builds a
# generated project into an empty local repository, with the repository
# the step above filled as its remote, and fails on a port, on an ffmpeg
# native for another platform, or on the total passing its budget.
- name: Check what a new project's first build downloads
# What a developer runs the simulator on. JAVA_HOME is the JDK 8 the
# framework builds with, which the plugin refuses for a simulator build.
env:
JAVA_HOME: ${{ env.CN1_BACKEND_PACKAGE_JDK }}
run: python3 scripts/check-first-build-footprint.py --repo "$HOME/.m2/repository"
- name: Run archetype simulator smoke (auto-bundle pref forced on)
env:
# The packaging check must not be skipped on a runner that has clang.
Expand Down
17 changes: 15 additions & 2 deletions .github/workflows/starter-launchers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ on:
paths:
- 'scripts/test-starter-launchers.py'
- 'scripts/sync-initializr-launchers.py'
# The launchers this workflow compares, and their copies: a change to one
# without the other is the drift it exists to report.
- 'maven/cn1app-archetype/src/main/resources/archetype-resources/build.*'
- 'maven/cn1app-archetype/src/main/resources/archetype-resources/run.*'
- 'maven/cn1app-archetype/src/main/resources/archetype-resources/mvnw*'
- 'maven/cn1app-archetype/src/main/resources/archetype-resources/mvnconfig.toml'
- 'maven/cn1app-archetype/src/main/resources/archetype-resources/.vscode/**'
- 'maven/cn1app-archetype/src/main/resources/archetype-resources/tools/**'
- 'maven/cli/src/main/batsh/build.batsh'
- 'scripts/initializr/common/src/main/resources/*.zip'
- 'maven/codenameone-maven-plugin/src/main/resources/com/codename1/maven/intellij/workspace.xml'
- '.github/workflows/starter-launchers.yml'
workflow_dispatch:
schedule:
Expand All @@ -28,8 +39,10 @@ jobs:
os: [ubuntu-24.04, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
# macOS runners take ~37 s per generated download, and there are past
# thirty of them now: twenty minutes cancelled a run that was passing.
timeout-minutes: 40
# thirty of them now: twenty minutes cancelled a run that was passing, and
# then forty did -- passing runs finish in 35 to 39 on macOS, and the nightly
# was cancelled at the limit with nothing wrong.
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
Expand Down
57 changes: 57 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,63 @@ scripts/check-build-hint-catalog.sh # every hint the code reads is de
means a hint went in undeclared. Do not re-run `tools/build-hint-bootstrap/`; it
seeded the catalog once and would overwrite hand edits.

### A new project's first build is a download, and it is gated

The first thing a developer does is run a project from the initializr on a
machine that has never built one. Maven resolves a plugin's **whole** class path
before its first goal, so every runtime dependency of `codenameone-maven-plugin`
and `codenameone-build-engine` is downloaded by a Hello World in the simulator.
That was 346 MB: 55 MB of ports nothing in a simulator run opens, and 157 MB of
ffmpeg for seven platforms. No test fails when it grows.

- **The ports are not dependencies of the build tools.** `codenameone-ios`,
`-android`, `-mac`, `-windows`, `-linux` and `-parparvm` are `provided` and
`optional` in both poms (reactor order and the test class path keep them).
`AppBuilder.LocalBuild` names the ports each local target reads,
`ProjectHost.getPortJar` resolves one at the build tool's own version when
that target is built, and `Executor.getResourceAsStream` reads the class path
first and those jars second. A builder must read a port **through the
Executor**, never `SomeClass.class.getResourceAsStream`: that sees the class
path alone and finds nothing in a customer build while passing every test
here, where the ports are on the test class path.
- **A new local target, or a new port, is a row in `LocalBuild`** and in
`LocalBuildPortsTest`, and a new port also in `PORT_ARTIFACTS` in the script
below.
- **Ports are resolved at the plugin's own version**, by both build tools, and a
port the *project* depends on is never used: a builder paired with an older
port writes a project that builds and does not start. A port under the plugin
declaration's own `<dependencies>` still wins, which is how one is pinned.
- **ffmpeg is the host's alone.** `maven/cn1-binaries/pom.xml` selects the
natives with OS-activated profiles, which Maven evaluates on the machine that
resolves the pom -- so that pom must never be flattened. Gradle does not
evaluate them; `PluginInfo.hostFfmpegPlatforms` is the same table there.
- **The desktop jar is the exception, because it runs somewhere else.**
`javase-executable-jar` resolves the other desktop platforms' natives itself
(`missingDesktopNatives`). A project with a `javase` module packages straight
from its class path, so its generated launch commands pass
`-Dcn1.ffmpeg.allPlatforms=true` (Maven only), and
`generate-desktop-app-wrapper` warns when an older project's do not. Those
commands exist in several copies nothing regenerates: the archetype's
scripts and IDE launchers, the Initializr's `common.zip` and per-IDE zips,
the plugin's IntelliJ `workspace.xml`, and `cn1:buildExecutableJar`, which
sends the property to its child build. `scripts/sync-initializr-launchers.py`
(`starter-launchers.yml`) fails on a copy that builds the jar without it.
- **`codename1.stageOnly` resolves the target's ports** although it never runs
the builder, so the staging tests cover resolution for every target they stage.

`scripts/check-first-build-footprint.py` (`archetype-smoke.yml`) generates a
project, builds what `run.sh` builds into an **empty** local repository with the
workspace's repository as a `file://` remote, and fails on a port, on an
`org.bytedeco` native for another platform, on a host with no ffmpeg at all, or
on the total passing `BUDGET_MB`. CI runs it on Linux only, so it first holds
the profile of *every* host in that pom to a table (`HOST_PROFILES`). It
measures a floor: the Maven wrapper's distribution and what the simulator
fetches once running are not in a local repository.

```bash
scripts/check-first-build-footprint.py --repo "$PWD/.m2-repo"
```

### The CSS compiler is headless, and has to stay that way

Compiling `css/theme.css` to `theme.res` opens no window, boots no JavaSE port
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ static File resolveExecutable(String name) {

/**
* Resolves an ffmpeg/ffprobe executable bundled by the
* {@code org.bytedeco:ffmpeg-platform} dependency, so simulator media
* {@code org.bytedeco:ffmpeg} dependency, so simulator media
* playback works with no externally installed ffmpeg. The bytedeco binaries
* are full builds whose native decoders cover H.264/AAC/MP3 (those require
* no GPL/x264 encoder), which is what the codec-free upstream CEF can no
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@

/// `com.codename1.media.VideoIO` implementation for the JavaSE simulator backed by the
/// `ffmpeg`/`ffprobe` executables (resolved by `FFMPEGMedia`, which bundles a binary via
/// the optional `org.bytedeco:ffmpeg-platform` dependency). This is a fully functional
/// the optional `org.bytedeco:ffmpeg` dependency). This is a fully functional
/// reference implementation: it encodes application frames + audio, decodes clips to
/// frame accurate RGBA frames + PCM, and enumerates the codecs the local ffmpeg build
/// exposes.
Expand Down
2 changes: 2 additions & 0 deletions docs/developer-guide/Maven-Appendix-Archetypes.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ Native interface implementations live in the same directories either way. `mvn c

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`.

The desktop jar is one archive for Windows, macOS and Linux, so it carries the media libraries of all three. This layout adds them without being asked. A project with a `javase` module packages the libraries of the machine that builds it unless the command passes `-Dcn1.ffmpeg.allPlatforms=true`, which the generated `build.sh`, `build.bat` and IDE launchers do. Add the property to a launcher written before it existed; the build prints a warning when it's missing.

[#maven-backend-only]
==== Backend-only projects

Expand Down
29 changes: 23 additions & 6 deletions maven/build-engine/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -247,22 +247,36 @@
<artifactId>cn1-builder-resources-android</artifactId>
<scope>runtime</scope>
</dependency>
<!-- The ports a local build reads.
Provided and optional, like the compatibility runtimes above: the
build needs this module built first, and its tests read the port
from the class path, but nothing that RUNS the build tool may
inherit it. A plugin's whole class path is downloaded before its
first goal, so as a runtime dependency every port was part of the
first build of every project, simulator runs included. The host
resolves a port when a target that reads it is built
(AppBuilder.portArtifacts, ProjectHost.getPortJar);
scripts/check-first-build-footprint.py fails the build if one
comes back. -->
<dependency>
<groupId>com.codenameone</groupId>
<artifactId>codenameone-android</artifactId>
<scope>runtime</scope>
<scope>provided</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.codenameone</groupId>
<artifactId>codenameone-ios</artifactId>
<classifier>bundle</classifier>
<scope>runtime</scope>
<scope>provided</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.codenameone</groupId>
<artifactId>codenameone-parparvm</artifactId>
<classifier>bundle</classifier>
<scope>runtime</scope>
<scope>provided</scope>
<optional>true</optional>
</dependency>
<!-- WindowsPort, MacPort and LinuxPort bundles: the native platform classes
and C / Objective-C sources the native desktop builders stage. -->
Expand All @@ -271,21 +285,24 @@
<artifactId>codenameone-windows</artifactId>
<classifier>bundle</classifier>
<version>${project.version}</version>
<scope>runtime</scope>
<scope>provided</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.codenameone</groupId>
<artifactId>codenameone-mac</artifactId>
<classifier>bundle</classifier>
<version>${project.version}</version>
<scope>runtime</scope>
<scope>provided</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.codenameone</groupId>
<artifactId>codenameone-linux</artifactId>
<classifier>bundle</classifier>
<version>${project.version}</version>
<scope>runtime</scope>
<scope>provided</scope>
<optional>true</optional>
</dependency>
<!-- Forces the reactor to build cn1-hardening first so its shaded jar can be
embedded below; never on the classpath (see the plugin pom's history for
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,18 @@ public interface ProjectHost {
/// dependencies or the build tool's own, or null.
File getJar(String groupId, String artifactId, String classifier);

/// The jar of a port a local build reads its platform from --
/// `com.codenameone:artifactId[:classifier]` at the build tool's own version --
/// downloading it when the local repository does not have it yet, or null
/// when it cannot be produced.
///
/// Not [#getJar(String, String, String)]: that answers from what is already
/// resolved, and a port is deliberately not. The build tool used to depend on
/// every port, so a first build of any project downloaded all of them before
/// it ran a goal; now each is fetched by the first build of a target that
/// reads it. `AppBuilder.portArtifacts` says which those are.
File getPortJar(String artifactId, String classifier);

/// When the current build invocation started, in epoch milliseconds, or
/// `Long.MAX_VALUE` when unknown.
long sessionStartTime();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.FileWriter;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
Expand All @@ -57,6 +58,7 @@
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
import javax.imageio.ImageIO;
Expand Down Expand Up @@ -110,6 +112,7 @@ public abstract class Executor {
private boolean canceled;
private Class<?>[] nativeInterfaces;
private boolean unitTestMode;
private final List<File> portJars = new ArrayList<File>();
static boolean IS_MAC;
/**
* The internal class name inside a JVM field descriptor.
Expand Down Expand Up @@ -4455,9 +4458,88 @@ public File getResourceAsFile(String res, String extension) throws IOException {
* Gets a potentially versioned resource
*/
public InputStream getResourceAsStream(String res) {
InputStream is = Executor.class.getResourceAsStream(res);
if (is != null) {
return is;
}
return openPortResource(res);
}

/// Attaches a port jar this build reads its platform out of -- `iOSPort.jar` and
/// `nativeios.jar` from the `codenameone-ios` bundle, the translator from the
/// `codenameone-parparvm` one, `android_port_sources.jar` from `codenameone-android`.
///
/// These used to be dependencies of the build tool itself, which made every one of
/// them part of the first download of every project: Maven resolves a plugin's whole
/// class path before it runs a single goal, so a Hello World started in the simulator
/// fetched the iOS, Android, macOS, Windows and Linux ports and the translator --
/// about 55 MB it never opened. The host now resolves only what the target being
/// built reads (see `AppBuilder.portArtifacts`) and hands the files over here.
///
/// The class path is still asked first, so a tool that does carry a port (the cloud
/// builder, a test) behaves exactly as before and nothing here is consulted.
public void addPortJar(File jar) {
if (jar != null && !portJars.contains(jar)) {
portJars.add(jar);
}
}

/// The port jars attached to this build, in the order they are searched.
public List<File> getPortJars() {
return new ArrayList<File>(portJars);
}

return Executor.class.getResourceAsStream(res);
private InputStream openPortResource(String res) {
// Class.getResourceAsStream resolves a name with no leading slash against the
// class's own package, and a port resource has to answer to the same name.
String entry = res.startsWith("/") ? res.substring(1) : "com/codename1/builders/" + res;
for (File jar : portJars) {
ZipFile zip = null;
try {
zip = new ZipFile(jar);
ZipEntry e = zip.getEntry(entry);
if (e != null && !e.isDirectory()) {
InputStream in = new PortResourceStream(zip, zip.getInputStream(e));
zip = null;
return in;
}
} catch (IOException ex) {
// An unreadable jar is not "the resource is absent": every caller turns
// null into "X is missing from the class path", which would send the
// reader looking for a packaging bug when the download is what broke.
throw new IllegalStateException("Could not read the port jar " + jar + ": " + ex, ex);
} finally {
if (zip != null) {
try {
zip.close();
} catch (IOException ignored) {
// nothing was read from it
}
}
}
}
return null;
}

/// A port resource's stream, which owns the jar it was opened from: closing the
/// stream closes the jar, so a caller needs no more care than it took with a class
/// path resource.
private static final class PortResourceStream extends FilterInputStream {
private final ZipFile zip;

PortResourceStream(ZipFile zip, InputStream in) {
super(in);
this.zip = zip;
}

@Override
public void close() throws IOException {
try {
super.close();
} finally {
zip.close();
}
}
}

protected boolean isUnitTestMode() {
Expand Down
Loading
Loading