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 {
|
dependencies {
|
||||||
implementation(project(":shared"))
|
implementation(project(":shared"))
|
||||||
implementation("org.jetbrains.compose.desktop:desktop-jvm-$desktopTarget:$composeVersion")
|
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 {
|
compose.desktop {
|
||||||
application {
|
application {
|
||||||
mainClass = "iris.desktop.MainKt"
|
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
|
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.Window
|
||||||
|
import androidx.compose.ui.window.WindowPosition
|
||||||
|
import androidx.compose.ui.window.WindowState
|
||||||
import androidx.compose.ui.window.application
|
import androidx.compose.ui.window.application
|
||||||
import iris.IrisApp
|
import iris.IrisApp
|
||||||
|
import iris.net.GatewayClient
|
||||||
|
import iris.platform.DesktopBridge
|
||||||
import iris.platform.DesktopSecureStore
|
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()
|
val store = DesktopSecureStore()
|
||||||
Window(onCloseRequest = ::exitApplication, title = "Iris") {
|
val traySupported = SystemTray.isSupported()
|
||||||
IrisApp(store)
|
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) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package iris.platform
|
||||||
|
|
||||||
|
actual val isDesktop: Boolean = false
|
||||||
@@ -77,6 +77,10 @@ class GatewayClient(
|
|||||||
private val _state = MutableStateFlow<State>(State.Disconnected)
|
private val _state = MutableStateFlow<State>(State.Disconnected)
|
||||||
val state: StateFlow<State> = _state.asStateFlow()
|
val state: StateFlow<State> = _state.asStateFlow()
|
||||||
|
|
||||||
|
/** The paired gateway URL (M6 inspector pane). */
|
||||||
|
val serverUrl: String
|
||||||
|
get() = store.serverUrl
|
||||||
|
|
||||||
private val _events = MutableSharedFlow<Frame>(extraBufferCapacity = 128)
|
private val _events = MutableSharedFlow<Frame>(extraBufferCapacity = 128)
|
||||||
val events: SharedFlow<Frame> = _events.asSharedFlow()
|
val events: SharedFlow<Frame> = _events.asSharedFlow()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package iris.platform
|
||||||
|
|
||||||
|
/** True on the desktop target (M6: big-screen layout, shortcuts, tray). */
|
||||||
|
expect val isDesktop: Boolean
|
||||||
@@ -2,6 +2,7 @@ package iris.ui.screens
|
|||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -25,6 +26,7 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@@ -48,8 +50,18 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.isCtrlPressed
|
||||||
|
import androidx.compose.ui.input.key.isMetaPressed
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
@@ -67,11 +79,13 @@ import iris.data.ToolItem
|
|||||||
import iris.net.GatewayClient
|
import iris.net.GatewayClient
|
||||||
import iris.platform.MediaFilePicker
|
import iris.platform.MediaFilePicker
|
||||||
import iris.platform.MediaPlayerView
|
import iris.platform.MediaPlayerView
|
||||||
|
import iris.platform.isDesktop
|
||||||
import iris.platform.loadMediaImage
|
import iris.platform.loadMediaImage
|
||||||
import iris.platform.openDocument
|
import iris.platform.openDocument
|
||||||
import iris.protocol.ChannelInfo
|
import iris.protocol.ChannelInfo
|
||||||
import iris.protocol.KIND_DOCUMENT
|
import iris.protocol.KIND_DOCUMENT
|
||||||
import iris.protocol.KIND_IMAGE
|
import iris.protocol.KIND_IMAGE
|
||||||
|
import iris.protocol.ROLE_ASSISTANT
|
||||||
import iris.protocol.ROLE_USER
|
import iris.protocol.ROLE_USER
|
||||||
import iris.protocol.SearchHit
|
import iris.protocol.SearchHit
|
||||||
import iris.state.IrisController
|
import iris.state.IrisController
|
||||||
@@ -110,6 +124,15 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
val drawerScope = rememberCoroutineScope()
|
val drawerScope = rememberCoroutineScope()
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
||||||
|
// M6 (desktop): two-pane layout, command palette, inspector, shortcuts.
|
||||||
|
var showPalette by remember { mutableStateOf(false) }
|
||||||
|
var showInspector by remember { mutableStateOf(false) }
|
||||||
|
val composerFocusRequester = remember { FocusRequester() }
|
||||||
|
var composerFocused by remember { mutableStateOf(false) }
|
||||||
|
val railFocusRequester = remember { FocusRequester() }
|
||||||
|
var railIndex by remember { mutableStateOf(0) }
|
||||||
|
val nonThreadChannels = channels.filter { it.kind != "thread" }
|
||||||
|
|
||||||
fun doSend() {
|
fun doSend() {
|
||||||
val ready = attachments.filter { it.mediaRef != null && it.error == null }
|
val ready = attachments.filter { it.mediaRef != null && it.error == null }
|
||||||
if (input.isBlank() && ready.isEmpty()) return
|
if (input.isBlank() && ready.isEmpty()) return
|
||||||
@@ -125,28 +148,46 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
if (last >= 0) listState.animateScrollToItem(last)
|
if (last >= 0) listState.animateScrollToItem(last)
|
||||||
}
|
}
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
// M6: keep the rail selection in sync with the current channel.
|
||||||
drawerState = drawerState,
|
LaunchedEffect(currentChatId, nonThreadChannels) {
|
||||||
gesturesEnabled = drawerState.isOpen,
|
val idx = nonThreadChannels.indexOfFirst { it.chatId == currentChatId }
|
||||||
drawerContent = {
|
if (idx >= 0) railIndex = idx
|
||||||
ModalDrawerSheet(modifier = Modifier.width(280.dp)) {
|
}
|
||||||
ChannelDrawer(
|
|
||||||
channels = channels.filter { it.kind != "thread" },
|
// M6: keyboard shortcuts (docs/11 §11.3) — desktop only.
|
||||||
currentChatId = currentChatId,
|
val shortcutsModifier = if (isDesktop) {
|
||||||
onOpen = { ch ->
|
Modifier.onPreviewKeyEvent { e ->
|
||||||
controller.openChannel(ch.chatId)
|
if (e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||||
drawerScope.launch { drawerState.close() }
|
val ctrlOrMeta = e.isCtrlPressed || e.isMetaPressed
|
||||||
},
|
val overlayOpen =
|
||||||
onNewChannel = {
|
showSearch || showPicker || showPalette || showNewChannel || showNewThread
|
||||||
showNewChannel = true
|
when {
|
||||||
drawerScope.launch { drawerState.close() }
|
ctrlOrMeta && e.key == Key.N -> { showNewChannel = true; true }
|
||||||
},
|
ctrlOrMeta && e.key == Key.T -> { showNewThread = true; true }
|
||||||
onSetDefault = { ch -> controller.setDefaultChannel(ch.chatId) },
|
ctrlOrMeta && e.key == Key.F -> { showSearch = true; true }
|
||||||
onDelete = { ch -> controller.deleteChannel(ch.chatId) },
|
ctrlOrMeta && e.key == Key.K -> { showPalette = true; true }
|
||||||
)
|
e.key == Key.Escape && overlayOpen -> {
|
||||||
|
showSearch = false
|
||||||
|
showPicker = false
|
||||||
|
showPalette = false
|
||||||
|
showNewChannel = false
|
||||||
|
showNewThread = false
|
||||||
|
true
|
||||||
|
}
|
||||||
|
e.key == Key.Slash && !composerFocused && !overlayOpen -> {
|
||||||
|
composerFocusRequester.requestFocus()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
) {
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
|
||||||
|
// M6: the chat column (header + list + composer), shared by both layouts.
|
||||||
|
@Composable
|
||||||
|
fun ChatContent(showHamburger: Boolean, onOpenDrawer: () -> Unit) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// Header
|
// Header
|
||||||
Row(
|
Row(
|
||||||
@@ -155,8 +196,10 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = { drawerScope.launch { drawerState.open() } }) {
|
if (showHamburger) {
|
||||||
Text("☰", fontSize = 18.sp)
|
IconButton(onClick = onOpenDrawer) {
|
||||||
|
Text("☰", fontSize = 18.sp)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
currentChannel?.name ?: "Iris",
|
currentChannel?.name ?: "Iris",
|
||||||
@@ -175,6 +218,20 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
TextButton(onClick = { controller.cycleToolDetail() }) {
|
TextButton(onClick = { controller.cycleToolDetail() }) {
|
||||||
Text("tools: ${toolDetail.label}", fontSize = 11.sp)
|
Text("tools: ${toolDetail.label}", fontSize = 11.sp)
|
||||||
}
|
}
|
||||||
|
if (isDesktop) {
|
||||||
|
IconButton(onClick = { showInspector = !showInspector }) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(
|
||||||
|
if (showInspector) Color(0xFF2A3550) else Color.Transparent,
|
||||||
|
)
|
||||||
|
.padding(2.dp),
|
||||||
|
) {
|
||||||
|
Text("ⓘ", fontSize = 16.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Topic switcher (threads on)
|
// Topic switcher (threads on)
|
||||||
@@ -276,7 +333,9 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
onValueChange = { input = it },
|
onValueChange = { input = it },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.heightIn(min = 48.dp, max = 160.dp),
|
.heightIn(min = 48.dp, max = 160.dp)
|
||||||
|
.focusRequester(composerFocusRequester)
|
||||||
|
.onFocusChanged { composerFocused = it.isFocused },
|
||||||
placeholder = { Text("Message") },
|
placeholder = { Text("Message") },
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||||
keyboardActions = KeyboardActions(onSend = { doSend() }),
|
keyboardActions = KeyboardActions(onSend = { doSend() }),
|
||||||
@@ -292,6 +351,71 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize().then(shortcutsModifier)) {
|
||||||
|
if (isDesktop) {
|
||||||
|
// M6: two-pane default (docs/11 §11.3): persistent channel rail +
|
||||||
|
// chat, optional inspector third pane.
|
||||||
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
|
ChannelRail(
|
||||||
|
channels = nonThreadChannels,
|
||||||
|
currentChatId = currentChatId,
|
||||||
|
selectedIndex = railIndex,
|
||||||
|
onSelectedIndex = { railIndex = it },
|
||||||
|
focusRequester = railFocusRequester,
|
||||||
|
onOpen = { ch -> controller.openChannel(ch.chatId) },
|
||||||
|
onNewChannel = { showNewChannel = true },
|
||||||
|
onSetDefault = { ch -> controller.setDefaultChannel(ch.chatId) },
|
||||||
|
onDelete = { ch -> controller.deleteChannel(ch.chatId) },
|
||||||
|
)
|
||||||
|
Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFF2A2E3B)))
|
||||||
|
Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
|
||||||
|
ChatContent(showHamburger = false, onOpenDrawer = {})
|
||||||
|
}
|
||||||
|
if (showInspector) {
|
||||||
|
Box(modifier = Modifier.width(1.dp).fillMaxHeight().background(Color(0xFF2A2E3B)))
|
||||||
|
InspectorPane(
|
||||||
|
controller = controller,
|
||||||
|
state = state,
|
||||||
|
currentChannel = currentChannel,
|
||||||
|
threads = threads,
|
||||||
|
items = items,
|
||||||
|
toolDetail = toolDetail,
|
||||||
|
threadsEnabled = threadsEnabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ModalNavigationDrawer(
|
||||||
|
drawerState = drawerState,
|
||||||
|
gesturesEnabled = drawerState.isOpen,
|
||||||
|
drawerContent = {
|
||||||
|
ModalDrawerSheet(modifier = Modifier.width(280.dp)) {
|
||||||
|
ChannelDrawer(
|
||||||
|
channels = nonThreadChannels,
|
||||||
|
currentChatId = currentChatId,
|
||||||
|
onOpen = { ch ->
|
||||||
|
controller.openChannel(ch.chatId)
|
||||||
|
drawerScope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
|
onNewChannel = {
|
||||||
|
showNewChannel = true
|
||||||
|
drawerScope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
|
onSetDefault = { ch -> controller.setDefaultChannel(ch.chatId) },
|
||||||
|
onDelete = { ch -> controller.deleteChannel(ch.chatId) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
ChatContent(
|
||||||
|
showHamburger = true,
|
||||||
|
onOpenDrawer = { drawerScope.launch { drawerState.open() } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// File picker overlay (M4)
|
// File picker overlay (M4)
|
||||||
if (showPicker) {
|
if (showPicker) {
|
||||||
Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.5f))) {
|
Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.5f))) {
|
||||||
@@ -306,6 +430,8 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
if (showSearch) {
|
if (showSearch) {
|
||||||
SearchOverlay(
|
SearchOverlay(
|
||||||
controller = controller,
|
controller = controller,
|
||||||
|
currentChatId = currentChatId,
|
||||||
|
currentThreadId = currentThreadId,
|
||||||
onJump = { hit ->
|
onJump = { hit ->
|
||||||
if (hit.threadId.isNullOrBlank()) controller.openChannel(hit.chatId)
|
if (hit.threadId.isNullOrBlank()) controller.openChannel(hit.chatId)
|
||||||
else controller.openThread(hit.chatId, hit.threadId)
|
else controller.openThread(hit.chatId, hit.threadId)
|
||||||
@@ -338,6 +464,32 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
onDismiss = { showNewThread = false },
|
onDismiss = { showNewThread = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// M6: command palette (Ctrl/Cmd+K)
|
||||||
|
if (showPalette) {
|
||||||
|
CommandPalette(
|
||||||
|
actions = listOf(
|
||||||
|
"new-channel" to "New channel (Ctrl+N)",
|
||||||
|
"new-thread" to "New topic (Ctrl+T)",
|
||||||
|
"search" to "Search (Ctrl+F)",
|
||||||
|
"toggle-threads" to "Toggle threads",
|
||||||
|
"cycle-tool-detail" to "Cycle tool verbosity",
|
||||||
|
"toggle-inspector" to "Toggle inspector",
|
||||||
|
),
|
||||||
|
onAction = { id ->
|
||||||
|
when (id) {
|
||||||
|
"new-channel" -> showNewChannel = true
|
||||||
|
"new-thread" -> showNewThread = true
|
||||||
|
"search" -> showSearch = true
|
||||||
|
"toggle-threads" -> controller.toggleThreads()
|
||||||
|
"cycle-tool-detail" -> controller.cycleToolDetail()
|
||||||
|
"toggle-inspector" -> showInspector = !showInspector
|
||||||
|
}
|
||||||
|
showPalette = false
|
||||||
|
},
|
||||||
|
onDismiss = { showPalette = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Left drawer: channel list + create / set-default / delete. */
|
/** Left drawer: channel list + create / set-default / delete. */
|
||||||
@@ -393,6 +545,210 @@ private fun ChannelDrawer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M6: persistent channel rail (two-pane default, docs/11 §11.3). Keyboard:
|
||||||
|
* ↑/↓ move the selection, Enter opens (when the rail has focus).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ChannelRail(
|
||||||
|
channels: List<ChannelInfo>,
|
||||||
|
currentChatId: String,
|
||||||
|
selectedIndex: Int,
|
||||||
|
onSelectedIndex: (Int) -> Unit,
|
||||||
|
focusRequester: FocusRequester,
|
||||||
|
onOpen: (ChannelInfo) -> Unit,
|
||||||
|
onNewChannel: () -> Unit,
|
||||||
|
onSetDefault: (ChannelInfo) -> Unit,
|
||||||
|
onDelete: (ChannelInfo) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(280.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.focusable()
|
||||||
|
.onPreviewKeyEvent { e ->
|
||||||
|
if (e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||||
|
when (e.key) {
|
||||||
|
Key.DirectionUp -> {
|
||||||
|
if (channels.isNotEmpty()) {
|
||||||
|
onSelectedIndex((selectedIndex - 1 + channels.size) % channels.size)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Key.DirectionDown -> {
|
||||||
|
if (channels.isNotEmpty()) onSelectedIndex((selectedIndex + 1) % channels.size)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Key.Enter -> {
|
||||||
|
channels.getOrNull(selectedIndex)?.let { onOpen(it) }
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Channels",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
items(channels, key = { it.chatId }) { ch ->
|
||||||
|
val isCurrent = ch.chatId == currentChatId
|
||||||
|
val isSelected = ch.chatId == channels.getOrNull(selectedIndex)?.chatId
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(
|
||||||
|
when {
|
||||||
|
isCurrent -> Color(0xFF2A3550)
|
||||||
|
isSelected -> Color(0xFF232838)
|
||||||
|
else -> Color.Transparent
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.clickable { onOpen(ch) }
|
||||||
|
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
ch.name + if (ch.isDefault) " ★" else "",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (!ch.isDefault) {
|
||||||
|
TextButton(onClick = { onSetDefault(ch) }) {
|
||||||
|
Text("★", fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
TextButton(onClick = { onDelete(ch) }) {
|
||||||
|
Text("✕", fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(onClick = onNewChannel, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("+ New channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** M6: optional third pane — session info, channel info, last reply, settings. */
|
||||||
|
@Composable
|
||||||
|
private fun InspectorPane(
|
||||||
|
controller: IrisController,
|
||||||
|
state: GatewayClient.State,
|
||||||
|
currentChannel: ChannelInfo?,
|
||||||
|
threads: List<ChannelInfo>,
|
||||||
|
items: List<ChatItem>,
|
||||||
|
toolDetail: ToolDetail,
|
||||||
|
threadsEnabled: Boolean,
|
||||||
|
) {
|
||||||
|
val lastAssistant = items.lastOrNull {
|
||||||
|
it is MessageItem && it.role == ROLE_ASSISTANT && !it.streaming
|
||||||
|
} as? MessageItem
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(300.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Inspector",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
Text("Connection", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(bottom = 4.dp))
|
||||||
|
InfoRow("Status", statusLabel(state))
|
||||||
|
InfoRow("Server", controller.client.serverUrl.ifBlank { "— (not paired)" })
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text("Channel", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(bottom = 4.dp))
|
||||||
|
InfoRow("Name", currentChannel?.name ?: "—")
|
||||||
|
InfoRow("ID", currentChannel?.chatId ?: "—")
|
||||||
|
InfoRow("Default", if (currentChannel?.isDefault == true) "yes" else "no")
|
||||||
|
InfoRow("Threads", threads.size.toString())
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text("Last reply", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(bottom = 4.dp))
|
||||||
|
InfoRow("Model", lastAssistant?.model ?: "—")
|
||||||
|
InfoRow("Tokens", lastAssistant?.tokens?.toString() ?: "—")
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text("Settings", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(bottom = 4.dp))
|
||||||
|
Row {
|
||||||
|
TextButton(onClick = { controller.cycleToolDetail() }) {
|
||||||
|
Text("tools: ${toolDetail.label}")
|
||||||
|
}
|
||||||
|
TextButton(onClick = { controller.toggleThreads() }) {
|
||||||
|
Text(if (threadsEnabled) "threads: on" else "threads: off")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InfoRow(label: String, value: String) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||||
|
Text(label, fontSize = 12.sp, color = Color(0xFF8A93A6), modifier = Modifier.width(70.dp))
|
||||||
|
Text(value, fontSize = 12.sp, modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun statusLabel(state: GatewayClient.State): String = when (state) {
|
||||||
|
GatewayClient.State.Disconnected -> "offline"
|
||||||
|
GatewayClient.State.Connecting -> "connecting…"
|
||||||
|
GatewayClient.State.Reconnecting -> "reconnecting…"
|
||||||
|
is GatewayClient.State.Connected -> "connected"
|
||||||
|
is GatewayClient.State.AuthFailed -> "auth failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** M6: command palette (Ctrl/Cmd+K) — all actions, filterable. */
|
||||||
|
@Composable
|
||||||
|
private fun CommandPalette(
|
||||||
|
actions: List<Pair<String, String>>,
|
||||||
|
onAction: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var query by remember { mutableStateOf("") }
|
||||||
|
val filtered = actions.filter { it.second.contains(query, ignoreCase = true) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Command palette") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = { query = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
placeholder = { Text("Type a command…") },
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
if (filtered.isEmpty()) {
|
||||||
|
Text("No matching commands.", fontSize = 12.sp, color = Color(0xFF8A93A6))
|
||||||
|
} else {
|
||||||
|
filtered.forEach { (id, label) ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(6.dp))
|
||||||
|
.clickable { onAction(id) }
|
||||||
|
.padding(vertical = 6.dp, horizontal = 4.dp),
|
||||||
|
) {
|
||||||
|
Text(label, fontSize = 13.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Horizontal topic chips: General + threads + "new topic". */
|
/** Horizontal topic chips: General + threads + "new topic". */
|
||||||
@Composable
|
@Composable
|
||||||
private fun TopicSwitcher(
|
private fun TopicSwitcher(
|
||||||
@@ -430,10 +786,12 @@ private fun TopicChip(label: String, selected: Boolean, onClick: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Full-screen search overlay (M3). */
|
/** Full-screen search overlay (M3; M6: all / this-chat scope toggle). */
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchOverlay(
|
private fun SearchOverlay(
|
||||||
controller: IrisController,
|
controller: IrisController,
|
||||||
|
currentChatId: String,
|
||||||
|
currentThreadId: String?,
|
||||||
onJump: (SearchHit) -> Unit,
|
onJump: (SearchHit) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -441,6 +799,15 @@ private fun SearchOverlay(
|
|||||||
val searching by controller.searching.collectAsState()
|
val searching by controller.searching.collectAsState()
|
||||||
val lastQuery by controller.lastQuery.collectAsState()
|
val lastQuery by controller.lastQuery.collectAsState()
|
||||||
var query by remember { mutableStateOf(lastQuery) }
|
var query by remember { mutableStateOf(lastQuery) }
|
||||||
|
var scope by remember { mutableStateOf("all") }
|
||||||
|
|
||||||
|
fun doSearch() {
|
||||||
|
if (scope == "chat") {
|
||||||
|
controller.search(query, scope = "chat", chatId = currentChatId, threadId = currentThreadId)
|
||||||
|
} else {
|
||||||
|
controller.search(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.5f))) {
|
Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.5f))) {
|
||||||
Column(
|
Column(
|
||||||
@@ -460,13 +827,26 @@ private fun SearchOverlay(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
placeholder = { Text("Search messages…") },
|
placeholder = { Text("Search messages…") },
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
keyboardActions = KeyboardActions(onSearch = { controller.search(query) }),
|
keyboardActions = KeyboardActions(onSearch = { doSearch() }),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Button(onClick = { controller.search(query) }, enabled = query.isNotBlank() && !searching) {
|
Button(onClick = { doSearch() }, enabled = query.isNotBlank() && !searching) {
|
||||||
Text(if (searching) "…" else "search")
|
Text(if (searching) "…" else "search")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
) {
|
||||||
|
Text("Scope:", fontSize = 12.sp, color = Color(0xFF8A93A6))
|
||||||
|
TextButton(onClick = { scope = "all" }) {
|
||||||
|
Text(if (scope == "all") "● all" else "○ all", fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
TextButton(onClick = { scope = "chat" }) {
|
||||||
|
Text(if (scope == "chat") "● this chat" else "○ this chat", fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
if (results.isEmpty() && !searching) {
|
if (results.isEmpty() && !searching) {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package iris.platform
|
||||||
|
|
||||||
|
import iris.state.IrisController
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M6: bridge between the desktop shell (tray / window) and the shared
|
||||||
|
* controller. The shell sets [foreground] from the window focus state and
|
||||||
|
* subscribes to [onNotification] for tray updates; [controller] lets the tray
|
||||||
|
* deep-link (notification click -> open chat).
|
||||||
|
*/
|
||||||
|
typealias NotificationListener =
|
||||||
|
(chatId: String?, title: String, body: String, threadId: String?) -> Unit
|
||||||
|
|
||||||
|
object DesktopBridge {
|
||||||
|
@Volatile
|
||||||
|
var foreground: Boolean = true
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var controller: IrisController? = null
|
||||||
|
|
||||||
|
private val notificationListeners = mutableListOf<NotificationListener>()
|
||||||
|
|
||||||
|
fun onNotification(listener: NotificationListener) {
|
||||||
|
notificationListeners.add(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun notifyListeners(chatId: String?, title: String, body: String, threadId: String?) {
|
||||||
|
notificationListeners.toList().forEach { it(chatId, title, body, threadId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,67 +5,94 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import iris.data.MediaItem
|
import iris.data.MediaItem
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.net.StandardProtocolFamily
|
||||||
|
import java.net.UnixDomainSocketAddress
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.channels.SocketChannel
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.imageio.ImageIO
|
import javax.imageio.ImageIO
|
||||||
|
import javax.swing.JFileChooser
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.buildJsonArray
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M6: native file dialog (docs/11 §11.2). Runs on the EDT (Compose Desktop's
|
||||||
|
* UI thread), so the chooser is shown directly.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
|
actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
|
||||||
var path by remember { mutableStateOf("") }
|
var busy by remember { mutableStateOf(false) }
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
) {
|
) {
|
||||||
Text("File path (desktop):", fontSize = 13.sp)
|
Text("Attach a file:", fontSize = 13.sp)
|
||||||
Spacer(modifier = Modifier.width(0.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
OutlinedTextField(
|
Button(
|
||||||
value = path,
|
onClick = {
|
||||||
onValueChange = { path = it },
|
busy = true
|
||||||
modifier = Modifier.fillMaxWidth(),
|
val picked = pickFile()
|
||||||
placeholder = { Text("/path/to/file") },
|
busy = false
|
||||||
)
|
onPicked(picked)
|
||||||
Spacer(modifier = Modifier.width(0.dp))
|
},
|
||||||
Row {
|
enabled = !busy,
|
||||||
Button(onClick = {
|
) {
|
||||||
val f = File(path.trim())
|
Text(if (busy) "Choosing…" else "Choose file…")
|
||||||
if (f.exists() && f.isFile) {
|
|
||||||
onPicked(
|
|
||||||
PickedFile(
|
|
||||||
path = f.absolutePath,
|
|
||||||
name = f.name,
|
|
||||||
mime = guessMime(f.name),
|
|
||||||
size = f.length(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
onPicked(null)
|
|
||||||
}
|
|
||||||
}, enabled = path.isNotBlank()) {
|
|
||||||
Text("Attach")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun pickFile(): PickedFile? {
|
||||||
|
val chooser = JFileChooser()
|
||||||
|
if (chooser.showOpenDialog(null) != JFileChooser.APPROVE_OPTION) return null
|
||||||
|
val f = chooser.selectedFile ?: return null
|
||||||
|
return PickedFile(
|
||||||
|
path = f.absolutePath,
|
||||||
|
name = f.name,
|
||||||
|
mime = guessMime(f.name),
|
||||||
|
size = f.length(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun guessMime(name: String): String {
|
private fun guessMime(name: String): String {
|
||||||
val ext = name.substringAfterLast('.', "").lowercase()
|
val ext = name.substringAfterLast('.', "").lowercase()
|
||||||
return when (ext) {
|
return when (ext) {
|
||||||
@@ -73,15 +100,18 @@ private fun guessMime(name: String): String {
|
|||||||
"png" -> "image/png"
|
"png" -> "image/png"
|
||||||
"webp" -> "image/webp"
|
"webp" -> "image/webp"
|
||||||
"gif" -> "image/gif"
|
"gif" -> "image/gif"
|
||||||
|
"bmp" -> "image/bmp"
|
||||||
"mp4" -> "video/mp4"
|
"mp4" -> "video/mp4"
|
||||||
"webm" -> "video/webm"
|
"webm" -> "video/webm"
|
||||||
"mov" -> "video/quicktime"
|
"mov" -> "video/quicktime"
|
||||||
|
"mkv" -> "video/x-matroska"
|
||||||
"mp3" -> "audio/mpeg"
|
"mp3" -> "audio/mpeg"
|
||||||
"m4a", "aac" -> "audio/mp4"
|
"m4a", "aac" -> "audio/mp4"
|
||||||
"ogg", "opus" -> "audio/ogg"
|
"ogg", "opus" -> "audio/ogg"
|
||||||
"wav" -> "audio/wav"
|
"wav" -> "audio/wav"
|
||||||
|
"flac" -> "audio/flac"
|
||||||
"pdf" -> "application/pdf"
|
"pdf" -> "application/pdf"
|
||||||
"txt" -> "text/plain"
|
"txt", "md" -> "text/plain"
|
||||||
else -> "application/octet-stream"
|
else -> "application/octet-stream"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,24 +122,287 @@ actual fun mediaCacheBaseDir(): String {
|
|||||||
return dir.absolutePath
|
return dir.absolutePath
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun loadMediaImage(path: String): ImageBitmap? {
|
actual suspend fun loadMediaImage(path: String): ImageBitmap? = withContext(Dispatchers.IO) {
|
||||||
// Desktop image decoding via BufferedImage->ImageBitmap is a later
|
try {
|
||||||
// milestone; return null so bubbles show the "loading" placeholder.
|
val img = ImageIO.read(File(path)) ?: return@withContext null
|
||||||
return null
|
img.toComposeImageBitmap()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M6: inline media playback backed by mpv (docs/11 §11.2).
|
||||||
|
*
|
||||||
|
* - audio: an mpv process driven over its JSON IPC socket (play/pause/seek,
|
||||||
|
* position/duration polled every 500 ms) — the same mini-player UX as the
|
||||||
|
* Android ExoPlayer actual.
|
||||||
|
* - video: mpv in its own window (embedding into a Compose surface would need
|
||||||
|
* the libmpv JNI renderer; a separate window is the honest v1).
|
||||||
|
* - no mpv on PATH: an info chip (the WebView fallback is a stretch goal).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
actual fun MediaPlayerView(media: MediaItem, modifier: Modifier) {
|
||||||
|
val path = media.localPath
|
||||||
|
if (path == null || !File(path).exists()) {
|
||||||
|
Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
"${media.kind}: ${media.filename} (${media.size} B) — not downloaded yet",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!Mpv.available) {
|
||||||
|
Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
"${media.kind}: ${media.filename} (${media.size} B) — playback unavailable (mpv not installed)",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (media.kind == "video") VideoPlayer(media, path) else AudioPlayer(media, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
private object Mpv {
|
||||||
|
val available: Boolean by lazy {
|
||||||
|
try {
|
||||||
|
val p = ProcessBuilder("which", "mpv").redirectErrorStream(true).start()
|
||||||
|
val found = p.inputStream.bufferedReader().readText().trim().isNotBlank()
|
||||||
|
p.waitFor(2, TimeUnit.SECONDS)
|
||||||
|
found
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun MediaPlayerView(media: MediaItem, modifier: Modifier) {
|
private fun AudioPlayer(media: MediaItem, path: String) {
|
||||||
// Desktop playback backend (libmpv/WebView) is a later milestone;
|
val session = remember(path) { MpvAudioSession(path) }
|
||||||
// show a chip with the file info for now.
|
var paused by remember { mutableStateOf(true) }
|
||||||
Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
var position by remember { mutableStateOf(0.0) }
|
||||||
Text(
|
var duration by remember { mutableStateOf(0.0) }
|
||||||
"${media.kind}: ${media.filename} (${media.size} B) — playback not available on desktop yet",
|
var dragValue by remember { mutableStateOf(0f) }
|
||||||
fontSize = 12.sp,
|
|
||||||
)
|
DisposableEffect(session) {
|
||||||
|
session.start()
|
||||||
|
onDispose { session.stop() }
|
||||||
|
}
|
||||||
|
LaunchedEffect(session) {
|
||||||
|
while (true) {
|
||||||
|
delay(500)
|
||||||
|
val (p, d, pause) = session.poll()
|
||||||
|
position = p
|
||||||
|
duration = d
|
||||||
|
paused = pause
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(Color(0xFF20242E))
|
||||||
|
.padding(10.dp),
|
||||||
|
) {
|
||||||
|
Text(media.filename, fontSize = 12.sp, maxLines = 1)
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
IconButton(onClick = { session.setPaused(!paused) }) {
|
||||||
|
Text(if (paused) "▶" else "⏸", fontSize = 16.sp)
|
||||||
|
}
|
||||||
|
Slider(
|
||||||
|
value = if (duration > 0) (position / duration).coerceIn(0.0, 1.0).toFloat() else 0f,
|
||||||
|
onValueChange = { dragValue = it },
|
||||||
|
onValueChangeFinished = {
|
||||||
|
if (duration > 0) session.seekTo(dragValue.toDouble() * duration)
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
enabled = duration > 0,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"${fmtTime(position)} / ${fmtTime(duration)}",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = Color(0xFF8A93A6),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VideoPlayer(media: MediaItem, path: String) {
|
||||||
|
var process by remember { mutableStateOf<Process?>(null) }
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(Color(0xFF20242E))
|
||||||
|
.padding(10.dp),
|
||||||
|
) {
|
||||||
|
Text(media.filename, fontSize = 12.sp, maxLines = 1)
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Button(onClick = {
|
||||||
|
try {
|
||||||
|
process?.destroy()
|
||||||
|
process = ProcessBuilder("mpv", path).redirectErrorStream(true).start()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text("▶ Play in mpv")
|
||||||
|
}
|
||||||
|
if (process != null) {
|
||||||
|
Spacer(modifier = Modifier.height(0.dp).padding(start = 8.dp))
|
||||||
|
TextButton(onClick = {
|
||||||
|
process?.destroy()
|
||||||
|
process = null
|
||||||
|
}) {
|
||||||
|
Text("stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fmtTime(sec: Double): String {
|
||||||
|
val s = sec.coerceAtLeast(0.0).toLong()
|
||||||
|
return "%d:%02d".format(s / 60, s % 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An mpv process + its JSON IPC socket (unix domain). Commands carry a
|
||||||
|
* [requestId] so responses can be matched and async events skipped.
|
||||||
|
*/
|
||||||
|
private class MpvAudioSession(private val path: String) {
|
||||||
|
private var process: Process? = null
|
||||||
|
private var channel: SocketChannel? = null
|
||||||
|
private val socketPath =
|
||||||
|
"/tmp/iris-mpv-${ProcessHandle.current().pid()}-${Random.nextLong(1_000_000_000L, 9_999_999_999L)}.sock"
|
||||||
|
private val lineBuffer = StringBuilder()
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
try {
|
||||||
|
process = ProcessBuilder(
|
||||||
|
"mpv", "--no-video", "--really-quiet", "--no-terminal",
|
||||||
|
"--input-ipc-server=$socketPath", path,
|
||||||
|
).redirectErrorStream(true).start()
|
||||||
|
connectIpc()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connectIpc() {
|
||||||
|
repeat(20) {
|
||||||
|
try {
|
||||||
|
val ch = SocketChannel.open(StandardProtocolFamily.UNIX)
|
||||||
|
ch.connect(UnixDomainSocketAddress.of(File(socketPath).toPath()))
|
||||||
|
channel = ch
|
||||||
|
return
|
||||||
|
} catch (_: Exception) {
|
||||||
|
Thread.sleep(150)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Poll time-pos / duration / pause. Returns (position, duration, paused). */
|
||||||
|
fun poll(): Triple<Double, Double, Boolean> {
|
||||||
|
var pos = 0.0
|
||||||
|
var dur = 0.0
|
||||||
|
var pause = false
|
||||||
|
val ch = channel ?: return Triple(pos, dur, pause)
|
||||||
|
try {
|
||||||
|
send(listOf("get_property", "time-pos"), "p1")
|
||||||
|
send(listOf("get_property", "duration"), "p2")
|
||||||
|
send(listOf("get_property", "pause"), "p3")
|
||||||
|
val deadline = System.currentTimeMillis() + 250
|
||||||
|
val answers = mutableMapOf<String, JsonObject>()
|
||||||
|
while (System.currentTimeMillis() < deadline && answers.size < 3) {
|
||||||
|
val line = readLineOrNull() ?: break
|
||||||
|
if (line.isBlank()) continue
|
||||||
|
try {
|
||||||
|
val el = json.parseToJsonElement(line).jsonObject
|
||||||
|
val rid = el["request_id"]?.jsonPrimitive?.content
|
||||||
|
if (rid != null) answers[rid] = el
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
answers["p1"]?.get("data")?.jsonPrimitive?.content?.toDoubleOrNull()?.let { pos = it }
|
||||||
|
answers["p2"]?.get("data")?.jsonPrimitive?.content?.toDoubleOrNull()?.let { dur = it }
|
||||||
|
answers["p3"]?.get("data")?.jsonPrimitive?.content?.toBooleanStrictOrNull()?.let { pause = it }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
return Triple(pos, dur, pause)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPaused(p: Boolean) {
|
||||||
|
try {
|
||||||
|
send(listOf("set_property", "pause", if (p) "true" else "false"), null)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun seekTo(seconds: Double) {
|
||||||
|
try {
|
||||||
|
send(listOf("seek", seconds.toString(), "absolute"), null)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
try {
|
||||||
|
channel?.close()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
process?.destroy()
|
||||||
|
try {
|
||||||
|
File(socketPath).delete()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun send(cmd: List<String>, requestId: String?) {
|
||||||
|
val ch = channel ?: return
|
||||||
|
val obj = buildJsonObject {
|
||||||
|
put("command", buildJsonArray { cmd.forEach { add(JsonPrimitive(it)) } })
|
||||||
|
requestId?.let { put("request_id", JsonPrimitive(it)) }
|
||||||
|
}
|
||||||
|
val bytes = (obj.toString() + "\n").toByteArray(Charsets.UTF_8)
|
||||||
|
ch.write(ByteBuffer.wrap(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readLineOrNull(): String? {
|
||||||
|
val ch = channel ?: return null
|
||||||
|
val buf = ByteBuffer.allocate(4096)
|
||||||
|
while (true) {
|
||||||
|
val n = ch.read(buf)
|
||||||
|
if (n == -1) return null
|
||||||
|
if (n > 0) break
|
||||||
|
Thread.sleep(20)
|
||||||
|
}
|
||||||
|
buf.flip()
|
||||||
|
val bytes = ByteArray(buf.remaining())
|
||||||
|
buf.get(bytes)
|
||||||
|
lineBuffer.append(String(bytes, Charsets.UTF_8))
|
||||||
|
val idx = lineBuffer.indexOf('\n')
|
||||||
|
if (idx < 0) return null
|
||||||
|
val line = lineBuffer.substring(0, idx)
|
||||||
|
lineBuffer.delete(0, idx + 1)
|
||||||
|
return line
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun openDocument(path: String, mime: String) {
|
actual fun openDocument(path: String, mime: String) {
|
||||||
// Desktop open-with is a later milestone; no-op for now.
|
try {
|
||||||
|
val os = System.getProperty("os.name").lowercase()
|
||||||
|
when {
|
||||||
|
os.contains("mac") -> ProcessBuilder("open", path).start()
|
||||||
|
os.contains("win") ->
|
||||||
|
ProcessBuilder("rundll32", "url.dll,FileProtocolHandler", path).start()
|
||||||
|
else -> ProcessBuilder("xdg-open", path).start()
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package iris.platform
|
package iris.platform
|
||||||
|
|
||||||
actual fun isAppForeground(): Boolean = true
|
import iris.state.IrisController
|
||||||
|
|
||||||
|
actual fun isAppForeground(): Boolean = DesktopBridge.foreground
|
||||||
|
|
||||||
actual fun postSystemNotification(
|
actual fun postSystemNotification(
|
||||||
chatId: String?,
|
chatId: String?,
|
||||||
@@ -9,9 +11,49 @@ actual fun postSystemNotification(
|
|||||||
body: String,
|
body: String,
|
||||||
threadId: String?,
|
threadId: String?,
|
||||||
) {
|
) {
|
||||||
// Desktop: in-app banner only (no system notification in M5).
|
DesktopNotifier.post(title, body)
|
||||||
|
DesktopBridge.notifyListeners(chatId, title, body, threadId)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun setActiveController(controller: Any?) {
|
actual fun setActiveController(controller: Any?) {
|
||||||
// Desktop: no push services to bridge to.
|
DesktopBridge.controller = controller as? IrisController
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M6: OS notifications (docs/11 §11.2). Desktop has no FCM; the tray + the
|
||||||
|
* platform notifier cover the "backgrounded" leg. Shells out to
|
||||||
|
* notify-send (Linux), osascript (macOS) or a PowerShell balloon (Windows).
|
||||||
|
* Best effort — failures are ignored (the in-app banner is the primary path).
|
||||||
|
*/
|
||||||
|
object DesktopNotifier {
|
||||||
|
fun post(title: String, body: String) {
|
||||||
|
try {
|
||||||
|
val os = System.getProperty("os.name").lowercase()
|
||||||
|
val cmd = when {
|
||||||
|
os.contains("linux") ->
|
||||||
|
listOf("notify-send", "-a", "Iris", "-c", "iris", title, body)
|
||||||
|
os.contains("mac") ->
|
||||||
|
listOf(
|
||||||
|
"osascript", "-e",
|
||||||
|
"display notification \"${esc(body)}\" with title \"${esc(title)}\"",
|
||||||
|
)
|
||||||
|
else ->
|
||||||
|
listOf(
|
||||||
|
"powershell", "-NoProfile", "-Command",
|
||||||
|
"Add-Type -AssemblyName System.Windows.Forms; " +
|
||||||
|
"\$n = New-Object System.Windows.Forms.NotifyIcon; " +
|
||||||
|
"\$n.Icon = [System.Drawing.SystemIcons]::Information; " +
|
||||||
|
"\$n.Visible = \$true; " +
|
||||||
|
"\$n.ShowBalloonTip(4000, \"${esc(title)}\", \"${esc(body)}\", " +
|
||||||
|
"[System.Windows.Forms.ToolTipIcon]::Info); " +
|
||||||
|
"Start-Sleep -Milliseconds 4500; \$n.Dispose()",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ProcessBuilder(cmd).redirectErrorStream(true).start()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun esc(s: String): String =
|
||||||
|
s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", " ")
|
||||||
}
|
}
|
||||||
@@ -3,42 +3,81 @@ package iris.platform
|
|||||||
import iris.data.SecureStore
|
import iris.data.SecureStore
|
||||||
import iris.protocol.IrisJson
|
import iris.protocol.IrisJson
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.security.SecureRandom
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Desktop pairing storage: a small JSON file under the user home
|
* M6: desktop pairing storage (docs/11 §11.2).
|
||||||
* (`~/.iris/pairing.json`). M1 dev storage; a keyring backend is a
|
*
|
||||||
* later-milestone concern.
|
* Non-secret settings live in `~/.iris/settings.json`; the gateway token goes
|
||||||
|
* to the OS keyring (macOS Keychain via `security`, Linux Secret Service via
|
||||||
|
* `secret-tool`) when available, falling back to an AES-GCM encrypted file
|
||||||
|
* (`~/.iris/pairing.enc`, key in `~/.iris/.key`, owner-only permissions).
|
||||||
|
*
|
||||||
|
* Migrates the M1 plaintext `~/.iris/pairing.json` on first run.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
|
||||||
private data class PairingData(
|
|
||||||
val serverUrl: String = "",
|
|
||||||
val token: String = "",
|
|
||||||
val deviceId: String = "",
|
|
||||||
val syncCursor: Long = 0L,
|
|
||||||
val fcmToken: String = "",
|
|
||||||
val ntfyTopic: String = "",
|
|
||||||
val ntfyServer: String = "",
|
|
||||||
)
|
|
||||||
|
|
||||||
class DesktopSecureStore : SecureStore {
|
class DesktopSecureStore : SecureStore {
|
||||||
private val file: File = File(System.getProperty("user.home"), ".iris/pairing.json")
|
private val baseDir = File(System.getProperty("user.home"), ".iris")
|
||||||
|
private val settingsFile = File(baseDir, "settings.json")
|
||||||
|
private val legacyFile = File(baseDir, "pairing.json")
|
||||||
|
private val secret = SecretBackend(baseDir)
|
||||||
|
|
||||||
private fun load(): PairingData =
|
@Serializable
|
||||||
if (file.exists()) {
|
private data class Settings(
|
||||||
|
val serverUrl: String = "",
|
||||||
|
val deviceId: String = "",
|
||||||
|
val syncCursor: Long = 0L,
|
||||||
|
val fcmToken: String = "",
|
||||||
|
val ntfyTopic: String = "",
|
||||||
|
val ntfyServer: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
migrateLegacy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun migrateLegacy() {
|
||||||
|
if (!legacyFile.exists()) return
|
||||||
|
val legacy = try {
|
||||||
|
IrisJson.instance.decodeFromString(PairingData.serializer(), legacyFile.readText())
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (legacy != null) {
|
||||||
|
val s = load()
|
||||||
|
save(
|
||||||
|
s.copy(
|
||||||
|
serverUrl = legacy.serverUrl.ifBlank { s.serverUrl },
|
||||||
|
deviceId = legacy.deviceId.ifBlank { s.deviceId },
|
||||||
|
syncCursor = if (legacy.syncCursor > 0) legacy.syncCursor else s.syncCursor,
|
||||||
|
fcmToken = legacy.fcmToken.ifBlank { s.fcmToken },
|
||||||
|
ntfyTopic = legacy.ntfyTopic.ifBlank { s.ntfyTopic },
|
||||||
|
ntfyServer = legacy.ntfyServer.ifBlank { s.ntfyServer },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (legacy.token.isNotBlank()) secret.write(legacy.token)
|
||||||
|
}
|
||||||
|
legacyFile.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun load(): Settings =
|
||||||
|
if (settingsFile.exists()) {
|
||||||
try {
|
try {
|
||||||
IrisJson.instance.decodeFromString(PairingData.serializer(), file.readText())
|
IrisJson.instance.decodeFromString(Settings.serializer(), settingsFile.readText())
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
PairingData()
|
Settings()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
PairingData()
|
Settings()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun save(data: PairingData) {
|
private fun save(data: Settings) {
|
||||||
file.parentFile?.mkdirs()
|
baseDir.mkdirs()
|
||||||
file.writeText(IrisJson.instance.encodeToString(PairingData.serializer(), data))
|
settingsFile.writeText(IrisJson.instance.encodeToString(Settings.serializer(), data))
|
||||||
}
|
}
|
||||||
|
|
||||||
override var serverUrl: String
|
override var serverUrl: String
|
||||||
@@ -49,10 +88,9 @@ class DesktopSecureStore : SecureStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override var token: String
|
override var token: String
|
||||||
get() = load().token
|
get() = secret.read().orEmpty()
|
||||||
set(value) {
|
set(value) {
|
||||||
val d = load()
|
if (value.isBlank()) secret.clear() else secret.write(value.trim())
|
||||||
save(d.copy(token = value.trim()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override val deviceId: String
|
override val deviceId: String
|
||||||
@@ -97,11 +135,169 @@ class DesktopSecureStore : SecureStore {
|
|||||||
|
|
||||||
override fun savePairing(url: String, token: String) {
|
override fun savePairing(url: String, token: String) {
|
||||||
val d = load()
|
val d = load()
|
||||||
save(d.copy(serverUrl = url.trim(), token = token.trim()))
|
save(d.copy(serverUrl = url.trim()))
|
||||||
|
if (token.isBlank()) secret.clear() else secret.write(token.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun clear() {
|
override fun clear() {
|
||||||
val d = load()
|
val d = load()
|
||||||
save(d.copy(serverUrl = "", token = ""))
|
save(d.copy(serverUrl = ""))
|
||||||
|
secret.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy M1 shape, kept only for migration. */
|
||||||
|
@Serializable
|
||||||
|
private data class PairingData(
|
||||||
|
val serverUrl: String = "",
|
||||||
|
val token: String = "",
|
||||||
|
val deviceId: String = "",
|
||||||
|
val syncCursor: Long = 0L,
|
||||||
|
val fcmToken: String = "",
|
||||||
|
val ntfyTopic: String = "",
|
||||||
|
val ntfyServer: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token storage: OS keyring when available, else an AES-GCM encrypted file.
|
||||||
|
* All backend failures degrade to the encrypted file (never plaintext).
|
||||||
|
*/
|
||||||
|
private class SecretBackend(private val baseDir: File) {
|
||||||
|
private val encFile = File(baseDir, "pairing.enc")
|
||||||
|
private val keyFile = File(baseDir, ".key")
|
||||||
|
private val keyring: KeyringBackend? = KeyringBackend().takeIf { it.available }
|
||||||
|
|
||||||
|
fun read(): String? = keyring?.read() ?: readEncrypted()
|
||||||
|
|
||||||
|
fun write(value: String) {
|
||||||
|
if (keyring != null) {
|
||||||
|
keyring.write(value)
|
||||||
|
if (keyring.read() == value) return
|
||||||
|
// Keyring accepted the write but did not persist it (e.g. KWallet
|
||||||
|
// without a live daemon). Drop the stale entry and fall back to
|
||||||
|
// the encrypted file so the token survives a restart.
|
||||||
|
keyring.clear()
|
||||||
|
}
|
||||||
|
writeEncrypted(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
keyring?.clear()
|
||||||
|
encFile.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadKey(): ByteArray {
|
||||||
|
if (keyFile.exists()) return keyFile.readBytes()
|
||||||
|
val key = ByteArray(32).also { SecureRandom().nextBytes(it) }
|
||||||
|
baseDir.mkdirs()
|
||||||
|
keyFile.writeBytes(key)
|
||||||
|
try {
|
||||||
|
keyFile.setReadable(false, false)
|
||||||
|
keyFile.setWritable(false, false)
|
||||||
|
keyFile.setReadable(true, true)
|
||||||
|
keyFile.setWritable(true, true)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeEncrypted(value: String) {
|
||||||
|
try {
|
||||||
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||||
|
val iv = ByteArray(12).also { SecureRandom().nextBytes(it) }
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(loadKey(), "AES"), GCMParameterSpec(128, iv))
|
||||||
|
val ct = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
|
||||||
|
baseDir.mkdirs()
|
||||||
|
encFile.writeBytes(iv + ct)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readEncrypted(): String? {
|
||||||
|
if (!encFile.exists()) return null
|
||||||
|
return try {
|
||||||
|
val bytes = encFile.readBytes()
|
||||||
|
if (bytes.size < 28) return null
|
||||||
|
val iv = bytes.copyOfRange(0, 12)
|
||||||
|
val ct = bytes.copyOfRange(12, bytes.size)
|
||||||
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(loadKey(), "AES"), GCMParameterSpec(128, iv))
|
||||||
|
String(cipher.doFinal(ct), Charsets.UTF_8)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OS keyring via the platform CLI (best effort). */
|
||||||
|
private class KeyringBackend {
|
||||||
|
private val os = System.getProperty("os.name").lowercase()
|
||||||
|
private val isMac = os.contains("mac")
|
||||||
|
private val isLinux = os.contains("linux")
|
||||||
|
|
||||||
|
/** macOS Keychain (`security`) or Linux Secret Service (`secret-tool`). */
|
||||||
|
val available: Boolean by lazy {
|
||||||
|
if (isMac) true
|
||||||
|
else if (isLinux) which("secret-tool") != null
|
||||||
|
else false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun read(): String? = try {
|
||||||
|
if (isMac) {
|
||||||
|
out(listOf("security", "find-generic-password", "-a", "iris", "-s", "iris-gateway-token", "-w"))
|
||||||
|
} else {
|
||||||
|
out(listOf("secret-tool", "lookup", "app", "iris"))
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun write(value: String) {
|
||||||
|
try {
|
||||||
|
if (isMac) {
|
||||||
|
ProcessBuilder(
|
||||||
|
"security", "add-generic-password", "-U",
|
||||||
|
"-a", "iris", "-s", "iris-gateway-token", "-w", value,
|
||||||
|
).inheritIO().start().waitFor()
|
||||||
|
} else {
|
||||||
|
ProcessBuilder(
|
||||||
|
"secret-tool", "store", "--label=Iris gateway token",
|
||||||
|
"app", "iris", "token", value,
|
||||||
|
).inheritIO().start().waitFor()
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
try {
|
||||||
|
if (isMac) {
|
||||||
|
ProcessBuilder(
|
||||||
|
"security", "delete-generic-password",
|
||||||
|
"-a", "iris", "-s", "iris-gateway-token",
|
||||||
|
).inheritIO().start().waitFor()
|
||||||
|
} else {
|
||||||
|
ProcessBuilder(
|
||||||
|
"secret-tool", "clear", "app", "iris",
|
||||||
|
).inheritIO().start().waitFor()
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun out(cmd: List<String>): String? {
|
||||||
|
val p = ProcessBuilder(cmd).redirectErrorStream(true).start()
|
||||||
|
val text = p.inputStream.bufferedReader().readText().trim()
|
||||||
|
p.waitFor()
|
||||||
|
return text.ifBlank { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun which(bin: String): String? = try {
|
||||||
|
val p = ProcessBuilder("which", bin).redirectErrorStream(true).start()
|
||||||
|
val text = p.inputStream.bufferedReader().readText().trim()
|
||||||
|
p.waitFor()
|
||||||
|
text.ifBlank { null }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package iris.platform
|
||||||
|
|
||||||
|
actual val isDesktop: Boolean = true
|
||||||
+26
-3
@@ -144,15 +144,38 @@ has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
|
|||||||
|
|
||||||
## M6 — Desktop app
|
## M6 — Desktop app
|
||||||
**Goal:** the same app on a big screen.
|
**Goal:** the same app on a big screen.
|
||||||
- [ ] `desktopMain`: tray + OS notifications; `MediaPlayer` actual (mpv/WebView);
|
- [x] `desktopMain`: tray + OS notifications; `MediaPlayer` actual (mpv/WebView);
|
||||||
`MediaPicker` actual (file dialog); `SecureStore` actual; window mgmt.
|
`MediaPicker` actual (file dialog); `SecureStore` actual; window mgmt.
|
||||||
- [ ] Two-pane default layout; keyboard shortcuts; optional inspector pane.
|
- [x] Two-pane default layout; keyboard shortcuts; optional inspector pane.
|
||||||
- [ ] Parity pass vs Android feature checklist (`11-desktop-app.md`).
|
- [ ] Parity pass vs Android feature checklist (`11-desktop-app.md`).
|
||||||
- [ ] jpackage builds (Linux first; macOS/Windows as available).
|
- [x] jpackage builds (Linux first; macOS/Windows as available).
|
||||||
- **Demo:** desktop app pairs to the same gateway; full feature parity; tray
|
- **Demo:** desktop app pairs to the same gateway; full feature parity; tray
|
||||||
notifications; media plays.
|
notifications; media plays.
|
||||||
- **Accept:** all Android features work on desktop; tray + shortcuts work;
|
- **Accept:** all Android features work on desktop; tray + shortcuts work;
|
||||||
native binary launches.
|
native binary launches.
|
||||||
|
- **Status (complete, 2026-08-19):** All `desktopMain` actuals implemented and
|
||||||
|
verified on Linux (X11). `DesktopSecureStore`: non-secrets in
|
||||||
|
`~/.iris/settings.json`; the gateway token goes to the OS keyring with
|
||||||
|
read-back verification, falling back to an AES-GCM encrypted file
|
||||||
|
(`~/.iris/pairing.enc` + owner-only `~/.iris/.key`) when the keyring doesn't
|
||||||
|
persist — KWallet on this host accepts writes without storing, so the
|
||||||
|
fallback is exercised (a stale keyring entry is cleared before falling back).
|
||||||
|
`DesktopMedia`: `JFileChooser` picker, ImageIO thumbnails, mpv playback
|
||||||
|
(audio via a JSON-IPC unix socket, video in a separate mpv window), docs via
|
||||||
|
`xdg-open`. `DesktopPush`: `notify-send` OS notifications + in-app banners,
|
||||||
|
foreground-aware via window focus (`DesktopBridge.foreground`). Tray (Compose
|
||||||
|
`Tray`): connection-state icon + tooltip, Show/Quit menu, close-to-tray.
|
||||||
|
Window size/position persisted to `~/.iris/window.json`. Two-pane layout
|
||||||
|
(channel rail + chat) + optional inspector pane + keyboard shortcuts
|
||||||
|
(Ctrl/Cmd N/T/F/K, Esc, `/` command palette) + search scope toggle.
|
||||||
|
Verified on-device: the app pairs to the same gateway, connects (green
|
||||||
|
"connected" chip), renders the two-pane layout, and replays the outbox.
|
||||||
|
jpackage Linux app-image builds (`./gradlew :desktopApp:jpackage`) and the
|
||||||
|
native binary launches + connects. Known: the jpackage launcher prints a
|
||||||
|
non-fatal "pure virtual method called" (JDK-8348560, a jpackage/Linux
|
||||||
|
launcher bug on JDK 17; the app runs and connects regardless). Parity is
|
||||||
|
achieved via shared-code reuse; a formal feature-by-feature parity pass and
|
||||||
|
macOS/Windows packaging are deferred to M7.
|
||||||
|
|
||||||
## M7 — Polish + E2E + docs
|
## M7 — Polish + E2E + docs
|
||||||
**Goal:** ship-quality.
|
**Goal:** ship-quality.
|
||||||
|
|||||||
Reference in new issue
Block a user