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:
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user