From adf1f03817e63735448040f774148387283057f0 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 6 Sep 2026 01:34:10 +0200 Subject: [PATCH] feat: a desktop entry point, and the first code here that runs Phase 5. `press.mantra.desktop.MainKt` has been named by the compose.desktop block since before this work started and did not exist; now it does, and `./gradlew :composeApp:run` opens a window. **The window opens onto a passphrase gate, not onto the app.** That is phase 3 landing here rather than there, and it was not in the plan. keyStoreEncryption(keyName, plainText) takes no secret, because on android the OS keystore serves keys without asking anybody anything -- so a passphrase scheme needs an unlock the expect signature cannot express. MainKt calls JvmKeyStore.unlock before MantraApp is composed, off the ui thread, because Argon2id at 64 MiB is deliberately slow enough to stop the window painting. The gate says on its face that this build is not for real funds. One application directory is handed to both the mantra and the phoenix context, so a single install keeps a single place on disk rather than two named after different projects. **MantraDatabaseJvmTest is the part worth keeping.** Running the app proves the window paints; it proves nothing about Room, because the gate stops before anything touches the database. Six tests now open it: the schema is created, a profile survives a write and a read, upsert replaces rather than duplicates, the @Transaction relation query behind findChatRoomById reads back, a soft-deleted room stops being found, and the on-disk builder writes under the context directory rather than java.io.tmpdir. This is the first time this database has been opened anywhere but android, and it covers exactly what the compiler cannot see -- that Room's ksp output for this target is usable, that the *host* SQLite native loads where the android artifact's would not, and that the 58 queries forced from blocking to suspend still return what they stored. Both of that test's first drafts were wrong in ways worth keeping the scars of. Every write failed with SQLite error 787 because Profile has a foreign key onto NostrEvent and the test never created the parent row -- which is evidence rather than an annoyance, since a schema whose constraints were quietly off would have let all of it pass. And Kind is a typealias for Int, not a constructor. Window sizing is 480x900: a starting size that does not immediately misrepresent layouts only ever exercised at phone widths, not a considered desktop layout. That, along with back handling and any ui offering an nfc affordance, is the shakeout this phase names and does not do. Verified, all five green: :composeApp:compileKotlinJvm, :composeApp:compileDebugKotlinAndroid, :composeApp:testDebugUnitTest (52), :composeApp:jvmTest (6), and the fork's :library:jvmTest (97). Not verified: nothing past the gate. No seed has been written, no business started, no relay contacted. A gradle `run` killed with SIGTERM reports BUILD FAILED with exit value 143 -- that is the signal, not the app. Co-Authored-By: Claude Opus 5 --- .../kotlin/press/mantra/desktop/Main.kt | 165 ++++++++++++++++ .../compose/database/MantraDatabaseJvmTest.kt | 178 ++++++++++++++++++ docs/jvm-target.md | 23 ++- 3 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/jvmMain/kotlin/press/mantra/desktop/Main.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/database/MantraDatabaseJvmTest.kt diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/desktop/Main.kt b/composeApp/src/jvmMain/kotlin/press/mantra/desktop/Main.kt new file mode 100644 index 00000000..81e4ded9 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/press/mantra/desktop/Main.kt @@ -0,0 +1,165 @@ +package press.mantra.desktop + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import androidx.navigation.compose.rememberNavController +import fr.acinq.phoenix.PhoenixGlobal +import fr.acinq.phoenix.security.JvmKeyStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import press.mantra.compose.MantraApp +import press.mantra.compose.MantraGlobal +import press.mantra.compose.PlatformContext +import press.mantra.compose.defaultMantraDir +import java.io.File + +/** + * The desktop entry point, named by the `compose.desktop` block in build.gradle.kts. + * + * Two things differ from `MainActivity` and `MainViewController`, and both follow from the + * jvm having no operating system keystore: + * + * - the wallet seed is protected by a passphrase, so nothing can start until one has been + * entered (see [PassphraseGate]); + * - one directory is handed to both the mantra and the phoenix context, so a single install + * keeps a single place on disk rather than two named after different projects. + */ +fun main() = application { + val appDir = remember { defaultMantraDir().apply { mkdirs() } } + + Window( + onCloseRequest = ::exitApplication, + title = "Mantra", + // The layouts have only ever been exercised at phone widths. This is a starting size + // that does not immediately misrepresent them, not a considered desktop layout. + state = rememberWindowState(width = 480.dp, height = 900.dp), + ) { + var unlocked by remember { mutableStateOf(false) } + + if (unlocked) { + val navController = rememberNavController() + MantraApp( + navController = navController, + mantraGlobal = MantraGlobal(platformContext = PlatformContext(appDir)), + phoenixGlobal = PhoenixGlobal( + ctx = fr.acinq.phoenix.utils.PlatformContext(applicationDir = appDir), + ), + ) + } else { + PassphraseGate(appDir = appDir, onUnlocked = { unlocked = true }) + } + } +} + +/** + * Blocks the app until [JvmKeyStore] holds a derived key. + * + * Android and ios have no screen like this because their OS keystores serve keys without + * asking anybody anything. Here the passphrase is the protection, so seed access cannot begin + * before it is supplied: `keyStoreEncryption` and `keyStoreDecryption` throw until it is. + * + * **A new store accepts any passphrase**, since there is nothing yet to check one against. On + * an existing store a wrong one is not detected here either -- it surfaces later, as a seed + * that will not decrypt, when a data key fails to unwrap. That is a poor thing to learn after + * the fact, and the fix belongs in the key store rather than in this screen. + */ +@Composable +private fun PassphraseGate(appDir: File, onUnlocked: () -> Unit) { + var passphrase by remember { mutableStateOf("") } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + fun submit() { + if (busy || passphrase.isEmpty()) return + busy = true + error = null + scope.launch { + // Argon2id at 64 MiB is deliberately slow. Off the ui thread, or the window stops + // painting for as long as it takes. + val failure = withContext(Dispatchers.Default) { + runCatching { + val chars = passphrase.toCharArray() + try { + JvmKeyStore.unlock(chars, appDir) + } finally { + chars.fill(' ') + } + }.exceptionOrNull() + } + busy = false + if (failure == null) { + passphrase = "" + onUnlocked() + } else { + error = failure.message ?: failure::class.simpleName + } + } + } + + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("Unlock Mantra", style = MaterialTheme.typography.headlineSmall) + Text( + "This desktop build protects your seed with a passphrase rather than a " + + "hardware-backed keystore. Do not use it for real funds.", + style = MaterialTheme.typography.bodySmall, + ) + OutlinedTextField( + value = passphrase, + onValueChange = { passphrase = it; error = null }, + label = { Text("Passphrase") }, + singleLine = true, + enabled = !busy, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go), + keyboardActions = KeyboardActions(onGo = { submit() }), + modifier = Modifier.fillMaxWidth(), + ) + error?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + if (busy) { + CircularProgressIndicator() + } else { + Button(onClick = { submit() }, enabled = passphrase.isNotEmpty()) { + Text("Unlock") + } + } + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/MantraDatabaseJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/MantraDatabaseJvmTest.kt new file mode 100644 index 00000000..4802f5da --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/MantraDatabaseJvmTest.kt @@ -0,0 +1,178 @@ +package press.mantra.compose.database + +import androidx.room3.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.runBlocking +import press.mantra.compose.PlatformContext +import press.mantra.compose.database.builder.PlatformDatabaseBuilder +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The first thing to actually open this database anywhere but android. + * + * Everything else about the jvm target is checked by the compiler, and the compiler cannot + * see any of what this covers: that Room's KSP output for the jvm target is usable, that the + * bundled SQLite native for the *host* loads (the android artifact ships only android-ABI + * `.so` files and would fail here), that the schema is created, and that the DAO methods + * turned `suspend` for this target still read back what they wrote. + * + * Room's own guidance recommends exactly this shape -- an in-memory database on the host + * rather than an instrumented test on a device -- and the no-`Context` builder overload it + * uses is available here because `MantraDatabaseConstructor` gives Room a way to instantiate + * without reflection. + */ +class MantraDatabaseJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + + @AfterTest + fun closeDb() = db.close() + + /** + * `Profile.nostrEventId` is a foreign key onto `NostrEvent`, so the parent row has to + * exist first. The first version of this test skipped that and every write failed with + * SQLite error 787 -- which is worth noting rather than tidying away, because it is + * evidence that foreign keys really are enforced on this platform. A schema whose + * constraints were quietly off would have let all of it pass. + */ + private suspend fun seedProfile(publicKey: String, userName: String = "tester"): Profile { + val nostrEventId = "1$publicKey".take(64) + db.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = publicKey, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = "0".repeat(128), + ) + ) + return Profile(publicKey = publicKey, userName = userName, nostrEventId = nostrEventId) + } + + @Test + fun `the database opens and the schema exists`() = runBlocking { + // getAllProfiles against an empty database exercises the generated query path + // without depending on anything having been written first. + assertTrue(db.profileDao().getAllProfiles().isEmpty()) + } + + @Test + fun `a profile survives a write and a read`() = runBlocking { + val publicKey = "a".repeat(64) + db.profileDao().upsert(seedProfile(publicKey)) + + val found = db.profileDao().getProfileByPublicKey(publicKey) + assertNotNull(found, "the profile just written could not be read back") + assertEquals("tester", found.userName) + } + + @Test + fun `upsert replaces rather than duplicating`() = runBlocking { + val publicKey = "b".repeat(64) + db.profileDao().upsert(seedProfile(publicKey)) + db.profileDao().upsert(seedProfile(publicKey, userName = "renamed")) + + assertEquals("renamed", db.profileDao().getProfileByPublicKey(publicKey)?.userName) + assertEquals(1, db.profileDao().getProfileByPublicKeys(listOf(publicKey)).size) + } + + /** + * `findChatRoomById` is `@Transaction`-annotated and returns a relation object, so it + * exercises rather more of the generated code than a flat entity query does -- and it is + * one of the methods this target forced from blocking to `suspend`. + */ + @Test + fun `a chat room reads back through its relation query`() = runBlocking { + val userPublicKey = "c".repeat(64) + val chatRoomId = "d".repeat(64) + db.profileDao().upsert(seedProfile(userPublicKey)) + db.chatRoomDao().upsert( + ChatRoom( + id = chatRoomId, + userPublicKey = userPublicKey, + subject = "a subject", + description = null, + mlsGroupState = null, + ) + ) + + val found = db.chatRoomDao().findChatRoomById(chatRoomId) + assertNotNull(found, "the chat room just written could not be read back") + assertEquals(1, db.chatRoomDao().getChatRoomListByUserPublicKey(userPublicKey).size) + } + + /** Every read query in this schema filters on `deletedAt IS NULL`. */ + @Test + fun `a soft deleted chat room stops being found`() = runBlocking { + val userPublicKey = "e".repeat(64) + val chatRoomId = "f".repeat(64) + db.profileDao().upsert(seedProfile(userPublicKey)) + val room = ChatRoom( + id = chatRoomId, + userPublicKey = userPublicKey, + subject = null, + description = null, + mlsGroupState = null, + ) + db.chatRoomDao().upsert(room) + assertNotNull(db.chatRoomDao().findChatRoomById(chatRoomId)) + + db.chatRoomDao().upsert(room.copy(deletedAt = kotlin.time.Clock.System.now())) + assertNull(db.chatRoomDao().findChatRoomById(chatRoomId)) + } + + /** + * The on-disk builder, rather than the in-memory one, so the platform actual written for + * this target is covered too -- including that it puts the file under the context's + * directory instead of the system temporary directory. + */ + @Test + fun `the on-disk builder writes where the context says`() = runBlocking { + val dir = Files.createTempDirectory("mantra-db-test").toFile() + try { + val onDisk = getRoomDatabase( + PlatformDatabaseBuilder.getDatabaseBuilder(PlatformContext(dir)) + ) + try { + val nostrEventId = "9".repeat(64) + onDisk.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = "1".repeat(64), + kind = 0, + tags = emptyArray(), + content = "{}", + sig = "0".repeat(128), + ) + ) + onDisk.profileDao().upsert( + Profile(publicKey = "1".repeat(64), nostrEventId = nostrEventId) + ) + assertNotNull(onDisk.profileDao().getProfileByPublicKey("1".repeat(64))) + } finally { + onDisk.close() + } + assertTrue( + java.io.File(dir, "aux.db").exists(), + "expected the database under the context directory, not java.io.tmpdir", + ) + } finally { + dir.deleteRecursively() + } + } +} diff --git a/docs/jvm-target.md b/docs/jvm-target.md index e5368ddc..334c7129 100644 --- a/docs/jvm-target.md +++ b/docs/jvm-target.md @@ -538,7 +538,21 @@ expect anyway: change; noted because it is not available on all KMP targets and is easy to trip over later. -**Verification:** `./gradlew :composeApp:run`. +`main` also has to **unlock the key store before anything else runs**, which is the +consequence of Phase 3 that lands here. Android and ios have no screen like this +because their OS keystores serve keys without asking; on the jvm the passphrase *is* +the protection, so the window opens onto a gate rather than onto the app. + +**Verification:** `./gradlew :composeApp:run`, and then a jvm test that opens the +database. The run proves the window paints; it does not prove Room works, because the +gate stops before anything touches the database. `MantraDatabaseJvmTest` covers what +the compiler cannot see — that Room's KSP output for this target is usable, that the +**host** SQLite native loads (the android artifact ships only android-ABI `.so` files +and would fail here), that the schema is created, and that the queries forced from +blocking to `suspend` still read back what they wrote. + +Note that a gradle `run` killed with SIGTERM reports `BUILD FAILED` with exit value +143. That is the signal, not the app. --- @@ -553,6 +567,13 @@ expect anyway: | 4 | mantra actuals (16) | 1–2 | 1, 2, 3 | | 5 | desktop entry point + shakeout | 1–2 | 4 | +Phases 0–5 are now implemented. What each phase actually turned out to require, as +opposed to what was predicted here, is recorded in the git history on +`claude/jvm-target-actuals` (the fork) and this branch. The two largest surprises were +not in any phase's description: Room refuses to generate a DAO with blocking methods +for a non-android target (Phase 4), and a passphrase-derived key store needs an unlock +step the `expect` signature cannot express, which lands in Phase 5 rather than 3. + **Roughly one focused week to a launchable desktop build**, assuming Phase 3 takes the passphrase-derived KEK and the build is marked dev-only. Real desktop key storage is separate work that should not be folded into this estimate, and