【Kotlin】Compose for Desktop で、Mac/Linux/Windows で使える GUI アプリをインストーラー付きで作成する

![IntelliJ IDEA 2023.2.5](https://img.shields.io/badge/IntelliJ_IDEA-2023.2.5-blue)

Kotlin の Compose for Desktop を使えば
Mac/Linux/Windows で使える GUI アプリを
単一ソースで実装できる。
しかもインストーラーの生成まで簡単に行える。

この記事ではプロジェクトの作成方法からインストーラーの生成方法までを述べる。
（ただし Compose の実装方法については述べない。）

なお動作確認は Mac と Windows で行った。

## 0. 統合開発環境 IntelliJ IDEA をインストールする

1. [公式のダウンロードページ](https://www.jetbrains.com/ja-jp/idea/download/)から取得してインストールする。  
   無料の「Community版」でよい。

## 1. プロジェクトを作成する

![New Project ウィンドウ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61280/4a574ae5-74b3-96b7-ceac-da759c06c75d.png)

1. 左ペインで［Compose for Desktop］を選ぶ。
2. 右ペインでアプリ名などを任意に設定する。

## 2. アプリを実装する

1. アプリを実装する。  
   この記事では実装方法については述べない。  
   ただしプロジェクトを作成した時点でサンプル実装が作られているので、とりあえず試す分にはこの手順はスキップしてよい。

## 3. インストーラーを生成する

![Gradle タブ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61280/4be18bd0-5c4c-a4ef-c8c2-eebe21c56930.png)

1. ウィンドウの右上の方にある［Gradle］タブを開き、<i>MyApp</i>/Tasks/compose desktop/ （<i>MyApp</i> はプロジェクト作成時に指定したアプリ名）にある packageRelease<i>Dmg</i>（<i>Dmg</i> の部分はインストーラーの形式。インストール先に応じたものを選ぶこと。Windows なら Msi）をダブルクリックなどで実行する。  
   →プロジェクトのディレクトリーの build/compose/binaries/main-release/<i>dmg/MyApp-1.0.0.dmg</i> （<i>dmg/MyApp-1.0.0.dmg</i> の部分は設定などにより変わる）にインストーラーが生成される。

インストールして起動したサンプルアプリ：  
![サンプルアプリ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61280/e1a123e7-7373-a7b0-b867-20c4492d841f.png)

### 生成できるのは、ビルドを実行する OS をインストール先とするものだけ

生成できるインストーラーは、ビルドを実行する OS をインストール先とするものだけ。
Mac 上でビルドしたら DMG 形式しか生成できない。
他の OS 向けのインストーラーを生成する場合は、ソースコードをその OS に持って行って、`./gradlew packageReleaseMsi` などを実行するとよい。

### インストーラーの生成に失敗する場合

プロジェクトを作成した際に指定した JDK によってはインストーラーの生成に失敗するかもしれない。
その場合は次のようにして JDK を変更する。

1. メインメニュー［File］-［Project Structure］を選択する。  
   →［Project Structure］ウィンドウが開く。
   ![スクリーンショット 2023-11-23 20.14.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61280/15efe890-82ec-1964-54dd-ad50a251d5a7.png)
2. 左ペインで［Project Settings］-［Project］ を選択する。
3. 右ペインの［SDK］の設定を変更する。

筆者の環境では次の2つで生成できることを確認した。
- `jbr-17` JetBrains Runtime version 17.0.9
- `openjdk-17` java version "17"


## Preview するには

Compose のプレビュー機能を有効にするには、
[Settings] - [Plugins] - [marketplace] から検索して
`Compose Multiplatform IDE Support`（JetBrains製）をインストールする必要があるようだ。

![marketplaceのスクリーンキャプチャ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61280/c8c17ec7-6e77-f6e5-f6d2-f1c5d1a55190.png)


## ファイル選択

現時点ではファイル選択ダイアログなどは実装されていないようだ。
Compose for Desktop では AWT や Swing を使えるため、
ファイル選択ダイアログとしては Swing の [`JFileChooser`](https://docs.oracle.com/javase/jp/17/docs/api/java.desktop/javax/swing/JFileChooser.html) を使うのがよさそう。

<details><summary>サンプルコード</summary><div>
まだ Compose に慣れていないので、不適切なところがあるかもしれない。

```Kotlin
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.layout.Row
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import java.io.File
import javax.swing.JFileChooser

@Composable
@Preview
fun App() {
    MaterialTheme {
        var chosenFile by remember { mutableStateOf<File?>(null) }

        Row(verticalAlignment = Alignment.CenterVertically) {
            Text("Chosen File:")

            // 選択されたファイルの名前を表示するテキストフィールド
            TextField(
                value = chosenFile?.name ?: "---",
                onValueChange = {},
                readOnly = true,
            )

            // ファイル選択ダイアログを表示するボタン
            Button(
                onClick = { chosenFile = chooseFile() },
            ) {
                Text("Open File")
            }
        }
    }
}

fun main() = application {
    Window(onCloseRequest = ::exitApplication) {
        App()
    }
}

/**
 * ファイル選択ダイアログを表示する。
 *
 * @return 選択されたファイル。
 */
fun chooseFile(): File? {
    val fileChooser =
        // Java 標準の GUI ライブラリーである Swing のファイル選択ダイアログ
        JFileChooser()
            .apply {
                // TODO 必要に応じてファイル選択ダイアログの設定を行う。
                // 次のような設定ができる。
                // - 初期表示するディレクトリー（コンストラクター引数でも設定可能）。
                // - ファイルを選択させるのか、ディレクトリーを選択させるのか、その両方か。
                // - 複数選択を許すか。
                // - 表示するファイルの種類（ファイルフィルター）。
                // - ダイアログのタイトル。
            }

    val dialogOption =
    // 「開く」ためのファイルを選択するためのモーダルなファイル選択ダイアログを表示する。
    // 「保存する」場合は showSaveDialog を使う。
        // どちらでもない場合は showDialog を使う。
        fileChooser.showOpenDialog(null)

    return when (dialogOption) {
        // ファイルが選択された場合
        JFileChooser.APPROVE_OPTION ->
            // ファイル選択ダイアログで選択されたファイル。
            fileChooser.selectedFile

        else ->
            null
    }
}
```
</div></details>

参考:

https://github.com/JetBrains/compose-multiplatform/issues/3309


## 他のアプリからのドロップ

他のアプリからのドロップを受け取るには、`Modifier.onExternalDrag` を使う。
**ただし 2024-02-03 時点では Experimenal である。**

<details><summary>サンプルコード</summary><div>

```Kotlin
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.runtime.*
import androidx.compose.ui.DragData
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.onExternalDrag
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application

@Composable
@Preview
fun App() {
    MaterialTheme {
        Column {
            // テキストのドロップ
            TextDroppable()

            Spacer(Modifier.height(32.dp))

            // 画像のドロップ
            ImageDroppable()

            Spacer(Modifier.height(32.dp))

            // ファイルのドロップ
            FilesDroppable()
        }
    }
}

fun main() = application {
    Window(onCloseRequest = ::exitApplication) {
        App()
    }
}

/**
 * テキストをドロップできるコンポーザブル。
 */
@Composable
@Preview
fun TextDroppable() {
    Column {
        Text("テキストをドロップしてください")

        var canDrop by remember { mutableStateOf(false) }
        val (textFieldValue, setTextFieldValue) = remember { mutableStateOf(TextFieldValue()) }
        @OptIn(ExperimentalComposeUiApi::class) // onExternalDrag
        TextField(
            value = textFieldValue,
            onValueChange = setTextFieldValue,
            modifier = Modifier
                .background(if (canDrop) Color.Green else Color.Transparent)
                .onExternalDrag(
                    onDragStart = { externalDragValue ->
                        canDrop = externalDragValue.dragData is DragData.Text
                    },
                    onDragExit = {
                        canDrop = false
                    },
                    onDrop = { externalDragValue ->
                        canDrop = false

                        val dragData = externalDragValue.dragData
                        if (dragData is DragData.Text) {
                            val droppedText = dragData.readText()
                            setTextFieldValue(
                                textFieldValue.copy(
                                    text = textFieldValue.text.take(textFieldValue.selection.start)
                                            + droppedText
                                            + textFieldValue.text.drop(textFieldValue.selection.end),
                                    selection = TextRange(
                                        start = textFieldValue.selection.start,
                                        end = textFieldValue.selection.start + droppedText.length,
                                    ),
                                )
                            )
                        }
                    },
                )
        )
    }
}

/**
 * 画像をドロップできるコンポーザブル。
 */
@Composable
@Preview
fun ImageDroppable() {
    Column {
        Text("画像をドロップしてください")

        var canDrop by remember { mutableStateOf(false) }
        var painter by remember { mutableStateOf(ColorPainter(Color.Transparent) as Painter) }
        @OptIn(ExperimentalComposeUiApi::class) // onExternalDrag
        Image(
            painter = painter,
            contentDescription = "ドロップされた画像",
            modifier = Modifier
                .size(256.dp)
                .border(
                    4.dp,
                    if (canDrop) Color.Green else Color.Gray
                )
                .onExternalDrag(
                    onDragStart = { externalDragValue ->
                        canDrop = externalDragValue.dragData is DragData.Image
                    },
                    onDragExit = {
                        canDrop = false
                    },
                    onDrop = { externalDragValue ->
                        canDrop = false

                        val dragData = externalDragValue.dragData
                        if (dragData is DragData.Image) {
                            painter = dragData.readImage()
                        }
                    },
                )
        )
    }
}

/**
 * ファイル群をドロップできるコンポーザブル。
 */
@Composable
@Preview
fun FilesDroppable() {
    Column {
        Text("ファイル（複数可）をドロップしてください")

        var canDrop by remember { mutableStateOf(false) }
        var files by remember { mutableStateOf(listOf<String>()) }
        @OptIn(ExperimentalComposeUiApi::class) // onExternalDrag
        Column(
            modifier = Modifier
                .background(if (canDrop) Color.Green else Color.Transparent)
                .onExternalDrag(
                    onDragStart = { externalDragValue ->
                        canDrop = externalDragValue.dragData is DragData.FilesList
                    },
                    onDragExit = {
                        canDrop = false
                    },
                    onDrop = { externalDragValue ->
                        canDrop = false

                        val dragData = externalDragValue.dragData
                        if (dragData is DragData.FilesList) {
                            files = dragData.readFiles()
                        }
                    },
                )
        ) {
            files
                .ifEmpty { listOf("") }
                .forEach { file ->
                    TextField(
                        value = file,
                        onValueChange = { },
                        readOnly = true,
                    )
                }
        }
    }
}
```
</div></details>

参考：

https://github.com/JetBrains/compose-multiplatform/issues/222


## URL をブラウザーで開く

URL をブラウザーで開くには、AWT の [`Desktop.browse`](https://docs.oracle.com/javase/jp/17/docs/api/java.desktop/java/awt/Desktop.html#browse(java.net.URI)) を使用する。

```Kotlin:サンプルコード
Desktop.getDesktop().browse(
    URI("https://kotlinlang.org/")
)
```


## Preferences

アプリの設定など小さなデータをローカルに保存するには [`Preferences`](https://docs.oracle.com/javase/jp/17/docs/api/java.prefs/java/util/prefs/Preferences.html) を使うとよい。

<details><summary>サンプルコード</summary><div>

アプリを終了してもカウントが失われないサンプル。

```Kotlin
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.layout.Row
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.runtime.*
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.util.prefs.Preferences

@Composable
@Preview
fun App() {
    MaterialTheme {
        Row {
            val counter by remember { mutableStateOf(Counter()) }
            val count by counter.count.collectAsState()

            TextField(
                value = count.toString(),
                onValueChange = {},
                readOnly = true,
            )

            Button(
                onClick = { counter.countUp() },
            ) {
                Text("+")
            }
        }
    }
}

fun main() = application {
    Window(onCloseRequest = ::exitApplication) {
        App()
    }
}

/**
 * カウンターのモデル
 */
class Counter {
    val count: StateFlow<Int>
        get() = _count
    private val _count = MutableStateFlow(getCountFromPreferences())

    fun countUp() {
        _count.value += 1
        putCountToPreferences(_count.value)
    }

    companion object {
        private const val KEY = "count"

        private val preferences: Preferences
            get() {
                // OS のログインユーザーと [Counter] クラスに固有の [Preferences]。
                // （注意！　他のアプリからもアクセスはできる。）
                return Preferences.userNodeForPackage(Counter::class.java)
            }

        private fun getCountFromPreferences(): Int =
            preferences.getInt(KEY, 0)

        private fun putCountToPreferences(count: Int) {
            preferences.putInt(KEY, count)
        }
    }
}
```
</div></details>

／以上