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)