From 64eec9844bb31e4cff0ffc61b47112fca6e068ef Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Sat, 3 Jan 2026 13:00:02 +0200 Subject: [PATCH] bugfixes --- .../commons/state/EventCollectionState.kt | 2 +- .../amethyst/desktop/DebugConfig.kt | 59 ++++ .../vitorpamplona/amethyst/desktop/Main.kt | 40 ++- .../amethyst/desktop/ui/DevSettingsSection.kt | 261 ++++++++++++++++++ .../amethyst/desktop/DebugConfigTest.kt | 55 ++++ 5 files changed, 406 insertions(+), 11 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DebugConfig.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DevSettingsSection.kt create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/DebugConfigTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/EventCollectionState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/EventCollectionState.kt index 9307da691..daeabe2fe 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/EventCollectionState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/state/EventCollectionState.kt @@ -200,7 +200,7 @@ class EventCollectionState( // Sort if comparator provided, otherwise keep newest first (pending items already at end) val sorted = if (sortComparator != null) { - merged.sortedWith(sortComparator) + merged.sortedWith(sortComparator).distinctBy { getId(it) } } else { // Reverse so newest (pending) items come first (pendingItems.reversed() + _items.value).distinctBy { getId(it) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DebugConfig.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DebugConfig.kt new file mode 100644 index 000000000..85a88fde5 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DebugConfig.kt @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop + +/** + * Debug configuration for the desktop application. + * + * To enable debug mode, set the environment variable: + * AMETHYST_DEBUG=true + * + * Or run with: + * ./gradlew :desktopApp:run -PamethystDebug=true + */ +object DebugConfig { + /** + * Enables developer settings and debug features. + * + * When true, shows: + * - Raw nsec/npub in settings + * - Additional logging + * - Debug UI elements + */ + val isDebugMode: Boolean by lazy { + // Check environment variable + val envDebug = System.getenv("AMETHYST_DEBUG")?.toBoolean() ?: false + + // Check system property (for gradle -PamethystDebug=true) + val propDebug = System.getProperty("amethyst.debug")?.toBoolean() ?: false + + envDebug || propDebug + } + + /** + * Log debug information. + */ + fun log(message: String) { + if (isDebugMode) { + println("[DEBUG] $message") + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 898dac477..b328301a6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -91,21 +91,21 @@ private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") * Desktop navigation state - extends AppScreen with dynamic destinations. */ sealed class DesktopScreen { - data object Feed : DesktopScreen() + object Feed : DesktopScreen() - data object Search : DesktopScreen() + object Search : DesktopScreen() - data object Messages : DesktopScreen() + object Messages : DesktopScreen() - data object Notifications : DesktopScreen() + object Notifications : DesktopScreen() - data object MyProfile : DesktopScreen() + object MyProfile : DesktopScreen() data class UserProfile( val pubKeyHex: String, ) : DesktopScreen() - data object Settings : DesktopScreen() + object Settings : DesktopScreen() } fun main() = @@ -365,7 +365,7 @@ fun MainContent( onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) }, ) - DesktopScreen.Settings -> RelaySettingsScreen(relayManager) + DesktopScreen.Settings -> RelaySettingsScreen(relayManager, account) } } } @@ -405,17 +405,37 @@ fun ProfileScreen( } @Composable -fun RelaySettingsScreen(relayManager: DesktopRelayConnectionManager) { +fun RelaySettingsScreen( + relayManager: DesktopRelayConnectionManager, + account: AccountState.LoggedIn, +) { val relayStatuses by relayManager.relayStatuses.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState() var newRelayUrl by remember { mutableStateOf("") } Column(modifier = Modifier.fillMaxSize()) { Text( - "Relay Settings", + "Settings", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground, ) + + Spacer(Modifier.height(24.dp)) + + // Developer Settings Section (only in debug mode) + if (DebugConfig.isDebugMode) { + com.vitorpamplona.amethyst.desktop.ui + .DevSettingsSection(account = account) + Spacer(Modifier.height(24.dp)) + HorizontalDivider() + Spacer(Modifier.height(24.dp)) + } + + Text( + "Relay Settings", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) Spacer(Modifier.height(8.dp)) Row( @@ -470,7 +490,7 @@ fun RelaySettingsScreen(relayManager: DesktopRelayConnectionManager) { verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f), ) { - items(relayStatuses.values.toList()) { status -> + items(relayStatuses.values.toList(), key = { it.url.url }) { status -> RelayStatusCard( status = status, onRemove = { relayManager.removeRelay(status.url) }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DevSettingsSection.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DevSettingsSection.kt new file mode 100644 index 000000000..6c25bd22d --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DevSettingsSection.kt @@ -0,0 +1,261 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.account.AccountState +import java.awt.Toolkit +import java.awt.datatransfer.StringSelection + +/** + * Developer settings section - shows sensitive keys for debugging. + * Only enable in debug builds or with explicit debug flag. + */ +@Composable +fun DevSettingsSection( + account: AccountState.LoggedIn, + modifier: Modifier = Modifier, +) { + var showKeys by remember { mutableStateOf(false) } + + Column( + modifier = + modifier + .fillMaxWidth() + .border( + width = 2.dp, + color = Color(0xFFFF9800), // Orange warning color + shape = RoundedCornerShape(8.dp), + ).background( + color = Color(0xFF332200), // Dark orange tint + shape = RoundedCornerShape(8.dp), + ).padding(16.dp), + ) { + // Warning header + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + Icons.Default.Warning, + contentDescription = "Warning", + tint = Color(0xFFFF9800), + ) + Text( + "Developer Settings", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = Color(0xFFFF9800), + ) + } + + Spacer(Modifier.height(8.dp)) + + Text( + "Debug mode only - Handle keys with care", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(16.dp)) + + HorizontalDivider(color = Color(0xFFFF9800).copy(alpha = 0.3f)) + + Spacer(Modifier.height(16.dp)) + + // Toggle button + OutlinedButton( + onClick = { showKeys = !showKeys }, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = Color(0xFFFF9800), + ), + ) { + Text(if (showKeys) "Hide Keys" else "Show Keys") + } + + if (showKeys) { + Spacer(Modifier.height(16.dp)) + + // npub + KeyRow( + label = "Public Key (npub)", + value = account.npub, + isSensitive = false, + ) + + Spacer(Modifier.height(12.dp)) + + // nsec (only if available) + account.nsec?.let { nsec -> + KeyRow( + label = "Private Key (nsec)", + value = nsec, + isSensitive = true, + ) + } ?: run { + Text( + "Read-only mode - No private key available", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontStyle = androidx.compose.ui.text.font.FontStyle.Italic, + ) + } + + Spacer(Modifier.height(12.dp)) + + // Hex key + KeyRow( + label = "Public Key (hex)", + value = account.pubKeyHex, + isSensitive = false, + ) + } + } +} + +@Composable +private fun KeyRow( + label: String, + value: String, + isSensitive: Boolean, + modifier: Modifier = Modifier, +) { + var copiedRecently by remember { mutableStateOf(false) } + + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + label, + style = MaterialTheme.typography.labelMedium, + color = + if (isSensitive) { + Color(0xFFFF5252) + } else { + MaterialTheme.colorScheme.onSurface + }, + fontWeight = FontWeight.Bold, + ) + + Button( + onClick = { + copyToClipboard(value) + copiedRecently = true + }, + colors = + ButtonDefaults.buttonColors( + containerColor = + if (copiedRecently) { + Color(0xFF4CAF50) + } else { + MaterialTheme.colorScheme.primaryContainer + }, + ), + ) { + Icon( + Icons.Default.ContentCopy, + contentDescription = "Copy", + modifier = Modifier.padding(end = 4.dp), + ) + Text(if (copiedRecently) "Copied!" else "Copy") + } + } + + Spacer(Modifier.height(4.dp)) + + // Display the key in monospace + Text( + value, + style = + MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + color = + if (isSensitive) { + Color(0xFFFF5252) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = + Modifier + .fillMaxWidth() + .background( + color = Color(0xFF1A1A1A), + shape = RoundedCornerShape(4.dp), + ).padding(8.dp), + ) + + // Reset copied state after a delay + if (copiedRecently) { + androidx.compose.runtime.LaunchedEffect(Unit) { + kotlinx.coroutines.delay(2000) + copiedRecently = false + } + } + } +} + +/** + * Copy text to system clipboard using AWT Toolkit. + */ +private fun copyToClipboard(text: String) { + try { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + val selection = StringSelection(text) + clipboard.setContents(selection, selection) + } catch (e: Exception) { + e.printStackTrace() + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/DebugConfigTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/DebugConfigTest.kt new file mode 100644 index 000000000..90e2b34d4 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/DebugConfigTest.kt @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DebugConfigTest { + @Test + fun testDebugModeCanBeQueried() { + // This test verifies that DebugConfig.isDebugMode is accessible + // The actual value depends on environment/system properties + val debugMode = DebugConfig.isDebugMode + // Just verify it returns a boolean (true or false) + assertTrue(debugMode || !debugMode, "isDebugMode should return a boolean value") + } + + @Test + fun testDebugLogDoesNotThrow() { + // Verify debug logging doesn't throw exceptions + try { + DebugConfig.log("Test debug message") + assertTrue(true) + } catch (e: Exception) { + assertFalse(true, "Debug log should not throw exceptions: ${e.message}") + } + } + + @Test + fun testDebugConfigIsObject() { + // Verify DebugConfig is a singleton object + val instance1 = DebugConfig + val instance2 = DebugConfig + assertTrue(instance1 === instance2, "DebugConfig should be a singleton object") + } +}