This commit is contained in:
nrobi144
2026-01-03 13:00:02 +02:00
parent f800e20b05
commit 64eec9844b
5 changed files with 406 additions and 11 deletions
@@ -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")
}
}
}
@@ -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) },
@@ -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()
}
}
@@ -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")
}
}