# 【TornadoFX】Badass Runtime Pluginを使ってユーザ環境にJREが不要のデスクトップアプリを作ってみる【Kotlin】

# この記事でやること

(忙しい人はこのリポジトリbuild.gradle.kts を見てください。)

Badass Runtime Plugin を使って TornadoFX 製のデスクトップアプリを .exe / .app 形式にビルドします。 ユーザ環境にはJREのインストールが不要です。
また、GitHub Actionsで .exe.app にビルドしてリリースするところまでやります。

# 開発環境

以下が私の開発環境です。
(GitHub Actionsの ubuntu-latestwindows-latest 上でもビルドが通ったことが確認できたので、JDKのバージョンさえ気をつければ大丈夫かと思われます。)

  • JDK 14
    • バージョンは絶対 14 以降にしてください。jpackageを使うので。
    • 私はこれ (https://jdk.java.net/14/) で実行しました。 (環境構築時の2020/5/2時点で 14.0.1)
    • JavaFX (OpenJFX) が入っていないディストリビューションで大丈夫です。
  • IntelliJ IDEA 2020.1.1 (Community Edition)
  • macOS 10.14.6

# Gradleの設定

Kotlin DSLとbuildSrcで書いています。 Groovyで書いている人は脳内で変換してください。

# プロジェクトの構成

マルチモジュールなプロジェクトにしてありますので、一応軽く構成を紹介しておきます。

プロジェクトルート/
 │
 ├ app/ (:appモジュール)
 │ ├ src/main/kotlin/...
 │ └ build.gradle.kts
 │
 ├ buildSrc/
 │ ├ src/main/kotlin/...
 │ └ build.gradle.kts
 │
 ├ modules/
 │ └ core/
 │  │
 │  ├ core-application/ (:core-applicationモジュール)
 │  │ ├ src/main/kotlin/...
 │  │ └ build.gradle.kts
 │  │
 │  ├ core-data/ (:core-dataモジュール)
 │  │ ├ src/main/kotlin/...
 │  │ └ build.gradle.kts
 │  │
 │  ├ core-domain/ (:core-domainモジュール)
 │  │ ├ src/main/kotlin/...
 │  │ └ build.gradle.kts
 │  │
 │  ├ core-ui/ (:core-uiモジュール)
 │  │ ├ src/main/kotlin/...
 │  │ └ build.gradle.kts

main 関数を置いてある :app モジュールがあり、modules ディレクトリ下に core ディレクトリを作り、そこに :core-* モジュールを配置してあります。
これらはJavaFXアプリのモジュールで、設定を共通化したかったので、ルートの build.gradle.ktssubprojects{} ブロックに設定をまとめてあります。

# settings.gradle.kts

↑のモジュールの配置になるように書いています。

rootProject.name = "MyTornadoFX"

include(":app")

include(":core-ui", ":core-application", ":core-data", ":core-domain")
project(":core-ui").projectDir = File("modules/core/core-ui")
project(":core-application").projectDir = File("modules/core/core-application")
project(":core-data").projectDir = File("modules/core/core-data")
project(":core-domain").projectDir = File("modules/core/core-domain")

# ルートのbuild.gradle.kts

さて、プロジェクトのルートの build.gradle.kts です。

import org.apache.tools.ant.taskdefs.condition.Os
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import org.jlleitschuh.gradle.ktlint.reporter.ReporterType

plugins {
    id("org.openjfx.javafxplugin") version "0.0.8"  // 👈 これちゃんと書いてね💖
    id("org.jlleitschuh.gradle.ktlint") version "9.2.1"
    id("com.github.ben-manes.versions") version "0.28.0"
}

buildscript {
    repositories {
        jcenter()
        mavenCentral()
    }

    dependencies {
        classpath(kotlin("gradle-plugin", version = "1.3.72"))
    }
}

allprojects {
    repositories {
        jcenter()
        mavenCentral()
    }

    apply(plugin = "org.jlleitschuh.gradle.ktlint")

    ktlint {
        outputColorName.set("RED")
        ignoreFailures.set(true)
        disabledRules.set(listOf("import-ordering", "no-wildcard-imports"))
        reporters { reporter(ReporterType.CHECKSTYLE) }
    }
}

subprojects {
    apply(plugin = "org.jetbrains.kotlin.jvm")
    apply(plugin = "org.openjfx.javafxplugin")

    javafx {
        version = JavaVersion.VERSION_14.toString()
        modules = listOf("javafx.controls", "javafx.fxml", "javafx.graphics")  // 👈 これちゃんと書いてね💖
    }

    tasks.withType<KotlinCompile> {
        sourceCompatibility = JavaVersion.VERSION_13.toString()
        targetCompatibility = JavaVersion.VERSION_13.toString()
        kotlinOptions.jvmTarget = JavaVersion.VERSION_13.toString()
    }
}

tasks.register<Zip>("release") {
    dependsOn("app:jpackageImage")

    val releaseName = "$MyTornadoFx v1.0.0"
    val archiveName = when {
        Os.isFamily(Os.FAMILY_WINDOWS) -> "$releaseName windows.zip"
        Os.isFamily(Os.FAMILY_MAC) -> "$releaseName mac.zip"
        Os.isFamily(Os.FAMILY_UNIX) -> "$releaseName linux.zip"
        else -> throw UnsupportedOperationException()
    }

    archiveFileName.set(archiveName)
    into(releaseName) {
        from("README.md")
        from("app/build/jpackage/")
    }
}

# JavaFX Gradle Pluginの設定

JavaFXが付いてこないJDKのディストリビューションを使っている場合でも JavaFX Gradle Plugin を使うことでJavaFXアプリを作ることができます。

https://openjfx.io/openjfx-docs/#gradle
ここのGradle版での手順通りです。

(前述した通り、マルチモジュールなので subprojects{} 内に書いてます。)

# :appモジュールのbuild.gradle.kts

import org.apache.tools.ant.taskdefs.condition.Os

plugins {
    id("application")
    id("org.beryx.runtime") version "1.8.3"  // 👈 これちゃんと書いてね💖
}

application {
    mainClassName = "net.aridai.mytornadofx.MainKt"
    applicationName = "MyTornadoFx"
}

runtime {
    options.set(listOf("--strip-debug", "--compress", "2", "--no-header-files", "--no-man-pages"))
    modules.set(listOf(  // 👈 これちゃんと書いてね💖
        "java.desktop",
        "java.xml",
        "jdk.unsupported",
        "java.scripting",
        "jdk.jfr",
        "java.logging",
        "java.prefs"))

    jpackage {
        when {
            Os.isFamily(Os.FAMILY_WINDOWS) -> imageOptions = listOf("--icon", "src/main/resources/icon.ico")
            Os.isFamily(Os.FAMILY_MAC) -> imageOptions = listOf("--icon", "src/main/resources/icon.icns")
        }
        imageName = "MyTornadoFx"
    }
}

dependencies {
    implementation(project(":core-ui"))

    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.3.72")
    implementation("no.tornado:tornadofx:1.7.20")  // 👈 これちゃんと書いてね💖
}

# Badass Runtime Pluginの設定

plugins { id("org.beryx.runtime") version "1.8.3" } といった感じで Badass Runtime Plugin をapplyします。

このプラグインは非モジュール化アプリでランタイムイメージを作成してくれます。
ありがたや。

runtime {} ブロックにランタイムイメージに含めるモジュールの設定 (と後は実行可能ファイルのメタ情報など) を書きます。
今回はJavaFXアプリなので java.desktopjava.xml などを指定します。

ここに指定すべきモジュールは ./gradlew suggestModules を実行することでこんな感じに教えてくれます。
ありがたや。ありがたや。

> Task :app:suggestModules
modules = [
'java.scripting',
'java.xml',
'java.desktop',
'jdk.unsupported',
'jdk.jfr',
'java.logging',
'java.prefs']

# GitHub Actionsでのリリース処理

プロジェクトルートの build.gradle.kts に以下のようなタスクを生やしてあります。

tasks.register<Zip>("release") {
    dependsOn("app:jpackageImage")

    val releaseName = "$MyTornadoFx v1.0.0"
    val archiveName = when {
        Os.isFamily(Os.FAMILY_WINDOWS) -> "$releaseName windows.zip"
        Os.isFamily(Os.FAMILY_MAC) -> "$releaseName mac.zip"
        Os.isFamily(Os.FAMILY_UNIX) -> "$releaseName linux.zip"
        else -> throw UnsupportedOperationException()
    }

    archiveFileName.set(archiveName)
    into(releaseName) {
        from("README.md")
        from("app/build/jpackage/")
    }
}

Badass Runtime Pluginの jpackageImage タスクによって作られた .exe または .appREADME.md と一緒にzipに詰めるだけのタスクです。
GitHub Actionsのリリース用のworkflowで使います。

GitHub Actions用の設定ファイルが以下になります。
(.github/workflows/Release.yml)

name: Release

on:
  push:
    tags:
      - "*"

jobs:
  release:
    strategy:
      matrix:
        os: [macos-latest, windows-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v2

      - uses: actions/cache@v1
        with:
          path: ~/.gradle/caches
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle.kts') }}
          restore-keys: |
            ${{ runner.os }}-gradle-

      - name: Setup Java 14
        id: setup-java-14
        uses: actions/setup-java@v1
        with:
          java-version: '14'
          java-package: jdk
          architecture: x64

      - name: Build
        id: build
        run: ./gradlew release

      - name: Release
        uses: softprops/action-gh-release@v1
        with:
          files: './build/distributions/*.zip'
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

何かしらのタグがpushされたときをトリガにしていて、WindowsとmacOSでマトリックスビルドを掛けてます。

ビルドの成果物 (./gradlew release で生成したzipファイル) をReleaseページにアップロードするために softprops/action-gh-release を利用しました。
公式の actions/upload-release-asset が複数のAssetにまだ対応していなかったみたいなので。
(#16)

おどけミミッミ

私のサンプルプロジェクトでも試しに こんな感じ にやってみました。