Clojure + JavaFX + カスタムJREでGUIアプリを作成し、リリースを自動化する

# まえがき

- ClojureでJavaFXを利用したGUIアプリケーションを実装します
- ユーザ環境にJavaランタイムをインストールしなくて良いように、カスタムJREを使ってアプリケーションを起動できるようにする方法を書きます
- リリース作業をGitHubActionsで自動化します

# 開発環境

| Key | Value |
| ----| ----- |
| OS | Ubuntu 18.04 |
| Clojure | 1.10.1 |
| Leiningen | 2.9.4 |
| Java | OpenJDK 14 |

# JavaFXアプリケーションの配布について

以下の記事でより詳細に書かれているのですが、JavaFXアプリを最新のJavaで起動する形で配布するには準備が必要です。
[OpenJFX時代のJDK選び - もしくはOpenJFX時代のアプリケーション配布](https://skrb.hatenablog.com/entry/2019/06/24/220639)

Clojureで作成した実行可能JARも同じ問題を抱えています。
ユーザにJavaのランタイム、JavaFX SDKを入れてもらう必要なく、JavaFXアプリケーションを配布するには、カスタムJREを作成して実行可能JARと一緒に配布する必要があります。

# 各プラットフォーム向けに配布する流れ

カスタムJREを作成してアプリを起動できるようにするのにはいくつか準備が必要で、手順もやや複雑です。なので、段階を追って以下の流れで説明します。

- Clojure＋JavaFXのアプリケーションを実装する
- jlinkでカスタムJREを作成し、カスタムJREでアプリケーションを起動できるようにする
- 各プラットフォーム向けに配布するために、CIと連携して自動リリースできるようにする

## 0. ソースコード

CIでの自動リリースまですべて構築したソースコードは以下です。
今回の記事はこのリポジトリのソースコードをベースに説明します。

https://github.com/jiro4989/iconisor

## 1. Clojure＋JavaFXのアプリケーションを実装する

まずClojureでJavaFXアプリケーションを起動できるところまで実装します。
この時点ではカスタムJREなどは作らず、lein経由でアプリが起動できればOKです。

プロジェクトの管理にはleiningenを使います。
ClojureでJavaFXを扱うためのライブラリとして`cljfx`を使います。
dependenciesに`[cljfx "1.7.10"]`を追加します。

pluginsにフォーマッタや静的解析を入れていますが、
こちらは今回の記事とは関係ないので入れなくても動作すると思います。

コードとしては以下です。

```project.clj
(defproject iconisor "0.1.0-SNAPSHOT"
  :description "FIXME: write description"
  :url "http://github.com/jiro4989/iconisor"
  :license {:name "EPL-2.0 OR GPL-2.0-or-later WITH Classpath-exception-2.0"
            :url "https://www.eclipse.org/legal/epl-2.0/"}
  :dependencies [[org.clojure/clojure "1.10.1"]
                 [cljfx "1.7.10"]]
  :main iconisor.core
  ;:main ^:skip-aot iconisor.core
  :target-path "target/%s"
  :plugins [[lein-cloverage "1.2.0"]
            [lein-cljfmt "0.7.0"]
            [jonase/eastwood "0.3.10"]
            [lein-kibit "0.1.8"]]
  :profiles {:uberjar {:aot :all
                       :jvm-opts ["-Dclojure.compiler.direct-linking=true"]
                       :prep-tasks ["compile"]
                       :uberjar-name "iconisor.jar"
                       :injections [(javafx.application.Platform/exit)]}})
```

最初ここで躓いたのが `:injections` の設定です。

アプリケーションの配布用に実行可能JARを作成する必要があるので`lein uberjar`を実行するとuberjarが永遠に終了しないという事象に遭遇しました。
以下のissuesを見つけて解決したのですが`:injections [(javafx.application.Platform/exit)]`という記述が必須です。
https://github.com/cljfx/cljfx/issues/17

次にmain関数を実装します。
とりあえずGUIが出れば良いので、cljfxのサンプルコードだけでOKです。

```src/iconisor/core.clj
(ns iconisor.core
  (:require [cljfx.api :as fx])
  (:gen-class))

(defn -main [& args]
  (fx/on-fx-thread
   (fx/create-component
    {:fx/type :stage
     :showing true
     :always-on-top true
     :style :transparent
     :scene {:fx/type :scene
             :fill :transparent
             :stylesheets #{"styles.css"}
             :root {:fx/type :v-box
                    :children [{:fx/type :label
                                :effect {:fx/type :drop-shadow
                                         :radius 1
                                         :offset-y 2}
                                :tooltip {:fx/type :tooltip
                                          :text "I am a tooltip!"}
                                :text "Hi! What's your name?"}
                               {:fx/type :text-field}]}}})))
```

これで`lein run`を実行すると以下のようなテキストフィールドだけのGUIが起動するようになります。

![demo.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/69665/ff63eb1e-ca30-9033-7d65-c2883bf26ac4.png)

## 2. jlinkでカスタムJREを作成し、カスタムJREでアプリケーションを起動できるようにする

カスタムJREを作成するためにjlinkを実行します。
jlinkを実行するには依存ライブラリのモジュールが必要になります。
そのためOpenJFXのサイトからjmodsをダウンロードする必要があります。
僕は以下のシェルを作成し、コマンドラインで取得するようにしています。

```install_jmods.sh
#!/bin/bash

set -eux

os_name=linux
version=14.0.1

rm -rf jmods || true
mkdir -p jmods
curl -o jmods/jmods.zip -sSL https://download2.gluonhq.com/openjfx/${version}/openjfx-${version}_${os_name}-x64_bin-jmods.zip
cd jmods
unzip jmods.zip
```

このシェルを実行するとカレントディレクトリに`jmods`ディレクトリが作成されます。

次に、以下のシェルスクリプトを作成します。

```jlink.sh
#!/bin/bash

set -eux

outdir=$1

jlink --module-path ./jmods/javafx-jmods-14.0.1/ \
  --add-modules javafx.base,javafx.controls,javafx.swing,javafx.graphics,javafx.fxml \
  --compress=2 \
  --output "$outdir"
```

このシェルを使ってカスタムJREを作成します。
以下のように呼び出します。

```bash
./jlink.sh jre
```

これでjreディレクトリにカスタムJREが作成されます。

このカスタムJREを利用して実行可能JARが起動できることを確認します。
以下のように実行します。

```bash
# 実行可能JARの生成
lein uberjar

cd jre
./bin/java -jar ../target/uberjar/iconisor.jar
```

これでGUIが表示されれば、カスタムJREで起動できるようになっています。

## 3. CIと連携して自動リリースできるようにする

通常の開発ではjmodsを落としてくる必要はありませんし、カスタムJREの作成も不要です。

リリース作業のためだけにローカル環境を整えるのも手間なのと、リリース作業をコード化して再利用できるようにするために、CI環境（GitHubActions）だけでリリースを完了できるようにします。

具体的には、以下のコマンドを実行して、タグをpushするだけでリリースを終えられるようにするのを目指します。

```bash
git tag v0.1.0
git push origin --tags
```

追加の準備として、起動スクリプトを作成します。

前述のとおり、カスタムJREを使ってアプリケーションを起動するのであれば、コマンドラインからカスタムJREのJavaを指定する必要があります。
一般配布を目指すのでしたら、コマンドライン入力を求めるようにはしたくありません。
なので、前述のコマンドをラップしたスクリプトを作成し、配布時にカスタムJREと一緒に配布する形とします。
以下のスクリプトを作成します。

Windows用。文字コードはSJIS、改行コードはCRLFにしましょう。

```.github/dist/iconisor.bat
@echo off

.\jre\bin\java.exe -jar .\iconisor.jar
```

Mac, Linux用。文字コードはUTF-8、改行コードはLFにしましょう。

```bash
#!/bin/bash

# .github/dist/iconisor

./jre/bin/java -jar iconisor.jar
```

これらのスクリプトと一緒に、以下のディレクトリ構成となるようにリリース物を収集して圧縮するようなワークフローにしていきます。

```
iconisor_windows.zip/
  iconisor_windows/
    jre/          # カスタムJRE
    LICENSE
    README.txt
    iconisor.bat  # 起動スクリプト
    iconisor      # 起動スクリプト
    iconisor.jar  # 実行可能JAR
```


ということで、CI環境のみでWindows、Linux、Mac向けにリリース物を生成してアップロードするワークフローを書きました。
以下が全体です。

```bash
name: test

on:
  push:
    paths-ignore:
      - 'LICENSE'
      - 'README.*'
      - 'docs/*'
  pull_request:
    paths-ignore:
      - 'LICENSE'
      - 'README.*'
      - 'docs/*'

env:
  app-name: iconisor # TODO: アプリ名なのでアプリ名に合わせて変更が必要
  javafx-version: '14.0.1'

defaults:
  run:
    shell: bash

jobs:
  # リリース自体には不要。テストを書いているなら使う
  test:
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-java@v1
        with:
          java-version: '14'
          java-package: jdk
          architecture: x64
      - name: Install xvfb
        run: sudo apt install -y xvfb
      - run: xvfb-run lein test
      - run: xvfb-run lein cloverage --codecov
      - uses: codecov/codecov-action@v1

  # リリース自体には不要。lintにかけるなら必要。project.cljへのplugin記述も必要
  linter:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        cmd:
          - 'cljfmt check'
          - 'kibit'
    steps:
      - uses: actions/checkout@v2
      - run: lein ${{ matrix.cmd }}

  # リリースのメイン処理
  build:
    runs-on: ${{ matrix.runs-on }}
    strategy:
      matrix:
        include:
          - runs-on: windows-latest
            os: windows
          - runs-on: macOS-latest
            os: osx
          - runs-on: ubuntu-latest
            os: linux
    steps:
      - uses: actions/checkout@v2

      - uses: actions/setup-java@v1
        with:
          java-version: '14'
          java-package: jdk
          architecture: x64
      - name: Install lein (unix)
        uses: DeLaGuardo/setup-clojure@master
        with:
          cli: '1.10.1.469'
          lein: 2.9.4
          boot: latest
        if: matrix.os != 'windows'

      - name: Install lein (windows)
        run: |
          from urllib import request
          import os
          lists = [
            ("https://raw.githubusercontent.com/technomancy/leiningen/stable/bin/%s", "lein.bat"),
          ]
          for elem in lists:
            url = elem[0] % elem[1]
            file = elem[1]
            request.urlretrieve(url, file)
            os.system("lein.bat self-install")
        shell: python
        if: matrix.os == 'windows'

      - name: Install openjfx jmods
        run: |
          mkdir -p jmods
          curl -o jmods/jmods.zip -sSL https://download2.gluonhq.com/openjfx/${{ env.javafx-version }}/openjfx-${{ env.javafx-version }}_${{ matrix.os }}-x64_bin-jmods.zip
          (
            cd jmods
            unzip jmods.zip
          )

      - name: Initialize custom JRE
        run: ./jlink.sh jre

      - name: Build (unix)
        run: lein uberjar
        if: matrix.os != 'windows'

      - name: Build (windows)
        run: lein.bat uberjar
        if: matrix.os == 'windows'
        shell: cmd

      - name: Create artifact
        run: |
          mkdir -p ${{ env.app-name }}_${{ matrix.os }}/
          mv jre .github/dist/* LICENSE target/uberjar/${{ env.app-name }}.jar ${{ env.app-name }}_${{ matrix.os }}/

      - name: Compress artifact (unix)
        run: |
          tar czf ${{ env.app-name }}_${{ matrix.os }}.tar.gz ${{ env.app-name }}_${{ matrix.os }}
        if: matrix.os != 'windows'

      - name: Compress artifact (windows)
        run: |
          7z a ${{ env.app-name }}_${{ matrix.os }}.zip ${{ env.app-name }}_${{ matrix.os }}
        if: matrix.os == 'windows'

      - uses: actions/upload-artifact@v2
        with:
          name: artifact-${{ matrix.os }}
          path: |
            ${{ env.app-name }}_${{ matrix.os }}.tar.gz
            ${{ env.app-name }}_${{ matrix.os }}.zip
        if: startsWith(github.ref, 'refs/tags/')

  create-release:
    if: startsWith(github.ref, 'refs/tags/')
    runs-on: ubuntu-latest
    needs:
      - build
    steps:
      - uses: actions/checkout@v1
      - name: Create Release
        id: create-release
        uses: actions/create-release@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          tag_name: ${{ github.ref }}
          release_name: ${{ github.ref }}
          body: ${{ github.ref }}
          draft: false
          prerelease: false

      - name: Write upload_url to file
        run: echo '${{ steps.create-release.outputs.upload_url }}' > upload_url.txt

      - uses: actions/upload-artifact@v2
        with:
          name: create-release
          path: upload_url.txt

  upload-release:
    runs-on: ubuntu-latest
    needs: create-release
    strategy:
      matrix:
        include:
          - os: windows
            asset_name_suffix: windows.zip
            asset_content_type: application/zip
          - os: osx
            asset_name_suffix: osx.tar.gz
            asset_content_type: application/gzip
          - os: linux
            asset_name_suffix: linux.tar.gz
            asset_content_type: application/gzip
    steps:
      - uses: actions/download-artifact@v2
        with:
          name: artifact-${{ matrix.os }}

      - uses: actions/download-artifact@v2
        with:
          name: create-release

      - id: vars
        run: |
          echo "::set-output name=upload_url::$(cat upload_url.txt)"
      - name: Upload Release Asset
        id: upload-release-asset 
        uses: actions/upload-release-asset@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          upload_url: ${{ steps.vars.outputs.upload_url }}
          asset_path: ${{ env.app-name }}_${{ matrix.asset_name_suffix }}
          asset_name: ${{ env.app-name }}_${{ matrix.asset_name_suffix }}
          asset_content_type: ${{ matrix.asset_content_type }}
```

解説すると

- WindowsVM, MacVM, LinuxVMを並列に起動
- それぞれのVMで実行可能JARを生成
- jmodsのダウンロード
- カスタムJREを作成
- カスタムJRE、起動スクリプト類(`.github/dist/*`)、実行可能JARを収集して圧縮
- artifactとしてアップロード
- GitHub Releaseの作成
- artifactをダウンロードし、Releasesにartifactをアップロード

という感じです。

結果として、以下のようにダウンロード可能になります。

https://github.com/jiro4989/iconisor/releases

# まとめ

以下の話をしました。

- Clojure＋JavaFXアプリの実装
- カスタムJREの作成方法
- リリースワークフローの実装

ClojureでのJavaFXアプリ開発の一助となれば幸いです。

以上