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