diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index 1febf242..165e4b89 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { implementation(libs.xerial.sqlite.jdbc) testImplementation(kotlin("test")) + testImplementation(libs.kotlinx.coroutines.test) } compose.desktop { diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/BunkerDatabase.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/BunkerDatabase.kt index 3362ca78..0a0c04ed 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/BunkerDatabase.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/BunkerDatabase.kt @@ -63,6 +63,8 @@ object BunkerDatabase { ) """.trimIndent(), ) + statement.executeUpdate("CREATE TABLE IF NOT EXISTS relays (url TEXT PRIMARY KEY)") + statement.executeUpdate("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)") } return connection } diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/RelayStore.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/RelayStore.kt new file mode 100644 index 00000000..87a5cb86 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/RelayStore.kt @@ -0,0 +1,30 @@ +package com.greenart7c3.nostrsigner.desktop.data + +import java.sql.Connection +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** Persists the user's custom relay list; [BunkerRelayConnection][com.greenart7c3.nostrsigner.desktop.relay.BunkerRelayConnection] falls back to its defaults when this is empty. */ +class RelayStore(private val connection: Connection) { + suspend fun list(): List = withContext(Dispatchers.IO) { + connection.prepareStatement("SELECT url FROM relays ORDER BY url").use { statement -> + statement.executeQuery().use { rows -> + buildList { while (rows.next()) add(rows.getString("url")) } + } + } + } + + suspend fun add(url: String) = withContext(Dispatchers.IO) { + connection.prepareStatement("INSERT OR IGNORE INTO relays (url) VALUES (?)").use { statement -> + statement.setString(1, url) + statement.executeUpdate() + } + } + + suspend fun remove(url: String) = withContext(Dispatchers.IO) { + connection.prepareStatement("DELETE FROM relays WHERE url = ?").use { statement -> + statement.setString(1, url) + statement.executeUpdate() + } + } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/SettingsStore.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/SettingsStore.kt new file mode 100644 index 00000000..5695e2df --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/SettingsStore.kt @@ -0,0 +1,28 @@ +package com.greenart7c3.nostrsigner.desktop.data + +import java.sql.Connection +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** Small key/value store for UI preferences (currently just theme mode). */ +class SettingsStore(private val connection: Connection) { + suspend fun get(key: String): String? = withContext(Dispatchers.IO) { + connection.prepareStatement("SELECT value FROM settings WHERE key = ?").use { statement -> + statement.setString(1, key) + statement.executeQuery().use { rows -> if (rows.next()) rows.getString("value") else null } + } + } + + suspend fun set(key: String, value: String) = withContext(Dispatchers.IO) { + connection.prepareStatement( + """ + INSERT INTO settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + """.trimIndent(), + ).use { statement -> + statement.setString(1, key) + statement.setString(2, value) + statement.executeUpdate() + } + } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/SqliteBunkerPermissionStore.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/SqliteBunkerPermissionStore.kt index 8717c34a..877c97b9 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/SqliteBunkerPermissionStore.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/data/SqliteBunkerPermissionStore.kt @@ -46,10 +46,51 @@ class SqliteBunkerPermissionStore(private val connection: Connection) : BunkerPe statement.executeUpdate() } } + + /** Lists every stored auto-accept/reject rule for one app, for the app-detail permission editor. */ + suspend fun permissionsFor(appPubKey: String): List = withContext(Dispatchers.IO) { + connection.prepareStatement( + "SELECT method, kind, approved FROM permissions WHERE app_pub_key = ? ORDER BY method, kind", + ).use { statement -> + statement.setString(1, appPubKey) + statement.executeQuery().use { rows -> + buildList { + while (rows.next()) { + add( + StoredPermission( + appPubKey = appPubKey, + method = BunkerMethod.valueOf(rows.getString("method")), + kind = BunkerDatabase.columnToKind(rows.getInt("kind")), + approved = rows.getInt("approved") != 0, + ), + ) + } + } + } + } + } + + /** Deletes a single stored rule, resetting that method/kind back to "ask next time". */ + suspend fun deletePermission(appPubKey: String, method: BunkerMethod, kind: Int?) = withContext(Dispatchers.IO) { + connection.prepareStatement( + "DELETE FROM permissions WHERE app_pub_key = ? AND method = ? AND kind = ?", + ).use { statement -> + statement.setString(1, appPubKey) + statement.setString(2, method.name) + statement.setInt(3, BunkerDatabase.kindToColumn(kind)) + statement.executeUpdate() + } + } } +/** A single stored auto-accept/auto-reject rule, as shown in the app-detail permission editor. */ +data class StoredPermission(val appPubKey: String, val method: BunkerMethod, val kind: Int?, val approved: Boolean) + data class ConnectedApp(val pubKey: String, val name: String, val connectedAt: Long) +/** One row of stored history, including its autoincrement id (used as a stable LazyColumn key). */ +data class HistoryRow(val id: Long, val entry: BunkerHistoryEntry) + class SqliteBunkerHistoryLogger(private val connection: Connection) : BunkerHistoryLogger { override suspend fun log(entry: BunkerHistoryEntry) { withContext(Dispatchers.IO) { @@ -65,12 +106,15 @@ class SqliteBunkerHistoryLogger(private val connection: Connection) : BunkerHist } connection.prepareStatement( """ - INSERT INTO applications (app_pub_key, connected_at) VALUES (?, ?) - ON CONFLICT(app_pub_key) DO UPDATE SET connected_at = excluded.connected_at + INSERT INTO applications (app_pub_key, name, connected_at) VALUES (?, ?, ?) + ON CONFLICT(app_pub_key) DO UPDATE SET + connected_at = excluded.connected_at, + name = CASE WHEN excluded.name != '' THEN excluded.name ELSE applications.name END """.trimIndent(), ).use { statement -> statement.setString(1, entry.appPubKey) - statement.setLong(2, entry.time) + statement.setString(2, entry.appName.orEmpty()) + statement.setLong(3, entry.time) statement.executeUpdate() } } @@ -87,4 +131,53 @@ class SqliteBunkerHistoryLogger(private val connection: Connection) : BunkerHist } } } + + /** The last known display name for an app, if any — used as [com.greenart7c3.nostrsigner.shared.BunkerSigningEngine]'s `appNameLookup` fallback. */ + suspend fun nameFor(appPubKey: String): String? = withContext(Dispatchers.IO) { + connection.prepareStatement("SELECT name FROM applications WHERE app_pub_key = ?").use { statement -> + statement.setString(1, appPubKey) + statement.executeQuery().use { rows -> + if (rows.next()) rows.getString("name").takeIf { it.isNotBlank() } else null + } + } + } + + /** The most recent history entries across all apps, newest first. */ + suspend fun recentHistory(limit: Int = 200): List = withContext(Dispatchers.IO) { + queryHistory("SELECT id, app_pub_key, method, kind, approved, time FROM history ORDER BY time DESC LIMIT ?") { statement -> + statement.setInt(1, limit) + } + } + + /** The most recent history entries for a single app, newest first. */ + suspend fun recentHistoryFor(appPubKey: String, limit: Int = 50): List = withContext(Dispatchers.IO) { + queryHistory( + "SELECT id, app_pub_key, method, kind, approved, time FROM history WHERE app_pub_key = ? ORDER BY time DESC LIMIT ?", + ) { statement -> + statement.setString(1, appPubKey) + statement.setInt(2, limit) + } + } + + private fun queryHistory(sql: String, bind: (java.sql.PreparedStatement) -> Unit): List = connection.prepareStatement(sql).use { statement -> + bind(statement) + statement.executeQuery().use { rows -> + buildList { + while (rows.next()) { + add( + HistoryRow( + id = rows.getLong("id"), + entry = BunkerHistoryEntry( + appPubKey = rows.getString("app_pub_key"), + method = BunkerMethod.valueOf(rows.getString("method")), + kind = BunkerDatabase.columnToKind(rows.getInt("kind")), + approved = rows.getInt("approved") != 0, + time = rows.getLong("time"), + ), + ), + ) + } + } + } + } } diff --git a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/data/SqliteBunkerDataLayerTest.kt b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/data/SqliteBunkerDataLayerTest.kt new file mode 100644 index 00000000..e42d27ac --- /dev/null +++ b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/data/SqliteBunkerDataLayerTest.kt @@ -0,0 +1,106 @@ +package com.greenart7c3.nostrsigner.desktop.data + +import com.greenart7c3.nostrsigner.shared.BunkerHistoryEntry +import com.greenart7c3.nostrsigner.shared.BunkerMethod +import java.sql.Connection +import java.sql.DriverManager +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +class SqliteBunkerDataLayerTest { + private lateinit var connection: Connection + + @BeforeTest + fun setUp() { + connection = DriverManager.getConnection("jdbc:sqlite::memory:") + connection.createStatement().use { statement -> + statement.executeUpdate("CREATE TABLE applications (app_pub_key TEXT PRIMARY KEY, name TEXT NOT NULL DEFAULT '', connected_at INTEGER NOT NULL)") + statement.executeUpdate("CREATE TABLE permissions (app_pub_key TEXT NOT NULL, method TEXT NOT NULL, kind INTEGER NOT NULL, approved INTEGER NOT NULL, PRIMARY KEY (app_pub_key, method, kind))") + statement.executeUpdate("CREATE TABLE history (id INTEGER PRIMARY KEY AUTOINCREMENT, app_pub_key TEXT NOT NULL, method TEXT NOT NULL, kind INTEGER NOT NULL, approved INTEGER NOT NULL, time INTEGER NOT NULL)") + statement.executeUpdate("CREATE TABLE relays (url TEXT PRIMARY KEY)") + statement.executeUpdate("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)") + } + } + + @AfterTest + fun tearDown() { + connection.close() + } + + @Test + fun permissionsForListsAndDeletePermissionRemovesOne() = runTest { + val store = SqliteBunkerPermissionStore(connection) + store.remember("app1", BunkerMethod.SIGN_EVENT, 1, true) + store.remember("app1", BunkerMethod.PING, null, false) + + val permissions = store.permissionsFor("app1") + assertEquals(2, permissions.size) + assertTrue(permissions.any { it.method == BunkerMethod.SIGN_EVENT && it.kind == 1 && it.approved }) + assertTrue(permissions.any { it.method == BunkerMethod.PING && it.kind == null && !it.approved }) + + store.deletePermission("app1", BunkerMethod.PING, null) + assertEquals(1, store.permissionsFor("app1").size) + assertNull(store.isApproved("app1", BunkerMethod.PING, null)) + } + + @Test + fun historyLoggerUpsertsAppNameWithoutClobberingWithBlank() = runTest { + val logger = SqliteBunkerHistoryLogger(connection) + logger.log(BunkerHistoryEntry("app1", BunkerMethod.CONNECT, null, true, 1000L, appName = "My App")) + assertEquals("My App", logger.nameFor("app1")) + + // A later request without metadata must not blank out the previously-learned name. + logger.log(BunkerHistoryEntry("app1", BunkerMethod.PING, null, true, 2000L, appName = null)) + assertEquals("My App", logger.nameFor("app1")) + + val connected = logger.connectedApps() + assertEquals(1, connected.size) + assertEquals("My App", connected.single().name) + assertEquals(2000L, connected.single().connectedAt) + } + + @Test + fun recentHistoryAndRecentHistoryForReturnNewestFirst() = runTest { + val logger = SqliteBunkerHistoryLogger(connection) + logger.log(BunkerHistoryEntry("app1", BunkerMethod.PING, null, true, 1000L)) + logger.log(BunkerHistoryEntry("app2", BunkerMethod.GET_PUBLIC_KEY, null, true, 2000L)) + logger.log(BunkerHistoryEntry("app1", BunkerMethod.SIGN_EVENT, 1, true, 3000L)) + + val all = logger.recentHistory() + assertEquals(listOf(3000L, 2000L, 1000L), all.map { it.entry.time }) + + val app1Only = logger.recentHistoryFor("app1") + assertEquals(listOf(3000L, 1000L), app1Only.map { it.entry.time }) + assertTrue(app1Only.all { it.entry.appPubKey == "app1" }) + } + + @Test + fun relayStoreAddsListsAndRemoves() = runTest { + val relayStore = RelayStore(connection) + relayStore.add("wss://relay.example.com") + relayStore.add("wss://relay2.example.com") + relayStore.add("wss://relay.example.com") // duplicate, ignored + + assertEquals(listOf("wss://relay.example.com", "wss://relay2.example.com"), relayStore.list()) + + relayStore.remove("wss://relay.example.com") + assertEquals(listOf("wss://relay2.example.com"), relayStore.list()) + } + + @Test + fun settingsStoreRoundTrips() = runTest { + val settingsStore = SettingsStore(connection) + assertNull(settingsStore.get("theme_mode")) + + settingsStore.set("theme_mode", "DARK") + assertEquals("DARK", settingsStore.get("theme_mode")) + + settingsStore.set("theme_mode", "LIGHT") + assertEquals("LIGHT", settingsStore.get("theme_mode")) + } +}