Extend desktop SQLite layer: per-app permissions, history queries, relays, settings

SqliteBunkerPermissionStore gains permissionsFor()/deletePermission() so
the upcoming app-detail screen can list and revoke a single rule instead
of only all-or-nothing via revokeAll(). SqliteBunkerHistoryLogger gains
recentHistory()/recentHistoryFor()/nameFor(), and its applications
upsert no longer clobbers a previously-learned app name with a blank one
from a later request.

New RelayStore/SettingsStore back a custom relay list and a small
key/value UI-preferences store (theme mode), backed by two new tables
in BunkerDatabase. All covered by SqliteBunkerDataLayerTest against an
in-memory SQLite connection.
This commit is contained in:
Claude
2026-07-01 18:52:44 +00:00
parent 06d9ba5b26
commit 5d2a5654be
6 changed files with 263 additions and 3 deletions
+1
View File
@@ -25,6 +25,7 @@ dependencies {
implementation(libs.xerial.sqlite.jdbc)
testImplementation(kotlin("test"))
testImplementation(libs.kotlinx.coroutines.test)
}
compose.desktop {
@@ -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
}
@@ -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<String> = 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()
}
}
}
@@ -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()
}
}
}
@@ -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<StoredPermission> = 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<HistoryRow> = 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<HistoryRow> = 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<HistoryRow> = 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"),
),
),
)
}
}
}
}
}
@@ -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"))
}
}