From 3dc0786d90cd5e372b31d925e6ca652891b3990d Mon Sep 17 00:00:00 2001 From: Dennis Bijlsma Date: Sun, 18 Feb 2024 10:04:46 +0100 Subject: [PATCH] Support alternative launchers for Mac apps. --- build.gradle | 8 +- example/build.gradle | 20 +--- example/resources/example-gallery.png | Bin 41018 -> 0 bytes example/source/ExampleApp.java | 89 +++++++-------- readme.md | 101 +++++++++++------- resources/config.xml | 42 -------- resources/example.jar | Bin 787 -> 910 bytes resources/launcher.sh | 15 +++ resources/xcodegen-template.yml | 33 ++++++ .../gradle/application/AppHelper.java | 56 +++++----- .../gradle/application/ApplicationPlugin.java | 3 + .../CreateApplicationBundleTask.java | 75 +++++++------ .../MacApplicationBundleExt.java | 48 +++++++-- .../PackageApplicationBundleTask.java | 100 +++++++++++++++++ .../SignApplicationBundleTask.java | 95 ++++++++-------- .../application/pwa/GeneratePwaTask.java | 4 +- .../staticsite/GenerateStaticSiteTask.java | 2 +- .../PackageWindowsStandaloneTask.java | 2 +- .../windowsexe/WindowsStandaloneExt.java | 11 +- .../windowsmsi/PackageMSITask.java | 2 +- .../windowsmsi/WindowsInstallerExt.java | 9 +- .../application/xcode/XcodeGenTask.java | 82 +++++++------- .../gradle/application/AppHelperTest.java | 42 ++++++++ .../CreateApplicationBundleTaskTest.java | 59 ++++++++++ .../PackageApplicationBundleTaskTest.java | 86 +++++++++++++++ .../SignApplicationBundleTaskTest.java | 33 ++++++ .../windowsmsi/PackageMSITaskTest.java | 1 + .../application/xcode/XcodeGenTaskTest.java | 80 +++++++++++++- 28 files changed, 761 insertions(+), 337 deletions(-) delete mode 100644 example/resources/example-gallery.png delete mode 100644 resources/config.xml create mode 100644 resources/launcher.sh create mode 100644 resources/xcodegen-template.yml create mode 100644 source/nl/colorize/gradle/application/macapplicationbundle/PackageApplicationBundleTask.java create mode 100644 test/nl/colorize/gradle/application/macapplicationbundle/PackageApplicationBundleTaskTest.java 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 9b257a1540e20c15fd2a6d142b3884fc1b050cbd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 41018 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 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 53e83722a371d362aa13a31d68aa4c4ad87d0295..11ecab7694618bdd549fd1a2c6fadba696cb8c0a 100644 GIT binary patch delta 152 zcmbQt*2liVp2?P(MTCKagM-0*a$p1l65s){^Abxk%To1HDswWEv^{n89x^cmc(ZdH zGu>&;4wPnPm~72-#E+3lgaKg&LXv?2WCjA*GNGCn;LXYgl4SwHUqHGJD9^wE0532Z A{r~^~ delta 32 hcmeBUpUk$wo@sIe(*Y4yHU=PI2EqeC+Lj5#0|0e{1sMPU 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)