M6: desktop app (tray, two-pane layout, shortcuts, inspector, jpackage)
This commit is contained in:
1 parent
2ecfe1c05c
commit
0cc8b7aafe
12 files changed
+1312
-109
No files matched your search
@@ -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) {
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user