feat: Add Compose Multiplatform Desktop support foundation

- Add desktopApp module with JVM entry point and sidebar navigation
- Add Claude specs for AI-assisted development:
  - Agent definitions: nostr-protocol, kotlin-multiplatform, compose-ui, kotlin-coroutines
  - Skills: quartz-kmp conversion, compose-desktop patterns
  - Commands: desktop-run, nip, extract
- Update Gradle configuration with Compose Multiplatform 1.7.1 plugin
- Add coroutines and secp256k1 JVM dependencies to version catalog

Next steps:
- Convert Quartz library to full KMP (expect/actual for crypto)
- Implement relay connections in desktop app
- Share UI components between Android and Desktop

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2025-12-26 07:51:43 +02:00
parent aa2f969be6
commit 82f8bc62a0
16 changed files with 1913 additions and 2 deletions
+339
View File
@@ -0,0 +1,339 @@
# Compose Desktop Skill
## Desktop Application Entry Point
```kotlin
// desktopApp/src/jvmMain/kotlin/Main.kt
package com.vitorpamplona.amethyst.desktop
import androidx.compose.ui.Alignment
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.*
fun main() = application {
val windowState = rememberWindowState(
width = 1200.dp,
height = 800.dp,
position = WindowPosition.Aligned(Alignment.Center)
)
// System tray
Tray(
icon = painterResource("icon.png"),
tooltip = "Amethyst",
menu = {
Item("Show", onClick = { windowState.isMinimized = false })
Separator()
Item("Exit", onClick = ::exitApplication)
}
)
Window(
onCloseRequest = ::exitApplication,
state = windowState,
title = "Amethyst",
icon = painterResource("icon.png")
) {
MenuBar {
Menu("File") {
Item("New Note", shortcut = KeyShortcut(Key.N, ctrl = true)) { }
Separator()
Item("Settings", shortcut = KeyShortcut(Key.Comma, ctrl = true)) { }
Separator()
Item("Quit", shortcut = KeyShortcut(Key.Q, ctrl = true), onClick = ::exitApplication)
}
Menu("Edit") {
Item("Copy", shortcut = KeyShortcut(Key.C, ctrl = true)) { }
Item("Paste", shortcut = KeyShortcut(Key.V, ctrl = true)) { }
}
Menu("View") {
Item("Feed") { }
Item("Messages") { }
Item("Notifications") { }
}
Menu("Help") {
Item("About Amethyst") { }
}
}
App()
}
}
```
## Desktop-Specific Components
### File Dialog
```kotlin
@Composable
fun rememberFileDialog(): FileDialogState {
return remember { FileDialogState() }
}
class FileDialogState {
var isOpen by mutableStateOf(false)
var result by mutableStateOf<File?>(null)
fun open() { isOpen = true }
@Composable
fun Dialog(
title: String = "Select File",
allowedExtensions: List<String> = emptyList()
) {
if (isOpen) {
DisposableEffect(Unit) {
val dialog = java.awt.FileDialog(null as java.awt.Frame?, title)
if (allowedExtensions.isNotEmpty()) {
dialog.setFilenameFilter { _, name ->
allowedExtensions.any { name.endsWith(it) }
}
}
dialog.isVisible = true
result = dialog.file?.let { File(dialog.directory, it) }
isOpen = false
onDispose { }
}
}
}
}
```
### Scroll Behavior
```kotlin
@Composable
fun DesktopScrollableColumn(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
val scrollState = rememberScrollState()
Box(modifier) {
Column(
modifier = Modifier
.verticalScroll(scrollState)
.fillMaxSize()
) {
content()
}
VerticalScrollbar(
modifier = Modifier.align(Alignment.CenterEnd),
adapter = rememberScrollbarAdapter(scrollState)
)
}
}
```
### Keyboard Navigation
```kotlin
@Composable
fun KeyboardNavigableList(
items: List<Note>,
selectedIndex: Int,
onSelect: (Int) -> Unit,
onActivate: (Note) -> Unit
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
LazyColumn(
modifier = Modifier
.focusRequester(focusRequester)
.focusable()
.onKeyEvent { event ->
when {
event.key == Key.DirectionDown && event.type == KeyEventType.KeyDown -> {
onSelect((selectedIndex + 1).coerceAtMost(items.lastIndex))
true
}
event.key == Key.DirectionUp && event.type == KeyEventType.KeyDown -> {
onSelect((selectedIndex - 1).coerceAtLeast(0))
true
}
event.key == Key.Enter && event.type == KeyEventType.KeyDown -> {
items.getOrNull(selectedIndex)?.let { onActivate(it) }
true
}
else -> false
}
}
) {
itemsIndexed(items) { index, note ->
NoteCard(
note = note,
isSelected = index == selectedIndex,
modifier = Modifier.clickable { onSelect(index) }
)
}
}
}
```
### Multi-Window Support
```kotlin
@Composable
fun ApplicationScope.NoteDetailWindow(
note: Note,
onClose: () -> Unit
) {
Window(
onCloseRequest = onClose,
title = "Note by ${note.author.name}",
state = rememberWindowState(width = 600.dp, height = 400.dp)
) {
NoteDetailScreen(note)
}
}
// Usage in main application
var openNotes by remember { mutableStateOf<List<Note>>(emptyList()) }
openNotes.forEach { note ->
key(note.id) {
NoteDetailWindow(
note = note,
onClose = { openNotes = openNotes - note }
)
}
}
```
### Tooltips
```kotlin
@Composable
fun TooltipButton(
tooltip: String,
onClick: () -> Unit,
content: @Composable () -> Unit
) {
TooltipArea(
tooltip = {
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.inverseSurface
) {
Text(
text = tooltip,
modifier = Modifier.padding(8.dp),
color = MaterialTheme.colorScheme.inverseOnSurface
)
}
}
) {
IconButton(onClick = onClick) {
content()
}
}
}
```
## Desktop Layout Pattern
```kotlin
@Composable
fun DesktopAppLayout(
currentScreen: Screen,
onNavigate: (Screen) -> Unit,
content: @Composable () -> Unit
) {
Row(Modifier.fillMaxSize()) {
// Sidebar navigation
NavigationRail(
modifier = Modifier.width(72.dp),
containerColor = MaterialTheme.colorScheme.surfaceVariant
) {
Spacer(Modifier.height(16.dp))
NavigationRailItem(
icon = { Icon(Icons.Default.Home, "Feed") },
label = { Text("Feed") },
selected = currentScreen == Screen.Feed,
onClick = { onNavigate(Screen.Feed) }
)
NavigationRailItem(
icon = { Icon(Icons.Default.Email, "Messages") },
label = { Text("DMs") },
selected = currentScreen == Screen.Messages,
onClick = { onNavigate(Screen.Messages) }
)
NavigationRailItem(
icon = { Icon(Icons.Default.Notifications, "Notifications") },
label = { Text("Alerts") },
selected = currentScreen == Screen.Notifications,
onClick = { onNavigate(Screen.Notifications) }
)
Spacer(Modifier.weight(1f))
NavigationRailItem(
icon = { Icon(Icons.Default.Settings, "Settings") },
label = { Text("Settings") },
selected = currentScreen == Screen.Settings,
onClick = { onNavigate(Screen.Settings) }
)
}
// Divider
VerticalDivider()
// Main content
Box(Modifier.weight(1f).fillMaxHeight()) {
content()
}
}
}
```
## Build Configuration
```kotlin
// desktopApp/build.gradle.kts
plugins {
kotlin("jvm")
id("org.jetbrains.compose")
}
dependencies {
implementation(compose.desktop.currentOs)
implementation(compose.material3)
implementation(project(":quartz"))
}
compose.desktop {
application {
mainClass = "com.vitorpamplona.amethyst.desktop.MainKt"
nativeDistributions {
targetFormats(
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg,
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Msi,
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Deb
)
packageName = "Amethyst"
packageVersion = "1.0.0"
macOS {
bundleID = "com.vitorpamplona.amethyst.desktop"
iconFile.set(project.file("icons/icon.icns"))
}
windows {
iconFile.set(project.file("icons/icon.ico"))
menuGroup = "Amethyst"
}
linux {
iconFile.set(project.file("icons/icon.png"))
}
}
}
}
```
+165
View File
@@ -0,0 +1,165 @@
# Quartz KMP Conversion Skill
When working with Quartz library conversion to Kotlin Multiplatform:
## Current Structure (Android-only)
```
quartz/
├── build.gradle
└── src/
├── main/kotlin/ # All Nostr code here
├── test/
└── androidTest/
```
## Target Structure (KMP)
```
quartz/
├── build.gradle.kts
└── src/
├── commonMain/kotlin/ # Shared protocol code
├── commonTest/kotlin/ # Shared tests
├── androidMain/kotlin/ # Android crypto, storage
├── androidTest/kotlin/
├── jvmMain/kotlin/ # Desktop crypto, storage
└── jvmTest/kotlin/
```
## Platform Abstractions Required
### 1. Cryptography (expect/actual)
```kotlin
// commonMain
expect object Secp256k1 {
fun sign(data: ByteArray, privateKey: ByteArray): ByteArray
fun verify(data: ByteArray, signature: ByteArray, pubKey: ByteArray): Boolean
fun pubKeyCreate(privateKey: ByteArray): ByteArray
}
// androidMain - uses secp256k1-kmp-jni-android
actual object Secp256k1 {
actual fun sign(data: ByteArray, privateKey: ByteArray): ByteArray {
return fr.acinq.secp256k1.Secp256k1.sign(data, privateKey)
}
// ...
}
// jvmMain - uses secp256k1-kmp-jni-jvm
actual object Secp256k1 {
actual fun sign(data: ByteArray, privateKey: ByteArray): ByteArray {
return fr.acinq.secp256k1.Secp256k1.sign(data, privateKey)
}
// ...
}
```
### 2. NIP-44 Encryption (Sodium)
```kotlin
// commonMain
expect object Nip44 {
fun encrypt(plaintext: String, sharedSecret: ByteArray): String
fun decrypt(ciphertext: String, sharedSecret: ByteArray): String
}
// androidMain - lazysodium-android
// jvmMain - lazysodium-java or libsodium-jni
```
### 3. Secure Random
```kotlin
// commonMain
expect fun secureRandomBytes(size: Int): ByteArray
// androidMain
actual fun secureRandomBytes(size: Int): ByteArray {
return SecureRandom().let { random ->
ByteArray(size).also { random.nextBytes(it) }
}
}
// jvmMain
actual fun secureRandomBytes(size: Int): ByteArray {
return java.security.SecureRandom().let { random ->
ByteArray(size).also { random.nextBytes(it) }
}
}
```
## Build Configuration
```kotlin
// quartz/build.gradle.kts
plugins {
kotlin("multiplatform")
id("com.android.library")
}
kotlin {
androidTarget {
compilations.all {
kotlinOptions.jvmTarget = "17"
}
}
jvm("desktop") {
compilations.all {
kotlinOptions.jvmTarget = "17"
}
}
sourceSets {
val commonMain by getting {
dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.collections.immutable)
}
}
val androidMain by getting {
dependencies {
implementation(libs.secp256k1.kmp.jni.android)
implementation(libs.lazysodium.android)
}
}
val desktopMain by getting {
dependencies {
implementation(libs.secp256k1.kmp.jni.jvm)
// lazysodium-java or alternative
}
}
}
}
android {
namespace = "com.vitorpamplona.quartz"
compileSdk = 35
defaultConfig.minSdk = 26
}
```
## Migration Steps
1. **Convert build.gradle to build.gradle.kts** with KMP plugin
2. **Move pure Kotlin code** to `commonMain/`
3. **Identify platform dependencies** (crypto, JNA, Android APIs)
4. **Create expect declarations** for platform-specific APIs
5. **Implement actuals** in androidMain and jvmMain
6. **Update imports** in amethyst module
7. **Test on both platforms**
## Files to Move to commonMain
Most of Quartz can be shared:
- Event classes and parsing
- Filter definitions
- Relay message types
- NIP implementations (logic only)
- Utilities (hex encoding, bech32)
## Files Needing expect/actual
- `Secp256k1.kt` - Signature operations
- `Nip04.kt` - Legacy encryption (uses AES)
- `Nip44.kt` - Modern encryption (uses ChaCha)
- `KeyPair.kt` - Key generation