diff --git a/build.gradle b/build.gradle
index 3826345..659fba3 100644
--- a/build.gradle
+++ b/build.gradle
@@ -12,7 +12,7 @@ buildscript {
plugins {
id "io.freefair.lombok" version "8.4"
- id "com.github.ben-manes.versions" version "0.50.0"
+ id "com.github.ben-manes.versions" version "0.51.0"
}
apply plugin: "java-gradle-plugin"
@@ -20,7 +20,7 @@ apply plugin: "com.gradle.plugin-publish"
apply plugin: "jacoco"
group = "nl.colorize"
-version = "2024.2"
+version = "2024.4"
compileJava.options.encoding = "UTF-8"
java {
@@ -39,7 +39,7 @@ dependencies {
implementation gradleApi()
implementation localGroovy()
implementation files("lib/appbundler-1.0ea.jar")
- implementation "org.jsoup:jsoup:1.17.1"
+ implementation "org.jsoup:jsoup:1.17.2"
implementation "org.commonmark:commonmark:0.21.0"
implementation "org.nanohttpd:nanohttpd-webserver:2.3.1"
testImplementation "org.junit.jupiter:junit-jupiter:5.10.1"
@@ -95,7 +95,7 @@ gradlePlugin {
}
}
-// Gradle has a compatibility issue with Java 17 when running tests,
+// Gradle has a compatibility issue with Java 17+ when running tests,
// see https://github.com/gradle/gradle/issues/18647 for details.
tasks.withType(Test).configureEach {
jvmArgs(["--add-opens=java.base/java.lang=ALL-UNNAMED", "--add-opens=java.base/java.util=ALL-UNNAMED"])
diff --git a/example/build.gradle b/example/build.gradle
index 3400805..feafc24 100644
--- a/example/build.gradle
+++ b/example/build.gradle
@@ -26,30 +26,14 @@ java {
sourceSets.test.java.srcDirs = ["source"]
}
-repositories {
- mavenCentral()
- maven {
- url "https://jitpack.io"
- }
-}
-
-dependencies {
- implementation "nl.colorize:colorize-java-commons:2024.1"
- implementation "nl.colorize:multimedialib:2024.1"
-}
-
jar {
- archiveFileName = "example.jar"
+ archiveFileName = "example-app.jar"
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
exclude "**/module-info.class"
manifest {
attributes "Main-Class": "com.example.ExampleApp"
}
-
- from {
- configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) }
- }
}
macApplicationBundle {
@@ -61,8 +45,6 @@ macApplicationBundle {
icon = "../resources/icon.icns"
applicationCategory = "public.app-category.developer-tools"
mainClassName = "com.example.ExampleApp"
- extractNatives = true
- args = ["gdx"]
}
msi {
diff --git a/example/resources/example-gallery.png b/example/resources/example-gallery.png
deleted file mode 100644
index 9b257a1..0000000
Binary files a/example/resources/example-gallery.png and /dev/null differ
diff --git a/example/source/ExampleApp.java b/example/source/ExampleApp.java
index e538455..5a9c9ef 100644
--- a/example/source/ExampleApp.java
+++ b/example/source/ExampleApp.java
@@ -6,69 +6,52 @@
package com.example;
-import nl.colorize.multimedialib.renderer.Canvas;
-import nl.colorize.multimedialib.renderer.DisplayMode;
-import nl.colorize.multimedialib.renderer.ErrorHandler;
-import nl.colorize.multimedialib.renderer.FilePointer;
-import nl.colorize.multimedialib.renderer.GraphicsMode;
-import nl.colorize.multimedialib.renderer.Renderer;
-import nl.colorize.multimedialib.renderer.ScaleStrategy;
-import nl.colorize.multimedialib.renderer.WindowOptions;
-import nl.colorize.multimedialib.renderer.java2d.Java2DRenderer;
-import nl.colorize.multimedialib.renderer.libgdx.GDXRenderer;
-import nl.colorize.multimedialib.stage.ColorRGB;
-import nl.colorize.multimedialib.stage.Image;
-import nl.colorize.multimedialib.stage.Sprite;
-import nl.colorize.multimedialib.scene.Scene;
-import nl.colorize.multimedialib.scene.SceneContext;
-import nl.colorize.util.swing.ApplicationMenuListener;
+import javax.imageio.ImageIO;
+import javax.swing.JFrame;
+import javax.swing.JPanel;
+import java.awt.Color;
+import java.awt.Dimension;
+import java.awt.Graphics;
+import java.awt.image.BufferedImage;
+import java.io.IOException;
+import java.io.InputStream;
/**
- * Example application that displays an extremely simple MultimediaLib scene.
- * This acts as a "real" application that is included in the plugin code,
- * both for testing purposes and as an example on how to use the plugin.
+ * Example application that displays an extremely simple Swing user interface.
+ * This is included in the plugin code so that the plugin can be tested from
+ * a Gradle build.
*/
-public class ExampleApp implements Scene, ApplicationMenuListener {
+public class ExampleApp extends JPanel {
- public static void main(String[] args) {
- ExampleApp app = new ExampleApp();
-
- Canvas canvas = new Canvas(800, 600, ScaleStrategy.flexible());
- DisplayMode displayMode = new DisplayMode(canvas, 60);
+ private BufferedImage logo;
- WindowOptions windowOptions = new WindowOptions("Example");
- windowOptions.setAppMenuListener(app);
-
- if (args.length > 0 && args[0].contains("java2d")) {
- windowOptions.setTitle(windowOptions.getTitle() + " (Java2D renderer)");
- Renderer renderer = new Java2DRenderer(displayMode, windowOptions);
- renderer.start(app, ErrorHandler.DEFAULT);
- } else {
- Renderer renderer = new GDXRenderer(GraphicsMode.MODE_2D, displayMode, windowOptions);
- renderer.start(app, ErrorHandler.DEFAULT);
- }
- }
-
- @Override
- public void start(SceneContext context) {
- context.getStage().setBackgroundColor(new ColorRGB(235, 235, 235));
-
- Image icon = context.getMediaLoader().loadImage(new FilePointer("icon.png"));
- Sprite sprite = new Sprite(icon);
- sprite.setPosition(context.getCanvas().getWidth() / 2f, context.getCanvas().getHeight() / 2f);
- sprite.getTransform().setScale(25);
- context.getStage().getRoot().addChild(sprite);
+ public static void main(String[] args) {
+ JFrame window = new JFrame();
+ window.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
+ window.setResizable(true);
+ window.setTitle("Example");
+ window.setContentPane(new ExampleApp());
+ window.pack();
+ window.setLocationRelativeTo(null);
+ window.setVisible(true);
}
- @Override
- public void update(SceneContext context, float deltaTime) {
- }
+ public ExampleApp() {
+ super();
+ setLayout(null);
+ setPreferredSize(new Dimension(800, 600));
+ setBackground(new Color(235, 235, 235));
- @Override
- public void onQuit() {
+ try (InputStream stream = getClass().getClassLoader().getResourceAsStream("icon.png")) {
+ logo = ImageIO.read(stream);
+ } catch (IOException e) {
+ throw new RuntimeException("Unable to load image", e);
+ }
}
@Override
- public void onAbout() {
+ protected void paintComponent(Graphics g) {
+ super.paintComponent(g);
+ g.drawImage(logo, getWidth() / 2 - 100, getHeight() / 2 - 100, 200, 200, null);
}
}
diff --git a/readme.md b/readme.md
index 0973064..ad66ce6 100644
--- a/readme.md
+++ b/readme.md
@@ -44,7 +44,7 @@ The plugin is available from the [Gradle plugin registry](https://plugins.gradle
use the plugin in your Gradle project by adding the following to `build.gradle`:
plugins {
- id "nl.colorize.gradle.application" version "2024.2"
+ id "nl.colorize.gradle.application" version "2024.4"
}
Building native Mac application bundles
@@ -81,39 +81,57 @@ The following shows an example on how to define this configuration in Gradle:
The following configuration options are available:
-| Name | Required | Description |
-|------------------------|----------|-----------------------------------------------------------------------|
-| `name` | yes | Mac application name. |
-| `displayName` | no | Optional display name, defaults to the value of `name`. |
-| `identifier` | yes | Apple application identfiier, in the format "com.example.name". |
-| `bundleVersion` | yes | Application bundle version number. |
-| `description` | yes | Short description text. |
-| `copyright` | yes | Copyright statement text. |
-| `applicationCategory` | yes | Apple application category ID. |
-| `minimumSystemVersion` | no | Minimum required Mac OS version number. Defaults to 10.13. |
-| `architectures` | no | List of supported CPU architectures. Default is `arm64` and `x86_64`. |
-| `mainClassName` | yes | Fully qualified main class name. |
-| `jdkPath` | no | Location of JDK. Defaults to `JAVA_HOME`. |
-| `modules` | no | List of JDK modules. An empty list will embed the entire JDK. |
-| `additionalModules` | no | List of JDK modules, added without overriding the default `modules`. |
-| `options` | no | List of JVM command line options. |
-| `args` | no | List of command line arguments provided to the main class. |
-| `startOnFirstThread` | no | When true, starts the application with `-XstartOnFirstThread`. |
-| `icon` | yes | Location of the `.icns` file. |
-| `extractNatives` | no | Extracts embedded native libraries from JAR files. |
-| `outputDir` | no | Output directory path, defaults to `build/mac`. |
-
-- Note that, in addition to the `bundleVersion` property, there is also the concept of build
- version. This is normally the same as the bundle version, but can be manually specified for each
- build by setting the `buildversion` system property.
-- Signing the application bundle requires an Apple Developer account and corresponding signing
- identity. The name of this identity can be set using the `MAC_SIGN_APP_IDENTITY` and
- `MAC_SIGN_INSTALLER_IDENTITY` environment variables, for signing applications and installers
- respectively.
-- By default, the contents of the application will be based on all JAR files produces by the
- project, as described by the `libsDir` property. This behavior can be replaced by setting the
- `contentDir` property in the plugin's configuration. The easiest way to bundle all content,
- including application binaries, resources, and libraries, is to create a single "fat JAR" file:
+| Name | Required | Description |
+|------------------------|----------|-----------------------------------------------------------------|
+| `name` | yes | Mac application name. |
+| `displayName` | no | Optional display name, defaults to the value of `name`. |
+| `identifier` | yes | Apple application identfiier, in the format "com.example.name". |
+| `bundleVersion` | yes | Application bundle version number. |
+| `description` | yes | Short description text. |
+| `copyright` | yes | Copyright statement text. |
+| `applicationCategory` | yes | Apple application category ID. |
+| `minimumSystemVersion` | no | Minimum required Mac OS version number. Defaults to 10.13. |
+| `architectures` | no | Supported CPU architectures. Default is [`arm64`, `x86_64`]. |
+| `mainJarName` | yes | File name for the JAR file containing the main class. |
+| `mainClassName` | yes | Fully qualified main class name. |
+| `jdkPath` | no | Location of JDK. Defaults to `JAVA_HOME`. |
+| `modules` | no | Overrides list of embedded JDK modules. |
+| `additionalModules` | no | Extends default list of embedded JDK modules. |
+| `options` | no | List of JVM command line options. |
+| `args` | no | List of command line arguments provided to the main class. |
+| `startOnFirstThread` | no | When true, starts the application with `-XstartOnFirstThread`. |
+| `icon` | yes | Location of the `.icns` file. |
+| `launcher` | no | Generated launcher type. Either "native" (default) or "shell". |
+| `signNativeLibraries` | no | Signs native libraries embedded in the application's JAR files. |
+| `outputDir` | no | Output directory path, defaults to `build/mac`. |
+
+The application bundle includes a Java runtime. This does not include the full JDK, to reduce
+the bundle size. The list of JDK modules can be extended using the `additionalModules` property,
+or replaced entirely using the `modules` property. By default, the following JDK modules are
+included in the runtime:
+
+- java.base
+- java.desktop
+- java.logging
+- java.net.http
+- java.sql
+- jdk.crypto.ec
+
+Mac applications use two different version numbers: The application version and the build version.
+By default, both are based on the `bundleVersion` property. It is possible to specify the build
+version on the command line (it's not a property since the build version is supposed to be unique
+for every build). The build version can be set using the `buildversion` system property, e.g.
+`gradle -Dbuildversion=1.0.1 createApplicationBundle`.
+
+Signing the application bundle requires an Apple Developer account and corresponding signing
+identity. The name of this identity can be set using the `MAC_SIGN_APP_IDENTITY` and
+`MAC_SIGN_INSTALLER_IDENTITY` environment variables, for signing applications and installers
+respectively.
+
+By default, the contents of the application will be based on all JAR files produces by the
+project, as described by the `libsDir` property. This behavior can be replaced by setting the
+`contentDir` property in the plugin's configuration. The easiest way to bundle all content,
+including application binaries, resources, and libraries, is to create a single "fat JAR" file:
```
jar {
@@ -137,8 +155,13 @@ The following configuration options are available:
The plugin adds a number of tasks to the project that use this configuration:
- **createApplicationBundle**: Creates the application bundle in the specified directory.
-- **signApplicationBundle**: Signs the created application bundle and packages it into an installer
- so that it can be distributed.
+- **signApplicationBundle**: Signs the created application bundle and packages it into an
+ installer so that it can be distributed.
+- **packageApplicationBundle**: An *experimental* task that creates the application bundle using
+ the [jpackage](https://docs.oracle.com/en/java/javase/21/docs/specs/man/jpackage.html) tool
+ that is included with the JDK. Creates both a DMG file and a PKG installer. This task is
+ experimental, it does not yet support all options from the *createApplicationBundle* and
+ *signApplicationBundle* tasks.
Note that the tasks are *not* added to any standard tasks such as `assemble`, as Mac application
bundles can only be created when running the build on a Mac, making the tasks incompatible with
@@ -167,7 +190,7 @@ are available:
| Name | Required | Description |
|-----------------|----------|-----------------------------------------------------------------|
| `inherit` | no | Inherits some configuration options from Mac app configuration. |
-| `mainJarName` | no | File name of the main JAR file. Defaults to application JAR. |
+| `mainJarName` | depends | File name of the main JAR file. Defaults to application JAR. |
| `mainClassName` | depends | Fully qualified main class name. |
| `options` | no | List of JVM command line options. |
| `args` | no | List of command line arguments provided to the main class. |
@@ -198,7 +221,7 @@ configured using the `exe` section:
| Name | Required | Description |
|---------------|----------|-----------------------------------------------------------------|
| `inherit` | no | Inherits some configuration options from Mac app configuration. |
-| `mainJarName` | no | File name of the main JAR file. Defaults to application JAR. |
+| `mainJarName` | depends | File name of the main JAR file. Defaults to application JAR. |
| `args` | no | List of command line arguments provided to the main class. |
| `name` | depends | Windows application name. |
| `version` | depends | Windows application version number. |
@@ -338,7 +361,9 @@ The plugin comes with an example application, that can be used to test the plugi
- Navigate to the `example` directory to build the example app.
- Run `gradle createApplicationBundle` to create a Mac application bundle.
- Run `gradle signApplicationBundle` to sign a Mac application bundle.
+ - Run `gradle packageApplicationBundle` to create a Mac application bundle using `jpackage`.
- Run `gradle packageMSI` to create a Windows MSI installer.
+ - Run `gradle packageEXE` to create a standalone Windows application.
- Run `gradle xcodeGen` to generate a Xcode project for a hybrid iOS app.
- Run `gradle generateStaticSite` to generate a website from Markdown templates.
- Run `gradle generatePWA` to create a PWA version of the aforementioned website.
diff --git a/resources/config.xml b/resources/config.xml
deleted file mode 100644
index d5f13c6..0000000
--- a/resources/config.xml
+++ /dev/null
@@ -1,42 +0,0 @@
-
-
- @@@NAME
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/resources/example.jar b/resources/example.jar
index 53e8372..11ecab7 100644
Binary files a/resources/example.jar and b/resources/example.jar differ
diff --git a/resources/launcher.sh b/resources/launcher.sh
new file mode 100644
index 0000000..8623559
--- /dev/null
+++ b/resources/launcher.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+
+# -----------------------------------------------------------------------------
+# File generated by Colorize Gradle application plugin
+# -----------------------------------------------------------------------------
+
+LAUNCHER_DIR=$(dirname "$0")
+
+"$LAUNCHER_DIR/../PlugIns/{{jdk}}/Contents/Home/bin/java" \
+ -Djava.launcher.path="$LAUNCHER_DIR" \
+ -Djava.library.path="$LAUNCHER_DIR" \
+ -Xmx2g \
+ -Xdock:name="{{appName}}" \
+ -Xdock:icon="$LAUNCHER_DIR/../Resources/icon.icns" \
+ -jar "$LAUNCHER_DIR/../Java/{{jarFileName}}" {{appArgs}}
diff --git a/resources/xcodegen-template.yml b/resources/xcodegen-template.yml
new file mode 100644
index 0000000..cdf67d1
--- /dev/null
+++ b/resources/xcodegen-template.yml
@@ -0,0 +1,33 @@
+name: "{{appName}}"
+options:
+ createIntermediateGroups: true
+targets:
+ {{appId}}:
+ type: application
+ platform: iOS
+ deploymentTarget: "{{deploymentTarget}}"
+ sources:
+ - {{appId}}
+ - path: HybridResources
+ type: folder
+ info:
+ path: "{{appId}}/Info.plist"
+ properties:
+ CFBundleDisplayName: "{{appName}}"
+ CFBundleShortVersionString: $(MARKETING_VERSION)
+ CFBundleVersion: $(CURRENT_PROJECT_VERSION)
+ UILaunchScreen:
+ UIColorName: "{{launchScreenColor}}"
+ UISupportedInterfaceOrientations~ipad:
+ - UIInterfaceOrientationPortrait
+ - UIInterfaceOrientationPortraitUpsideDown
+ - UIInterfaceOrientationLandscapeLeft
+ - UIInterfaceOrientationLandscapeRight
+ settings:
+ PRODUCT_BUNDLE_IDENTIFIER: {{bundleId}}
+ ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
+ TARGETED_DEVICE_FAMILY: 1,2
+ PRODUCT_NAME: "{{appName}}"
+ INFOPLIST_KEY_CFBundleDisplayName: "{{appName}}"
+ CURRENT_PROJECT_VERSION: "{{buildVersion}}"
+ MARKETING_VERSION: "{{appVersion}}"
diff --git a/source/nl/colorize/gradle/application/AppHelper.java b/source/nl/colorize/gradle/application/AppHelper.java
index a09589d..fd53d7c 100644
--- a/source/nl/colorize/gradle/application/AppHelper.java
+++ b/source/nl/colorize/gradle/application/AppHelper.java
@@ -14,7 +14,9 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
+import java.util.List;
import java.util.Map;
+import java.util.function.Predicate;
import static java.nio.charset.StandardCharsets.UTF_8;
@@ -50,10 +52,6 @@ public static File getLibsDir(Project project) {
return new File(buildDir, libsDirName);
}
- public static String getJarFileName(Project project) {
- return (String) project.getProperties().get("jar.archiveFileName");
- }
-
public static void check(boolean condition, String message) {
if (!condition) {
throw new IllegalArgumentException(message);
@@ -100,13 +98,23 @@ public static void cleanDirectory(File dir) {
Files.walk(dir.toPath())
.sorted(Comparator.reverseOrder())
.map(Path::toFile)
+ .filter(file -> !file.equals(dir))
.forEach(File::delete);
} catch (IOException e) {
throw new RuntimeException("Unable to delete: " + dir.getAbsolutePath());
}
}
+ }
- dir.mkdir();
+ public static List walk(File start, Predicate filter) {
+ try {
+ return Files.walk(start.toPath())
+ .map(Path::toFile)
+ .filter(filter)
+ .toList();
+ } catch (IOException e) {
+ throw new RuntimeException("Error while walking " + start.getAbsolutePath(), e);
+ }
}
public static File mkdir(File dir) {
@@ -118,6 +126,13 @@ public static File mkdir(File dir) {
return dir;
}
+ public static void exec(Project project, List command, File workDir) {
+ project.exec(exec -> {
+ exec.commandLine(command);
+ exec.workingDir(workDir);
+ });
+ }
+
public static String loadResourceFile(String path) {
try (InputStream stream = AppHelper.class.getClassLoader().getResourceAsStream(path)) {
check(stream != null, "Unable to locate resource file: " + path);
@@ -129,30 +144,15 @@ public static String loadResourceFile(String path) {
}
/**
- * Loads the specified resource file into a string, and then substitutes
- * the specified placeholders with the provided values.
+ * Loads a template from the specified classpath resource, then rewrites
+ * the placeholders in the template using the actual values. The
+ * placeholders should use the format "{{name}}".
*/
- public static String loadResourceFile(String path, Map properties) {
- String contents = loadResourceFile(path);
- for (Map.Entry entry : properties.entrySet()) {
- contents = contents.replace(entry.getKey(), entry.getValue());
- }
- return contents;
- }
-
- public static void clearOutputDir(File outputDir) {
- if (!outputDir.exists()) {
- return;
- }
-
- try {
- Files.walk(outputDir.toPath())
- .sorted(Comparator.reverseOrder())
- .map(Path::toFile)
- .filter(file -> !file.equals(outputDir))
- .forEach(File::delete);
- } catch (IOException e) {
- throw new RuntimeException("Unable to clear directory: " + outputDir.getAbsolutePath());
+ public static String rewriteTemplate(String templatePath, Map placeholders) {
+ String template = loadResourceFile(templatePath);
+ for (Map.Entry entry : placeholders.entrySet()) {
+ template = template.replace(entry.getKey(), entry.getValue());
}
+ return template;
}
}
diff --git a/source/nl/colorize/gradle/application/ApplicationPlugin.java b/source/nl/colorize/gradle/application/ApplicationPlugin.java
index ff273a9..8034082 100644
--- a/source/nl/colorize/gradle/application/ApplicationPlugin.java
+++ b/source/nl/colorize/gradle/application/ApplicationPlugin.java
@@ -8,6 +8,7 @@
import nl.colorize.gradle.application.macapplicationbundle.CreateApplicationBundleTask;
import nl.colorize.gradle.application.macapplicationbundle.MacApplicationBundleExt;
+import nl.colorize.gradle.application.macapplicationbundle.PackageApplicationBundleTask;
import nl.colorize.gradle.application.macapplicationbundle.SignApplicationBundleTask;
import nl.colorize.gradle.application.pwa.GeneratePwaTask;
import nl.colorize.gradle.application.pwa.PwaExt;
@@ -47,9 +48,11 @@ private void configureMacApplicationBundle(Project project) {
TaskContainer tasks = project.getTasks();
tasks.create("createApplicationBundle", CreateApplicationBundleTask.class);
tasks.create("signApplicationBundle", SignApplicationBundleTask.class);
+ tasks.create("packageApplicationBundle", PackageApplicationBundleTask.class);
tasks.getByName("signApplicationBundle").dependsOn(tasks.getByName("createApplicationBundle"));
tasks.getByName("createApplicationBundle").dependsOn("jar");
+ tasks.getByName("packageApplicationBundle").dependsOn("jar");
}
private void configureWindows(Project project) {
diff --git a/source/nl/colorize/gradle/application/macapplicationbundle/CreateApplicationBundleTask.java b/source/nl/colorize/gradle/application/macapplicationbundle/CreateApplicationBundleTask.java
index 4a25908..ac32dee 100644
--- a/source/nl/colorize/gradle/application/macapplicationbundle/CreateApplicationBundleTask.java
+++ b/source/nl/colorize/gradle/application/macapplicationbundle/CreateApplicationBundleTask.java
@@ -21,13 +21,13 @@
import java.io.File;
import java.io.IOException;
-import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
-import java.util.jar.JarEntry;
-import java.util.jar.JarFile;
+import java.util.Map;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
public class CreateApplicationBundleTask extends DefaultTask {
@@ -46,8 +46,9 @@ protected void run(MacApplicationBundleExt config) {
File outputDir = config.getOutputDir(getProject());
AppHelper.cleanDirectory(outputDir);
bundle(config, jdk, outputDir);
- if (config.isExtractNatives()) {
- extractNativeLibraries(outputDir);
+
+ if (config.getLauncher().equals("shell")) {
+ generateShellLauncher(config, jdk);
}
}
@@ -173,43 +174,41 @@ private String getShortVersion(MacApplicationBundleExt config) {
}
/**
- * Extracts all embedded native libraries from JAR files, as extracting
- * at runtime is not allowed by the Mac App Store.
+ * Generates a Shell script that launches the application. This will then
+ * be used instead of the normal native launcher executable, which has
+ * compatibility problems with some applications.
*/
- private void extractNativeLibraries(File outputDir) {
- try {
- Files.walk(outputDir.toPath())
- .map(Path::toFile)
- .filter(file -> file.getName().endsWith(".jar"))
- .filter(file -> file.getParentFile().getName().equals("Java"))
- .forEach(this::extractNativeLibrariesFromJAR);
- } catch (IOException e) {
- throw new RuntimeException("Failed to extract native libraries", e);
- }
- }
+ private void generateShellLauncher(MacApplicationBundleExt config, File jdk) {
+ File appBundle = config.locateApplicationBundle(getProject());
+ Path embeddedJDK = config.locateEmbeddedJDK(getProject()).toPath();
- private void extractNativeLibrariesFromJAR(File jarFile) {
- File outputDir = jarFile.getParentFile();
+ Map launcherProperties = Map.of(
+ "{{jdk}}", embeddedJDK.getFileName().toString(),
+ "{{jarFileName}}", config.getMainJarName(),
+ "{{appName}}", config.getName(),
+ "{{appArgs}}", String.join(" ", config.getArgs())
+ );
- try (JarFile jar = new JarFile(jarFile)) {
- jar.stream()
- .filter(entry -> entry.getName().endsWith(".dylib"))
- .forEach(entry -> extractNativeLibrary(jar, entry, outputDir));
- } catch (IOException e) {
- throw new RuntimeException("Failed to extract native libraries from " + jarFile, e);
- }
- }
-
- private void extractNativeLibrary(JarFile jar, JarEntry entry, File outputDir) {
- File outputFile = new File(outputDir, "native-" + entry.getName().replace("/", "-"));
- if (outputFile.exists()) {
- throw new IllegalStateException("File already exists: " + outputFile);
- }
-
- try (InputStream stream = jar.getInputStream(entry)) {
- Files.copy(stream, outputFile.toPath());
+ try {
+ File launcher = new File(appBundle, "/Contents/MacOS/ColorizeLauncher");
+ String template = AppHelper.rewriteTemplate("launcher.sh", launcherProperties);
+ Files.writeString(launcher.toPath(), template, UTF_8);
+ launcher.setExecutable(true, false);
+
+ File plistFile = new File(appBundle, "/Contents/Info.plist");
+ String plist = Files.readString(plistFile.toPath(), UTF_8);
+ plist = plist.replace("JavaAppLauncher", "ColorizeLauncher");
+ Files.writeString(plistFile.toPath(), plist, UTF_8);
+
+ // JavaAppLauncher doesn't need the Java binary,
+ // but the shell script does.
+ Files.createDirectory(embeddedJDK.resolve("Contents/Home/bin"));
+ Files.copy(jdk.toPath().resolve("bin/java"), embeddedJDK.resolve("Contents/Home/bin/java"));
+
+ File nativeLauncher = new File(appBundle, "/Contents/MacOS/JavaAppLauncher");
+ nativeLauncher.delete();
} catch (IOException e) {
- throw new RuntimeException("Failed to extract " + entry.getName(), e);
+ throw new RuntimeException("Error while generating shell launcher", e);
}
}
}
diff --git a/source/nl/colorize/gradle/application/macapplicationbundle/MacApplicationBundleExt.java b/source/nl/colorize/gradle/application/macapplicationbundle/MacApplicationBundleExt.java
index b4e8079..fbaf0a6 100644
--- a/source/nl/colorize/gradle/application/macapplicationbundle/MacApplicationBundleExt.java
+++ b/source/nl/colorize/gradle/application/macapplicationbundle/MacApplicationBundleExt.java
@@ -31,8 +31,8 @@ public class MacApplicationBundleExt implements Validatable {
private String applicationCategory;
private String minimumSystemVersion;
private List architectures;
-
private String contentDir;
+ private String mainJarName;
private String mainClassName;
private List modules;
private List additionalModules;
@@ -40,10 +40,14 @@ public class MacApplicationBundleExt implements Validatable {
private List args;
private boolean startOnFirstThread;
private String jdkPath;
- private boolean extractNatives;
+ private String launcher;
+ private boolean signNativeLibraries;
private String outputDir;
- public static final List SUPPORTED_EMBEDDED_JDKS = List.of(
+ public static final String SIGN_APP_ENV = "MAC_SIGN_APP_IDENTITY";
+ public static final String SIGN_INSTALLER_ENV = "MAC_SIGN_INSTALLER_IDENTITY";
+
+ private static final List SUPPORTED_EMBEDDED_JDKS = List.of(
"temurin-21.jdk",
"temurin-m1-21.jdk",
"temurin-17.jdk",
@@ -51,9 +55,6 @@ public class MacApplicationBundleExt implements Validatable {
"adoptopenjdk-11.jdk"
);
- public static final String SIGN_APP_ENV = "MAC_SIGN_APP_IDENTITY";
- public static final String SIGN_INSTALLER_ENV = "MAC_SIGN_INSTALLER_IDENTITY";
-
private static final List DEFAULT_MODULES = List.of(
"java.base",
"java.desktop",
@@ -71,15 +72,14 @@ public MacApplicationBundleExt() {
minimumSystemVersion = "10.13";
architectures = List.of("arm64", "x86_64");
-
modules = DEFAULT_MODULES;
additionalModules = Collections.emptyList();
options = List.of("-Xmx2g");
args = Collections.emptyList();
startOnFirstThread = false;
-
jdkPath = AppHelper.getEnvironmentVariable("JAVA_HOME");
- extractNatives = false;
+ launcher = "native";
+ signNativeLibraries = false;
outputDir = "mac";
}
@@ -92,10 +92,40 @@ public void validate() {
AppHelper.check(name != null, "Missing macApplicationBundle.name");
AppHelper.check(identifier != null, "Missing macApplicationBundle.identifier");
AppHelper.check(bundleVersion != null, "Missing macApplicationBundle.bundleVersion");
+ AppHelper.check(mainJarName != null, "Missing macApplicationBundle.mainJarName");
AppHelper.check(mainClassName != null, "Missing macApplicationBundle.mainClassName");
File jdk = new File(jdkPath);
AppHelper.check(jdk.exists(), "JDK not found: " + jdk.getAbsolutePath());
AppHelper.check(jdk.getName().equals("Home"), "JDK should point to /Contents/Home");
+
+ AppHelper.check(List.of("native", "shell").contains(launcher),
+ "Unknown launcher type in macApplicationBundle.launcher");
+ }
+
+ protected File locateApplicationBundle(Project project) {
+ return new File(getOutputDir(project), getName() + ".app");
+ }
+
+ protected File locateEmbeddedJDK(Project project) {
+ File appBundle = locateApplicationBundle(project);
+ File pluginsDir = new File(appBundle.getAbsolutePath() + "/Contents/PlugIns");
+ File embeddedJDK = new File(pluginsDir, getEmbeddedJdkName());
+
+ if (!embeddedJDK.exists()) {
+ throw new IllegalStateException("Cannot locate embedded JDK in " +
+ embeddedJDK.getAbsolutePath());
+ }
+
+ return embeddedJDK;
+ }
+
+ private String getEmbeddedJdkName() {
+ String javaHome = System.getenv("JAVA_HOME");
+
+ return MacApplicationBundleExt.SUPPORTED_EMBEDDED_JDKS.stream()
+ .filter(javaHome::contains)
+ .findFirst()
+ .orElseThrow(() -> new IllegalArgumentException("Unsupported JDK: " + javaHome));
}
}
diff --git a/source/nl/colorize/gradle/application/macapplicationbundle/PackageApplicationBundleTask.java b/source/nl/colorize/gradle/application/macapplicationbundle/PackageApplicationBundleTask.java
new file mode 100644
index 0000000..f3c7259
--- /dev/null
+++ b/source/nl/colorize/gradle/application/macapplicationbundle/PackageApplicationBundleTask.java
@@ -0,0 +1,100 @@
+//-----------------------------------------------------------------------------
+// Gradle Application Plugin
+// Copyright 2010-2024 Colorize
+// Apache license (http://www.apache.org/licenses/LICENSE-2.0)
+//-----------------------------------------------------------------------------
+
+package nl.colorize.gradle.application.macapplicationbundle;
+
+import nl.colorize.gradle.application.AppHelper;
+import org.gradle.api.DefaultTask;
+import org.gradle.api.plugins.ExtensionContainer;
+import org.gradle.api.tasks.TaskAction;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Gradle task to create a Mac application bundle using the {@code jpackage}
+ * tool that is included with the JDK.
+ */
+public class PackageApplicationBundleTask extends DefaultTask {
+
+ private static final String ENTITLEMENTS = "entitlements-app.plist";
+
+ @TaskAction
+ public void run() {
+ AppHelper.requireMac();
+ ExtensionContainer ext = getProject().getExtensions();
+ MacApplicationBundleExt config = ext.getByType(MacApplicationBundleExt.class);
+ run(config);
+ }
+
+ protected void run(MacApplicationBundleExt config) {
+ File outputDir = config.getOutputDir(getProject());
+ AppHelper.cleanDirectory(outputDir);
+
+ getProject().exec(exec -> exec.commandLine(getCommand("dmg", config)));
+ getProject().exec(exec -> exec.commandLine(getCommand("pkg", config)));
+ }
+
+ protected List getCommand(String packageType, MacApplicationBundleExt config) {
+ List command = new ArrayList<>();
+ command.add("jpackage");
+ command.add("--type");
+ command.add(packageType);
+ command.add("--app-version");
+ command.add(config.getBundleVersion());
+ command.add("--copyright");
+ command.add(config.getCopyright());
+ command.add("--description");
+ command.add(config.getDescription());
+ command.add("--icon");
+ command.add(new File(config.getIcon()).getAbsolutePath());
+ command.add("--name");
+ command.add(config.getName());
+ command.add("--dest");
+ command.add(config.getOutputDir(getProject()).getAbsolutePath());
+ command.add("--add-modules");
+ command.add(getModules(config));
+ command.add("--main-class");
+ command.add(config.getMainClassName());
+ command.add("--main-jar");
+ command.add(config.getMainJarName());
+ command.add("--input");
+ command.add(config.getContentDir());
+ if (!config.getArgs().isEmpty()) {
+ command.add("--arguments");
+ command.add(String.join(" ", config.getArgs()));
+ }
+ command.add("--mac-sign");
+ command.add("--mac-app-store");
+ command.add("--mac-entitlements");
+ command.add(generateEntitlements().getAbsolutePath());
+ command.add("--mac-signing-key-user-name");
+ command.add(AppHelper.getEnvironmentVariable(MacApplicationBundleExt.SIGN_APP_ENV));
+ return command;
+ }
+
+ private String getModules(MacApplicationBundleExt config) {
+ List modules = new ArrayList<>();
+ modules.addAll(config.getModules());
+ modules.addAll(config.getAdditionalModules());
+ return String.join(",", modules);
+ }
+
+ private File generateEntitlements() {
+ try (InputStream stream = getClass().getClassLoader().getResourceAsStream(ENTITLEMENTS)) {
+ byte[] contents = stream.readAllBytes();
+ File tempFile = File.createTempFile("entitlements-" + System.currentTimeMillis(), ".plist");
+ Files.write(tempFile.toPath(), contents);
+ return tempFile;
+ } catch (IOException e) {
+ throw new RuntimeException("Error while generating entitlements file", e);
+ }
+ }
+}
diff --git a/source/nl/colorize/gradle/application/macapplicationbundle/SignApplicationBundleTask.java b/source/nl/colorize/gradle/application/macapplicationbundle/SignApplicationBundleTask.java
index 18d81cf..11f9ff0 100644
--- a/source/nl/colorize/gradle/application/macapplicationbundle/SignApplicationBundleTask.java
+++ b/source/nl/colorize/gradle/application/macapplicationbundle/SignApplicationBundleTask.java
@@ -15,8 +15,10 @@
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
-import java.nio.file.Path;
+import java.util.Collections;
import java.util.List;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
public class SignApplicationBundleTask extends DefaultTask {
@@ -38,74 +40,59 @@ public void run() {
}
protected void run(MacApplicationBundleExt config) throws IOException {
- File appBundle = new File(config.getOutputDir(getProject()), config.getName() + ".app");
+ File appBundle = config.locateApplicationBundle(getProject());
+ File embeddedJDK = config.locateEmbeddedJDK(getProject());
+ File appEntitlements = generateEntitlements(ENTITLEMENTS_APP);
+ File jreEntitlements = generateEntitlements(ENTITLEMENTS_JRE);
- if (!appBundle.exists()) {
- throw new IllegalStateException("Application bundle does not exist: " +
- appBundle.getAbsolutePath());
+ if (config.isSignNativeLibraries()) {
+ extractNativeLibraries(config);
}
- signBundle(appBundle, config);
- }
-
- private void signBundle(File appBundle, MacApplicationBundleExt config) throws IOException {
- File embeddedJDK = locateEmbeddedJDK(appBundle);
-
- File appEntitlements = generateEntitlements(ENTITLEMENTS_APP);
- File jreEntitlements = generateEntitlements(ENTITLEMENTS_JRE);
+ for (File file : AppHelper.walk(appBundle, this::isNativeBinary)) {
+ sign(file, jreEntitlements);
+ }
- Files.walk(appBundle.toPath())
- .map(Path::toFile)
- .filter(file -> file.getName().endsWith(".dylib") || file.getName().equals("jspawnhelper"))
- .forEach(bin -> sign(bin, jreEntitlements));
+ File shellLauncher = new File(appBundle, "/Contents/MacOS/ColorizeLauncher");
+ if (shellLauncher.exists()) {
+ //sign(shellLauncher, appEntitlements);
+ }
sign(embeddedJDK, jreEntitlements);
sign(appBundle, appEntitlements);
createInstallerPackage(config, appBundle);
}
+ private boolean isNativeBinary(File file) {
+ return file.getName().endsWith(".dylib") || file.getName().equals("jspawnhelper");
+ }
+
private void sign(File target, File entitlements) {
- exec(
+ List command = List.of(
"codesign",
"-s", AppHelper.getEnvironmentVariable(MacApplicationBundleExt.SIGN_APP_ENV),
"-vvvv",
"--force",
"--entitlements", entitlements.getAbsolutePath(),
+ "--options", "runtime",
target.getAbsolutePath()
);
+
+ getProject().exec(exec -> exec.commandLine(command));
}
private void createInstallerPackage(MacApplicationBundleExt config, File appFile) {
File pkgFile = new File(config.getOutputDir(getProject()), config.getName() + ".pkg");
- exec(
+ List command = List.of(
"productbuild",
"--component", appFile.getAbsolutePath(),
"/Applications",
"--sign", AppHelper.getEnvironmentVariable(MacApplicationBundleExt.SIGN_INSTALLER_ENV),
pkgFile.getAbsolutePath()
);
- }
-
- private File locateEmbeddedJDK(File appBundle) {
- File pluginsDir = new File(appBundle.getAbsolutePath() + "/Contents/PlugIns");
- File embeddedJDK = new File(pluginsDir, getEmbeddedJdkName());
-
- if (!embeddedJDK.exists()) {
- throw new IllegalStateException("Cannot locate embedded JDK in " +
- embeddedJDK.getAbsolutePath());
- }
-
- return embeddedJDK;
- }
- private String getEmbeddedJdkName() {
- String javaHome = System.getenv("JAVA_HOME");
-
- return MacApplicationBundleExt.SUPPORTED_EMBEDDED_JDKS.stream()
- .filter(javaHome::contains)
- .findFirst()
- .orElseThrow(() -> new IllegalArgumentException("Unsupported JDK: " + javaHome));
+ getProject().exec(exec -> exec.commandLine(command));
}
private File generateEntitlements(String sourceFile) throws IOException {
@@ -119,8 +106,32 @@ private File generateEntitlements(String sourceFile) throws IOException {
return tempFile;
}
- private void exec(String... command) {
- List args = List.of(command);
- getProject().exec(exec -> exec.commandLine(args));
+ private void extractNativeLibraries(MacApplicationBundleExt config) throws IOException {
+ File appBundle = config.locateApplicationBundle(getProject());
+ File jarDir = new File(appBundle, "/Contents/Java");
+ File jarFile = new File(jarDir, config.getMainJarName());
+ File nativesDir = new File(appBundle, "/Contents/MacOS");
+
+ try (JarFile jar = new JarFile(jarFile)) {
+ for (JarEntry entry : Collections.list(jar.entries())) {
+ if (isCompatibleNativeLibrary(entry.getName(), config)) {
+ String fileName = entry.getName().substring(entry.getName().lastIndexOf("/") + 1);
+ File dylib = new File(nativesDir, fileName);
+ if (!dylib.exists()) {
+ Files.copy(jar.getInputStream(entry), dylib.toPath());
+ }
+ }
+ }
+ }
+ }
+
+ private boolean isCompatibleNativeLibrary(String name, MacApplicationBundleExt config) {
+ if (!name.endsWith(".dylib")) {
+ return false;
+ }
+
+ boolean intel = name.contains("x64") || name.contains("x86");
+ boolean arm = name.contains("arm64") || name.contains("aarch");
+ return config.getArchitectures().contains("x86_64") ? !arm : !intel;
}
}
diff --git a/source/nl/colorize/gradle/application/pwa/GeneratePwaTask.java b/source/nl/colorize/gradle/application/pwa/GeneratePwaTask.java
index 743231d..7c2687a 100644
--- a/source/nl/colorize/gradle/application/pwa/GeneratePwaTask.java
+++ b/source/nl/colorize/gradle/application/pwa/GeneratePwaTask.java
@@ -31,7 +31,7 @@ protected void run(PwaExt config) {
config.validate();
File outputDir = config.getOutputDir(getProject());
- AppHelper.clearOutputDir(outputDir);
+ AppHelper.cleanDirectory(outputDir);
getProject().copy(copy -> {
copy.from(config.getWebAppDir());
@@ -87,7 +87,7 @@ private String prepareServiceWorker(PwaExt config) throws IOException {
.map(file -> "\"/" + file + "\",\n")
.collect(Collectors.joining(""));
- return AppHelper.loadResourceFile("service-worker.js", Map.of(
+ return AppHelper.rewriteTemplate("service-worker.js", Map.of(
"{{cacheName}}", config.getCacheName(),
"{{resourceFiles}}", resourceFileList
));
diff --git a/source/nl/colorize/gradle/application/staticsite/GenerateStaticSiteTask.java b/source/nl/colorize/gradle/application/staticsite/GenerateStaticSiteTask.java
index e3ad3bc..a5392f6 100644
--- a/source/nl/colorize/gradle/application/staticsite/GenerateStaticSiteTask.java
+++ b/source/nl/colorize/gradle/application/staticsite/GenerateStaticSiteTask.java
@@ -73,7 +73,7 @@ private void reset(File outputDir) {
outputDir.mkdir();
}
- AppHelper.clearOutputDir(outputDir);
+ AppHelper.cleanDirectory(outputDir);
templateCache.clear();
}
diff --git a/source/nl/colorize/gradle/application/windowsexe/PackageWindowsStandaloneTask.java b/source/nl/colorize/gradle/application/windowsexe/PackageWindowsStandaloneTask.java
index 9e68f4b..9a6defe 100644
--- a/source/nl/colorize/gradle/application/windowsexe/PackageWindowsStandaloneTask.java
+++ b/source/nl/colorize/gradle/application/windowsexe/PackageWindowsStandaloneTask.java
@@ -134,7 +134,7 @@ private void addZipEntry(ZipOutputStream zip, String zipPath, Path file) {
private File getMainJarFile(WindowsStandaloneExt config) {
Project project = getProject();
- File jarFile = new File(AppHelper.getLibsDir(project), config.getMainJarName(project));
+ File jarFile = new File(AppHelper.getLibsDir(project), config.getMainJarName());
AppHelper.check(jarFile.exists(), "Cannot locate JAR file: " + jarFile.getAbsolutePath());
return jarFile;
}
diff --git a/source/nl/colorize/gradle/application/windowsexe/WindowsStandaloneExt.java b/source/nl/colorize/gradle/application/windowsexe/WindowsStandaloneExt.java
index 9f55ab6..f3c5e69 100644
--- a/source/nl/colorize/gradle/application/windowsexe/WindowsStandaloneExt.java
+++ b/source/nl/colorize/gradle/application/windowsexe/WindowsStandaloneExt.java
@@ -37,17 +37,10 @@ public WindowsStandaloneExt() {
this.javaVersion = "17";
}
- public String getMainJarName(Project project) {
- if (mainJarName != null) {
- return mainJarName;
- }
- return AppHelper.getJarFileName(project);
- }
-
public File getExeFile(Project project) {
String fileName = exeFileName;
if (exeFileName == null) {
- fileName = getMainJarName(project).replace(".jar", ".exe");
+ fileName = mainJarName.replace(".jar", ".exe");
}
return new File(project.getBuildDir(), fileName);
}
@@ -59,10 +52,12 @@ public void validate() {
AppHelper.check(icon != null, "Missing exe.icon");
AppHelper.check(icon.endsWith(".ico"), "Windows icon must be a .ico file");
AppHelper.check(supportURL != null, "Missing exe.supportURL");
+ AppHelper.check(mainJarName != null, "Missing exe.mainJarName");
}
public void inherit(MacApplicationBundleExt macConfig) {
name = macConfig.getName();
version = macConfig.getBundleVersion();
+ mainJarName = macConfig.getMainJarName();
}
}
diff --git a/source/nl/colorize/gradle/application/windowsmsi/PackageMSITask.java b/source/nl/colorize/gradle/application/windowsmsi/PackageMSITask.java
index a2d57ee..1d55dc1 100644
--- a/source/nl/colorize/gradle/application/windowsmsi/PackageMSITask.java
+++ b/source/nl/colorize/gradle/application/windowsmsi/PackageMSITask.java
@@ -40,7 +40,7 @@ protected List buildPackageCommand(WindowsInstallerExt config) {
"jpackage",
"--type", "msi",
"--input", AppHelper.getLibsDir(getProject()).getAbsolutePath(),
- "--main-jar", config.getMainJarName(getProject()),
+ "--main-jar", config.getMainJarName(),
"--main-class", config.getMainClassName(),
"--name", config.getName(),
"--app-version", config.getVersion(),
diff --git a/source/nl/colorize/gradle/application/windowsmsi/WindowsInstallerExt.java b/source/nl/colorize/gradle/application/windowsmsi/WindowsInstallerExt.java
index 5b4aa57..0672372 100644
--- a/source/nl/colorize/gradle/application/windowsmsi/WindowsInstallerExt.java
+++ b/source/nl/colorize/gradle/application/windowsmsi/WindowsInstallerExt.java
@@ -40,19 +40,13 @@ public WindowsInstallerExt() {
this.outputDir = "windows-msi";
}
- public String getMainJarName(Project project) {
- if (mainJarName != null) {
- return mainJarName;
- }
- return AppHelper.getJarFileName(project);
- }
-
public File getOutputDir(Project project) {
return AppHelper.getOutputDir(project, outputDir);
}
@Override
public void validate() {
+ AppHelper.check(mainJarName != null, "Missing msi.mainJarName");
AppHelper.check(mainClassName != null, "Missing msi.mainClassName");
AppHelper.check(name != null, "Missing msi.name");
AppHelper.check(version != null, "Missing msi.version");
@@ -65,6 +59,7 @@ public void validate() {
}
public void inherit(MacApplicationBundleExt macConfig) {
+ mainJarName = macConfig.getMainJarName();
mainClassName = macConfig.getMainClassName();
name = macConfig.getName();
version = macConfig.getBundleVersion();
diff --git a/source/nl/colorize/gradle/application/xcode/XcodeGenTask.java b/source/nl/colorize/gradle/application/xcode/XcodeGenTask.java
index d3abd11..5ac4e73 100644
--- a/source/nl/colorize/gradle/application/xcode/XcodeGenTask.java
+++ b/source/nl/colorize/gradle/application/xcode/XcodeGenTask.java
@@ -13,12 +13,13 @@
import javax.imageio.ImageIO;
import java.awt.Color;
import java.awt.Graphics2D;
+import java.awt.Image;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
-import java.io.PrintWriter;
import java.nio.file.Files;
import java.util.List;
+import java.util.Map;
import static java.awt.RenderingHints.KEY_ANTIALIASING;
import static java.awt.RenderingHints.KEY_INTERPOLATION;
@@ -84,42 +85,21 @@ protected void generateProjectStructure(XcodeGenExt ext, File outputDir) throws
}
protected void generateSpecFile(XcodeGenExt ext, File specFile) {
- try (PrintWriter writer = new PrintWriter(specFile, UTF_8)) {
- writer.println("name: " + ext.getAppName());
- writer.println("options:");
- writer.println(" createIntermediateGroups: true");
- writer.println("targets:");
- writer.println(" " + ext.getAppId() + ":");
- writer.println(" type: application");
- writer.println(" platform: iOS");
- writer.println(" deploymentTarget: \"" + ext.getDeploymentTarget() + "\"");
- writer.println(" sources:");
- writer.println(" - " + ext.getAppId());
- writer.println(" - path: HybridResources");
- writer.println(" type: folder");
- writer.println(" info:");
- writer.println(" path: \"" + ext.getAppId() + "/Info.plist\"");
- writer.println(" properties:");
- writer.println(" CFBundleDisplayName: \"" + ext.getAppName() + "\"");
- writer.println(" CFBundleShortVersionString: \"" + ext.getAppVersion() + "\"");
- writer.println(" CFBundleVersion: \"" + ext.getBuildVersion() + "\"");
- writer.println(" UILaunchScreen:");
- writer.println(" UIColorName: " + ext.getLaunchScreenColor());
- writer.println(" UISupportedInterfaceOrientations~ipad:");
- writer.println(" - UIInterfaceOrientationPortrait");
- writer.println(" - UIInterfaceOrientationPortraitUpsideDown");
- writer.println(" - UIInterfaceOrientationLandscapeLeft");
- writer.println(" - UIInterfaceOrientationLandscapeRight");
- writer.println(" settings:");
- writer.println(" PRODUCT_BUNDLE_IDENTIFIER: " + ext.getBundleId());
- writer.println(" ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon");
- writer.println(" TARGETED_DEVICE_FAMILY: 1,2");
- writer.println(" PRODUCT_NAME: \"" + ext.getAppName() + "\"");
- writer.println(" INFOPLIST_KEY_CFBundleDisplayName: \"" + ext.getAppName() + "\"");
- writer.println(" CURRENT_PROJECT_VERSION: \"" + ext.getBuildVersion() + "\"");
- writer.println(" MARKETING_VERSION: \"" + ext.getAppVersion() + "\"");
+ Map properties = Map.of(
+ "{{appName}}", ext.getAppName(),
+ "{{appId}}", ext.getAppId(),
+ "{{deploymentTarget}}", ext.getDeploymentTarget(),
+ "{{launchScreenColor}}", ext.getLaunchScreenColor(),
+ "{{bundleId}}", ext.getBundleId(),
+ "{{appVersion}}", ext.getAppVersion(),
+ "{{buildVersion}}", ext.getBuildVersion()
+ );
+
+ try {
+ String template = AppHelper.rewriteTemplate("xcodegen-template.yml", properties);
+ Files.writeString(specFile.toPath(), template, UTF_8);
} catch (IOException e) {
- throw new RuntimeException("Unable to generate XcodeGen spec file", e);
+ throw new RuntimeException("Error while generating XcodeGen spec file", e);
}
}
@@ -141,11 +121,11 @@ private void generateIconSet(File baseIconFile, File iconDir, Color background)
for (IconVariant variant : ICON_VARIANTS) {
BufferedImage image = new BufferedImage(variant.size, variant.size, TYPE_INT_ARGB);
Graphics2D g2 = image.createGraphics();
- g2.setColor(background);
- g2.fillRect(0, 0, image.getWidth(), image.getHeight());
g2.setRenderingHint(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON);
g2.setRenderingHint(KEY_INTERPOLATION, VALUE_INTERPOLATION_BILINEAR);
- g2.drawImage(base, 0, 0, image.getWidth(), image.getHeight(), null);
+ g2.setColor(background);
+ g2.fillRect(0, 0, variant.size, variant.size);
+ g2.drawImage(scaleImage(base, variant.size, variant.size, true), 0, 0, null);
g2.dispose();
File outputFile = new File(iconDir, "icon-" + variant.size + ".png");
@@ -153,6 +133,30 @@ private void generateIconSet(File baseIconFile, File iconDir, Color background)
}
}
+ private BufferedImage scaleImage(Image original, int width, int height, boolean highQuality) {
+ Image current = original;
+ int currentWidth = current.getWidth(null);
+ int currentHeight = current.getHeight(null);
+
+ while (highQuality && (currentWidth >= width * 2 || currentHeight >= height * 2)) {
+ currentWidth = currentWidth / 2;
+ currentHeight = currentHeight / 2;
+ current = scaleImage(current, currentWidth, currentHeight);
+ }
+
+ return scaleImage(current, width, height);
+ }
+
+ private BufferedImage scaleImage(Image original, int width, int height) {
+ BufferedImage result = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
+ Graphics2D g2 = result.createGraphics();
+ g2.setRenderingHint(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON);
+ g2.setRenderingHint(KEY_INTERPOLATION, VALUE_INTERPOLATION_BILINEAR);
+ g2.drawImage(original, 0, 0, width, height, null);
+ g2.dispose();
+ return result;
+ }
+
private List buildCommand(XcodeGenExt ext, File specFile, File outputDir) {
return List.of(
ext.getXcodeGenPath(),
diff --git a/test/nl/colorize/gradle/application/AppHelperTest.java b/test/nl/colorize/gradle/application/AppHelperTest.java
index c529bf2..048db47 100644
--- a/test/nl/colorize/gradle/application/AppHelperTest.java
+++ b/test/nl/colorize/gradle/application/AppHelperTest.java
@@ -14,6 +14,10 @@
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.gradle.internal.impldep.org.junit.Assert.assertFalse;
@@ -65,4 +69,42 @@ void getOutputDir(@TempDir File tempDir) {
assertEquals("test", outputDir.getName());
assertEquals("build", outputDir.getParentFile().getName());
}
+
+ @Test
+ void walk(@TempDir Path tempDir) throws IOException {
+ Files.writeString(tempDir.resolve("a.txt"), "a", UTF_8);
+ Files.writeString(tempDir.resolve("b.txt"), "b", UTF_8);
+
+ List files = AppHelper.walk(tempDir.toFile(), file -> file.getName().startsWith("a"));
+
+ assertEquals(1, files.size());
+ assertEquals("a.txt", files.getFirst().getName());
+ }
+
+ @Test
+ void rewriteTemplate() {
+ Map placeholders = Map.of(
+ "{{cacheName}}", "test",
+ "{{resourceFiles}}", "\"first\",\n \"second\""
+ );
+
+ String template = AppHelper.rewriteTemplate("service-worker.js", placeholders);
+ String head = template.lines().limit(11).collect(Collectors.joining("\n"));
+
+ String expected = """
+ //-----------------------------------------------------------------------------
+ // File generated by Colorize Gradle application plugin
+ //-----------------------------------------------------------------------------
+
+ const CACHE_NAME = "test";
+
+ const RESOURCE_FILES = [
+ "/",
+ "first",
+ "second"
+ ];
+ """;
+
+ assertEquals(expected.trim(), head);
+ }
}
diff --git a/test/nl/colorize/gradle/application/macapplicationbundle/CreateApplicationBundleTaskTest.java b/test/nl/colorize/gradle/application/macapplicationbundle/CreateApplicationBundleTaskTest.java
index 2e5361f..2135bbc 100644
--- a/test/nl/colorize/gradle/application/macapplicationbundle/CreateApplicationBundleTaskTest.java
+++ b/test/nl/colorize/gradle/application/macapplicationbundle/CreateApplicationBundleTaskTest.java
@@ -13,7 +13,11 @@
import org.junit.jupiter.api.io.TempDir;
import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CreateApplicationBundleTaskTest {
@@ -32,6 +36,7 @@ void createApplicationBundleJLink(@TempDir File tempDir) {
config.setIdentifier("com.example");
config.setDescription("A description for your application");
config.setCopyright("Copyright 2024");
+ config.setMainJarName("example.jar");
config.setMainClassName("HelloWorld.Main");
config.setContentDir("resources");
config.setBundleVersion("1.0");
@@ -53,4 +58,58 @@ void createApplicationBundleJLink(@TempDir File tempDir) {
assertTrue(new File(tempDir + "/build/mac/Example.app/Contents/Info.plist").exists());
assertTrue(new File(tempDir + "/build/mac/Example.app/Contents/PkgInfo").exists());
}
+
+ @Test
+ void generateLauncherScript(@TempDir File tempDir) throws IOException {
+ Project project = ProjectBuilder.builder()
+ .withProjectDir(tempDir)
+ .build();
+
+ ApplicationPlugin plugin = new ApplicationPlugin();
+ plugin.apply(project);
+
+ MacApplicationBundleExt config = new MacApplicationBundleExt();
+ config.setName("Example");
+ config.setIdentifier("com.example");
+ config.setMainJarName("example.jar");
+ config.setMainClassName("HelloWorld.Main");
+ config.setContentDir("resources");
+ config.setLauncher("shell");
+
+ CreateApplicationBundleTask task = (CreateApplicationBundleTask) project.getTasks()
+ .getByName("createApplicationBundle");
+ task.run(config);
+
+ File appDir = new File(tempDir + "/build/mac/Example.app");
+ File jdkDir = new File(appDir + "/Contents/PlugIns/temurin-21.jdk");
+ File launcher = new File(appDir + "/Contents/MacOS/ColorizeLauncher");
+
+ assertTrue(appDir.exists());
+ assertTrue(jdkDir.exists());
+ assertTrue(new File(jdkDir + "/Contents/Home/bin").exists());
+ assertTrue(new File(jdkDir + "/Contents/Home/bin/java").exists());
+ assertTrue(new File(jdkDir + "/Contents/Home/bin/java").canExecute());
+
+ String expected = """
+ #!/usr/bin/env bash
+
+ # -----------------------------------------------------------------------------
+ # File generated by Colorize Gradle application plugin
+ # -----------------------------------------------------------------------------
+
+ LAUNCHER_DIR=$(dirname "$0")
+
+ "$LAUNCHER_DIR/../PlugIns/temurin-21.jdk/Contents/Home/bin/java" \\
+ -Djava.launcher.path="$LAUNCHER_DIR" \\
+ -Djava.library.path="$LAUNCHER_DIR" \\
+ -Xmx2g \\
+ -Xdock:name="Example" \\
+ -Xdock:icon="$LAUNCHER_DIR/../Resources/icon.icns" \\
+ -jar "$LAUNCHER_DIR/../Java/example.jar"
+ """;
+
+ assertTrue(launcher.exists());
+ assertTrue(launcher.canExecute());
+ assertEquals(expected.strip(), Files.readString(launcher.toPath(), UTF_8).strip());
+ }
}
diff --git a/test/nl/colorize/gradle/application/macapplicationbundle/PackageApplicationBundleTaskTest.java b/test/nl/colorize/gradle/application/macapplicationbundle/PackageApplicationBundleTaskTest.java
new file mode 100644
index 0000000..0e46daa
--- /dev/null
+++ b/test/nl/colorize/gradle/application/macapplicationbundle/PackageApplicationBundleTaskTest.java
@@ -0,0 +1,86 @@
+//-----------------------------------------------------------------------------
+// Gradle Application Plugin
+// Copyright 2010-2024 Colorize
+// Apache license (http://www.apache.org/licenses/LICENSE-2.0)
+//-----------------------------------------------------------------------------
+
+package nl.colorize.gradle.application.macapplicationbundle;
+
+import nl.colorize.gradle.application.ApplicationPlugin;
+import org.gradle.api.Project;
+import org.gradle.testfixtures.ProjectBuilder;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class PackageApplicationBundleTaskTest {
+
+ @Test
+ void runJPackage(@TempDir File tempDir) {
+ Project project = ProjectBuilder.builder()
+ .withProjectDir(tempDir)
+ .build();
+
+ ApplicationPlugin plugin = new ApplicationPlugin();
+ plugin.apply(project);
+
+ project.copy(copy -> {
+ copy.from(new File("resources").getAbsolutePath());
+ copy.into(new File(tempDir, "resources").getAbsolutePath());
+ });
+
+ MacApplicationBundleExt config = new MacApplicationBundleExt();
+ config.setName("Example");
+ config.setIdentifier("com.example");
+ config.setMainJarName("example.jar");
+ config.setMainClassName("HelloWorld.Main");
+ config.setContentDir("resources");
+ config.setDescription("?");
+
+ PackageApplicationBundleTask task = (PackageApplicationBundleTask) project.getTasks()
+ .getByName("packageApplicationBundle");
+ List command = task.getCommand("dmg", config);
+
+ String expected = """
+ jpackage
+ --type
+ dmg
+ --app-version
+ 1.0
+ --copyright
+ Copyright 2024
+ --description
+ ?
+ --icon
+ icon.icns
+ --name
+ Example
+ --dest
+ mac
+ --add-modules
+ java.base,java.desktop,java.logging,java.net.http,java.sql,jdk.crypto.ec
+ --main-class
+ HelloWorld.Main
+ --main-jar
+ example.jar
+ --input
+ resources
+ --mac-sign
+ --mac-app-store
+ --mac-entitlements
+ entitlements-1234.plist
+ --mac-signing-key-user-name
+ 3rd Party Mac Developer Application: Colorize (F9TKFY3EK3)
+ """;
+
+ String cleanCommand = String.join("\n", command)
+ .replaceAll("/\\w+/.+/", "")
+ .replaceAll("\\d{4}\\d+", "1234");
+
+ assertEquals(expected.trim(), cleanCommand.trim());
+ }
+}
diff --git a/test/nl/colorize/gradle/application/macapplicationbundle/SignApplicationBundleTaskTest.java b/test/nl/colorize/gradle/application/macapplicationbundle/SignApplicationBundleTaskTest.java
index 05607e1..da48d80 100644
--- a/test/nl/colorize/gradle/application/macapplicationbundle/SignApplicationBundleTaskTest.java
+++ b/test/nl/colorize/gradle/application/macapplicationbundle/SignApplicationBundleTaskTest.java
@@ -31,6 +31,7 @@ void signApplicationBundle(@TempDir File tempDir) throws IOException {
MacApplicationBundleExt config = new MacApplicationBundleExt();
config.setName("Example");
config.setIdentifier("com.example");
+ config.setMainJarName("example.jar");
config.setMainClassName("HelloWorld.Main");
config.setContentDir("resources");
@@ -46,4 +47,36 @@ void signApplicationBundle(@TempDir File tempDir) throws IOException {
assertTrue(bundle.exists());
}
+
+ @Test
+ void extractNativeLibraries(@TempDir File tempDir) throws IOException {
+ Project project = ProjectBuilder.builder()
+ .withProjectDir(tempDir)
+ .build();
+
+ ApplicationPlugin plugin = new ApplicationPlugin();
+ plugin.apply(project);
+
+ MacApplicationBundleExt config = new MacApplicationBundleExt();
+ config.setName("Example");
+ config.setIdentifier("com.example");
+ config.setMainJarName("example.jar");
+ config.setMainClassName("HelloWorld.Main");
+ config.setContentDir("resources");
+ config.setSignNativeLibraries(true);
+
+ CreateApplicationBundleTask createTask = (CreateApplicationBundleTask) project.getTasks()
+ .getByName("createApplicationBundle");
+ createTask.run(config);
+
+ SignApplicationBundleTask signTask = (SignApplicationBundleTask) project.getTasks()
+ .getByName("signApplicationBundle");
+ signTask.run(config);
+
+ File bundle = new File(tempDir + "/build/mac/Example.app");
+
+ assertTrue(bundle.exists());
+ assertTrue(new File(bundle, "Contents/MacOS").exists());
+ assertTrue(new File(bundle, "Contents/MacOS/native.dylib").exists());
+ }
}
diff --git a/test/nl/colorize/gradle/application/windowsmsi/PackageMSITaskTest.java b/test/nl/colorize/gradle/application/windowsmsi/PackageMSITaskTest.java
index d89de4c..2db0322 100644
--- a/test/nl/colorize/gradle/application/windowsmsi/PackageMSITaskTest.java
+++ b/test/nl/colorize/gradle/application/windowsmsi/PackageMSITaskTest.java
@@ -30,6 +30,7 @@ void inheritConfiguration(@TempDir File tempDir) {
macConfig.setDescription("A simple example application");
macConfig.setCopyright("Copyright 2010-2024 Colorize");
macConfig.setIcon("resources/icon.icns");
+ macConfig.setMainJarName("example.jar");
macConfig.setMainClassName("com.example.ExampleApp");
WindowsInstallerExt windowsConfig = new WindowsInstallerExt();
diff --git a/test/nl/colorize/gradle/application/xcode/XcodeGenTaskTest.java b/test/nl/colorize/gradle/application/xcode/XcodeGenTaskTest.java
index 8e67c5f..e298538 100644
--- a/test/nl/colorize/gradle/application/xcode/XcodeGenTaskTest.java
+++ b/test/nl/colorize/gradle/application/xcode/XcodeGenTaskTest.java
@@ -18,7 +18,8 @@
import java.nio.file.Files;
import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
class XcodeGenTaskTest {
@@ -36,7 +37,7 @@ void generateSpecFile(@TempDir File tempDir) throws IOException {
task.generateSpecFile(config, specFile);
String expected = """
- name: Example App
+ name: "Example App"
options:
createIntermediateGroups: true
targets:
@@ -52,10 +53,10 @@ void generateSpecFile(@TempDir File tempDir) throws IOException {
path: "example/Info.plist"
properties:
CFBundleDisplayName: "Example App"
- CFBundleShortVersionString: "1.0"
- CFBundleVersion: "1.0"
+ CFBundleShortVersionString: $(MARKETING_VERSION)
+ CFBundleVersion: $(CURRENT_PROJECT_VERSION)
UILaunchScreen:
- UIColorName: #000000
+ UIColorName: "#000000"
UISupportedInterfaceOrientations~ipad:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
@@ -93,6 +94,75 @@ void generateProjectStructure(@TempDir File tempDir) throws IOException {
assertTrue(new File(tempDir, "HybridResources").exists());
}
+ @Test
+ void generateAppIcons(@TempDir File tempDir) throws IOException {
+ AppHelper.mkdir(new File(tempDir, "resources"));
+
+ XcodeGenExt config = new XcodeGenExt();
+ config.setAppId("example");
+ config.setBundleId("com.example");
+ config.setAppName("Example App");
+ config.setAppVersion("1.0");
+ config.setIcon(new File("resources/icon.png").getAbsolutePath());
+ config.setResourcesDir("resources");
+
+ XcodeGenTask task = prepareTask(tempDir);
+ task.generateProjectStructure(config, tempDir);
+
+ File iconDir = new File(tempDir, "example/Assets.xcassets/AppIcon.appiconset");
+ File index = new File(iconDir, "Contents.json");
+
+ String expected = """
+ {
+ "images" : [
+ {
+ "filename" : "icon-120.png",
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "60x60"
+ },
+ {
+ "filename" : "icon-180.png",
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "60x60"
+ },
+ {
+ "filename" : "icon-152.png",
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "76x76"
+ },
+ {
+ "filename" : "icon-167.png",
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "83.5x83.5"
+ },
+ {
+ "filename" : "icon-1024.png",
+ "idiom" : "ios-marketing",
+ "scale" : "1x",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+ }
+ """;
+
+ assertTrue(iconDir.exists());
+ assertTrue(new File(iconDir, "icon-1024.png").exists());
+ assertTrue(new File(iconDir, "icon-180.png").exists());
+ assertTrue(new File(iconDir, "icon-167.png").exists());
+ assertTrue(new File(iconDir, "icon-152.png").exists());
+ assertTrue(new File(iconDir, "icon-120.png").exists());
+ assertTrue(index.exists());
+ assertEquals(expected, Files.readString(index.toPath(), UTF_8));
+ }
+
private XcodeGenTask prepareTask(File tempDir) {
Project project = ProjectBuilder.builder()
.withProjectDir(tempDir)