M6: desktop app (tray, two-pane layout, shortcuts, inspector, jpackage)

This commit is contained in:
ARIA committed 2026-08-19 20:43:47 +02:00
1 parent 2ecfe1c05c
commit 0cc8b7aafe
12 files changed
+1312 -109

No files matched your search

+54
View File
@@ -18,10 +18,64 @@ val desktopTarget = when {
dependencies {
implementation(project(":shared"))
implementation("org.jetbrains.compose.desktop:desktop-jvm-$desktopTarget:$composeVersion")
// M6: window-state persistence (window.json).
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
}
compose.desktop {
application {
mainClass = "iris.desktop.MainKt"
}
}
// ── M6: native packaging (docs/11 §11.4) ─────────────────────────────────
// `./gradlew :desktopApp:jpackage` builds a self-contained app image under
// build/jpackage (JRE bundled). `./gradlew :desktopApp:jpackage
// -PjpackageType=deb` builds a .deb instead (needs dpkg-deb).
val jpackageInputDir = layout.buildDirectory.dir("jpackage-input")
val fatJar = tasks.register<Jar>("fatJar") {
archiveClassifier.set("all")
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
manifest { attributes("Main-Class" to "iris.desktop.MainKt") }
from(sourceSets.main.get().output)
from({
configurations.runtimeClasspath.get()
.filter { it.name.endsWith(".jar") }
.map { zipTree(it) }
}) {
exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA")
}
}
// Stage the fat jar into the jpackage input dir. A dedicated Sync task keeps
// the Exec task free of task references (configuration-cache compatible) and
// prunes stale jars from the input dir automatically.
tasks.register<Sync>("stageJpackageInput") {
dependsOn(fatJar)
from(fatJar)
into(jpackageInputDir)
}
tasks.register<Exec>("jpackage") {
dependsOn("stageJpackageInput")
val jpackageBin = file("${System.getProperty("java.home")}/bin/jpackage")
val type = (project.findProperty("jpackageType") as? String) ?: "app-image"
val inputDir = jpackageInputDir.get().asFile
val destDir = layout.buildDirectory.dir("jpackage").get().asFile
inputs.dir(inputDir)
outputs.dir(destDir)
commandLine(
if (jpackageBin.exists()) jpackageBin.absolutePath else "jpackage",
"--name", "iris",
"--app-version", "0.1.0",
"--vendor", "Iris",
"--type", type,
"--input", inputDir.absolutePath,
"--main-jar", "desktopApp-all.jar",
"--main-class", "iris.desktop.MainKt",
"--dest", destDir.absolutePath,
"--java-options", "-Xmx1g",
)
}
@@ -1,13 +1,184 @@
package iris.desktop
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.awt.ComposeWindow
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Tray
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.WindowState
import androidx.compose.ui.window.application
import iris.IrisApp
import iris.net.GatewayClient
import iris.platform.DesktopBridge
import iris.platform.DesktopSecureStore
import java.awt.Color
import java.awt.Graphics2D
import java.awt.RenderingHints
import java.awt.SystemTray
import java.awt.event.WindowEvent
import java.awt.event.WindowFocusListener
import java.awt.image.BufferedImage
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.delay
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
fun main() = application {
/**
* M6: desktop shell (docs/11 §11.2/§11.3).
*
* - resizable window, size/position persisted to `~/.iris/window.json`
* - system tray (Compose Desktop `Tray`): connection-state icon + tooltip,
* Show / Quit menu, close-to-tray (X hides the window when the tray is up)
* - window focus drives `DesktopBridge.foreground` (banner vs OS notification)
* - OS notifications themselves are posted from shared/desktopMain
* (DesktopNotifier), which also updates the tray tooltip
*/
private val windowJson = File(System.getProperty("user.home"), ".iris/window.json")
// Tray icon colors (ARGB). .toInt(): the literals exceed the Int range.
private const val TRAY_OFFLINE = 0xFF9E9E9E.toInt()
private const val TRAY_CONNECTING = 0xFFFFC107.toInt()
private const val TRAY_CONNECTED = 0xFF4CAF50.toInt()
private const val TRAY_AUTH_FAILED = 0xFFF44336.toInt()
fun main() {
val store = DesktopSecureStore()
Window(onCloseRequest = ::exitApplication, title = "Iris") {
IrisApp(store)
val traySupported = SystemTray.isSupported()
val closeToTray = AtomicBoolean(traySupported)
var composeWindow: ComposeWindow? = null
application {
var trayColor by mutableStateOf(TRAY_OFFLINE)
var trayTooltip by mutableStateOf("Iris — offline")
if (traySupported) {
val trayBitmap = remember(trayColor) { trayIconImage(trayColor).toComposeImageBitmap() }
Tray(
icon = BitmapPainter(trayBitmap),
tooltip = trayTooltip,
onAction = { showWindow(composeWindow) },
) {
Item("Show Iris") { showWindow(composeWindow) }
Separator()
Item("Quit") {
closeToTray.set(false)
saveWindowState(composeWindow)
exitApplication()
}
}
}
// Connection-state poller: tray icon color + tooltip.
LaunchedEffect(Unit) {
while (true) {
delay(2_000)
val state = DesktopBridge.controller?.client?.state?.value
val (color, tooltip) = when (state) {
null,
is GatewayClient.State.Disconnected -> TRAY_OFFLINE to "Iris — offline"
is GatewayClient.State.Connecting,
is GatewayClient.State.Reconnecting -> TRAY_CONNECTING to "Iris — connecting…"
is GatewayClient.State.Connected -> TRAY_CONNECTED to "Iris — connected"
is GatewayClient.State.AuthFailed -> TRAY_AUTH_FAILED to "Iris — auth failed"
}
trayColor = color
trayTooltip = tooltip
}
}
// A notification (backgrounded reply / approval / cron) also shows up
// in the tray tooltip; the OS notification itself is posted by
// DesktopNotifier (shared/desktopMain).
DisposableEffect(Unit) {
DesktopBridge.onNotification { _, title, _, _ ->
trayTooltip = "Iris — $title"
}
onDispose { }
}
Window(
onCloseRequest = {
if (closeToTray.get()) {
composeWindow?.isVisible = false
} else {
saveWindowState(composeWindow)
exitApplication()
}
true
},
title = "Iris",
state = loadWindowState(),
) {
composeWindow = window
window.addWindowFocusListener(object : WindowFocusListener {
override fun windowGainedFocus(e: WindowEvent) {
DesktopBridge.foreground = true
}
override fun windowLostFocus(e: WindowEvent) {
DesktopBridge.foreground = false
}
})
IrisApp(store)
}
}
}
private fun showWindow(window: ComposeWindow?) {
window ?: return
window.isVisible = true
window.toFront()
window.requestFocus()
}
/** A simple two-tone circle; the outer color encodes the connection state. */
private fun trayIconImage(color: Int): BufferedImage {
val img = BufferedImage(22, 22, BufferedImage.TYPE_INT_ARGB)
val g = img.createGraphics() as Graphics2D
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g.color = Color(color)
g.fillOval(2, 2, 18, 18)
g.color = Color(0xFF1B1E28.toInt())
g.fillOval(7, 7, 8, 8)
g.dispose()
return img
}
private fun loadWindowState(): WindowState {
return try {
if (!windowJson.exists()) return WindowState(width = 1280.dp, height = 800.dp)
val obj = Json.parseToJsonElement(windowJson.readText()).jsonObject
val x = obj["x"]?.jsonPrimitive?.content?.toIntOrNull()
val y = obj["y"]?.jsonPrimitive?.content?.toIntOrNull()
val w = obj["w"]?.jsonPrimitive?.content?.toIntOrNull()
val h = obj["h"]?.jsonPrimitive?.content?.toIntOrNull()
WindowState(
position = if (x != null && y != null) WindowPosition(x.dp, y.dp) else WindowPosition.PlatformDefault,
width = w?.dp ?: 1280.dp,
height = h?.dp ?: 800.dp,
)
} catch (_: Exception) {
WindowState(width = 1280.dp, height = 800.dp)
}
}
private fun saveWindowState(window: ComposeWindow?) {
try {
val w = window ?: return
windowJson.parentFile?.mkdirs()
windowJson.writeText(
"""{"x":${w.x},"y":${w.y},"w":${w.width},"h":${w.height}}""",
)
} catch (_: Exception) {
}
}