Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions apps/desktop/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ dependencies {
implementation(project(":libs:locale:public"))
implementation(project(":libs:network:connectivity:public"))
implementation(project(":libs:encryption:chat-cipher"))
implementation(project(":apps:flipcash:shared:device-link"))
implementation(libs.kotlinx.coroutines.swing)
implementation(libs.dagger)
implementation(libs.grpc.okhttp)
Expand All @@ -59,7 +60,7 @@ tasks.withType<Test>().configureEach {
systemProperty("opencode.processLifecycle.autoStart", "false")
}

// Dev-only sign-in phrase (see DevSession), forwarded only when local.properties has one. It goes in
// Dev-only sign-in phrase (see DesktopSession), forwarded only when local.properties has one. It goes in
// the environment, not a -D flag, so it stays out of the process's command line.
val devMnemonic = tryReadProperty(rootProject.rootDir, "flipcash.test.mnemonic", "")
.takeIf { it.isNotBlank() }
Expand All @@ -77,10 +78,13 @@ compose.desktop {

tasks.register<JavaExec>("snapshot") {
group = "application"
description = "Renders the wallet screen off-screen to a PNG (-Pout=<file>)."
description = "Renders the wallet (or, with -Pscreen=link, the link screen) off-screen to a PNG (-Pout=<file>)."
classpath = sourceSets["main"].runtimeClasspath
mainClass.set("com.flipcash.desktop.SnapshotMainKt")
args(providers.gradleProperty("out").getOrElse(layout.buildDirectory.file("wallet.png").get().asFile.path))
args(
providers.gradleProperty("out").getOrElse(layout.buildDirectory.file("wallet.png").get().asFile.path),
providers.gradleProperty("screen").getOrElse("wallet"),
)
}

tasks.withType<JavaExec>().configureEach {
Expand Down
68 changes: 68 additions & 0 deletions apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopApp.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.flipcash.desktop

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.flipcash.app.devicelink.DesktopLinkHost
import com.flipcash.app.devicelink.ui.DesktopLinkScreen
import com.flipcash.app.theme.FlipcashTheme
import com.getcode.theme.CodeTheme
import java.net.Inet4Address
import java.net.NetworkInterface

/** The wallet when signed in; otherwise the link screen, which stores the phone's seed and signs in. */
@Composable
fun DesktopApp(graph: DesktopGraph, signedIn: Boolean) {
var linked by remember { mutableStateOf(signedIn) }
if (linked) {
DesktopWalletContent(graph)
} else {
DesktopLinkContent(graph, onSignedIn = { linked = true })
}
}

@Composable
fun DesktopLinkContent(graph: DesktopGraph, onSignedIn: () -> Unit) {
var attempt by remember { mutableIntStateOf(0) }
var state by remember { mutableStateOf<DesktopLinkHost.State?>(null) }
LaunchedEffect(attempt) {
val host = lanAddress()
if (host == null) {
state = DesktopLinkHost.State.Failed("This computer isn't on a local network")
return@LaunchedEffect
}
DesktopLinkHost(host).link().collect { next ->
state = next
if (next is DesktopLinkHost.State.Linked) {
graph.seedVault.write(next.entropyB64)
graph.signIn(next.entropyB64)
.onSuccess { onSignedIn() }
.onFailure { state = DesktopLinkHost.State.Failed("Linked, but sign-in failed: ${it.message}") }
}
}
}
FlipcashTheme {
Box(Modifier.fillMaxSize().background(CodeTheme.colors.background)) {
DesktopLinkScreen(state, onRetry = { attempt++ })
}
}
}

/**
* The address the phone dials: the first site-local IPv4 address on an interface that is up. The
* one piece of linking that stays in the desktop app, since the phone never needs its own address.
*/
internal fun lanAddress(): String? = NetworkInterface.getNetworkInterfaces().asSequence()
.filter { it.isUp && !it.isLoopback && !it.isVirtual }
.flatMap { it.inetAddresses.asSequence() }
.filterIsInstance<Inet4Address>()
.firstOrNull { it.isSiteLocalAddress }
?.hostAddress
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ interface DesktopGraph {
val analytics: FlipcashAnalytics
val userManager: com.flipcash.services.user.UserManager
val accountController: com.flipcash.services.controllers.AccountController
val seedVault: com.flipcash.app.devicelink.SeedVault

// Android constructs this eagerly in FlipcashApp.onCreate; it is what turns Events.OnLoggedIn into
// SessionListener.onUserLoggedIn calls.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.chat.TipHistorySource
import com.flipcash.app.core.storage.DirectoryPreferencesFiles
import com.flipcash.app.core.storage.PreferencesFiles
import com.flipcash.app.devicelink.KeyringSeedVault
import com.flipcash.app.devicelink.SeedVault
import com.flipcash.app.funding.PurchaseMethod
import com.flipcash.app.funding.PurchaseMethodController
import com.flipcash.app.funding.PurchaseMethodMetadata
Expand Down Expand Up @@ -49,6 +51,10 @@ object DesktopModule {
File(System.getProperty("user.home"), ".flipcash").apply { mkdirs() }
}

@Provides
@Singleton
fun seedVault(): SeedVault = KeyringSeedVault()

@Provides
@Singleton
fun dispatchers(): DispatcherProvider = object : DispatcherProvider {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.flipcash.desktop

import com.flipcash.app.devicelink.SeedVault
import com.flipcash.app.persistence.FlipcashDatabase
import com.flipcash.app.persistence.init
import com.flipcash.services.controllers.AccountController
import com.flipcash.services.user.AuthState
import com.flipcash.services.user.UserManager
import com.getcode.crypt.MnemonicPhrase
import java.io.File

/**
* Signs the desktop in from a seed: the one a phone linked into the [SeedVault], or, for development,
* a recovery phrase in `FLIPCASH_DEV_MNEMONIC` (set by Gradle from `flipcash.test.mnemonic` in
* local.properties, only when present). The dev phrase is never written to the vault or logged.
*
* Kept in the desktop app: [FlipcashDatabase.init] takes a [File], and the app module is JVM-only.
*/
internal object DesktopSession {
private const val VARIABLE = "FLIPCASH_DEV_MNEMONIC"

/** The dev phrase's entropy, or null when none is configured. */
fun devEntropy(): String? {
val words = System.getenv(VARIABLE)?.trim()?.split(Regex("\\s+"))?.takeIf { it.size > 1 } ?: return null
return MnemonicPhrase.newInstance(words)?.getBase64EncodedEntropy()
?: run { println("dev session: $VARIABLE is not a 12 or 24 word phrase"); null }
}

/** The same steps `AuthManager.login` walks for an account that has finished onboarding. */
suspend fun signIn(
entropyB64: String,
userManager: UserManager,
accountController: AccountController,
dataDir: File,
): Result<Unit> {
userManager.establish(entropyB64)
return accountController.login().map { accountId ->
FlipcashDatabase.init(dataDir, entropyB64)
userManager.set(accountId = accountId)
userManager.set(AuthState.Ready)
}
}
}

internal suspend fun DesktopGraph.signIn(entropyB64: String): Result<Unit> =
DesktopSession.signIn(entropyB64, userManager, accountController, DesktopModule.appDir)

/**
* Signs in at launch with the dev phrase if one is configured, else with the seed a phone linked
* earlier. Returns false when neither is there, and the app shows the link screen. Run [startup] first.
*/
internal suspend fun DesktopGraph.restoreSession(): Boolean {
DesktopSession.devEntropy()?.let { entropy ->
return signIn(entropy)
.onSuccess { println("session: signed in (dev phrase)") }
.onFailure { println("session: dev sign-in failed (${it.message})") }
.isSuccess
}
val stored = seedVault.read() ?: return false.also { println("session: not linked") }
return signIn(stored)
.onSuccess { println("session: signed in (linked seed)") }
.onFailure { println("session: linked sign-in failed (${it.message})") }
.isSuccess
}
45 changes: 0 additions & 45 deletions apps/desktop/src/main/kotlin/com/flipcash/desktop/DevSession.kt

This file was deleted.

6 changes: 3 additions & 3 deletions apps/desktop/src/main/kotlin/com/flipcash/desktop/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,17 @@ import java.lang.management.ManagementFactory

fun main() {
val graph = createGraph<DesktopGraph>()
runBlocking {
val signedIn = runBlocking {
graph.startup()
graph.devSignIn()
graph.restoreSession()
}
application {
Window(
onCloseRequest = ::exitApplication,
title = "Flipcash",
state = rememberWindowState(width = 420.dp, height = 860.dp),
) {
DesktopWalletContent(graph)
DesktopApp(graph, signedIn)
LaunchedEffect(Unit) {
val sinceJvmStart = System.currentTimeMillis() -
ManagementFactory.getRuntimeMXBean().startTime
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,19 @@ import org.jetbrains.skia.EncodedImageFormat

/**
* Renders the wallet screen off-screen through the same graph as [main] and writes a PNG.
* Usage: `./gradlew :apps:desktop:snapshot -Pout=<file>`.
* Usage: `./gradlew :apps:desktop:snapshot -Pout=<file>`; add `-Pscreen=link` for the link screen,
* which skips sign-in.
*/
fun main(args: Array<String>) {
val out = File(args.firstOrNull() ?: "wallet.png")
val linkScreen = args.getOrNull(1) == "link"
val graph = createGraph<DesktopGraph>()
runBlocking {
graph.startup()
graph.devSignIn()
if (!linkScreen) graph.restoreSession()
}
ImageComposeScene(width = 840, height = 1720, density = Density(2f)) {
DesktopWalletContent(graph)
if (linkScreen) DesktopLinkContent(graph, onSignedIn = {}) else DesktopWalletContent(graph)
}.use { scene ->
var nanos = 0L
repeat(80) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.scene.OverlayScene
import androidx.navigation3.scene.SinglePaneSceneStrategy
import com.flipcash.app.devicelogs.DeviceLogsScreen
import com.flipcash.app.myaccount.LinkDesktopScreen
import com.flipcash.app.purchase.OnboardingPurchaseStep
import com.flipcash.app.backupkey.BackupKeyScreen
import com.flipcash.app.balance.WalletScreen
import com.flipcash.app.cash.CashScreen
Expand Down Expand Up @@ -102,7 +104,11 @@ internal fun appEntryProvider(

// Onboarding flow
annotatedEntry<AppRoute.OnboardingFlow> { key ->
OnboardingFlowScreen(route = key, resultStateRegistry = resultStateRegistry)
OnboardingFlowScreen(
route = key,
resultStateRegistry = resultStateRegistry,
purchaseStep = { onAccountCreated -> OnboardingPurchaseStep(onAccountCreated) },
)
}

// Main
Expand Down Expand Up @@ -219,6 +225,7 @@ internal fun appEntryProvider(
annotatedEntry<AppRoute.Menu.TrustedWebsites> { TrustedWebsitesScreen() }
annotatedEntry<AppRoute.Menu.BackupKey> { BackupKeyScreen() }
annotatedEntry<AppRoute.Menu.DeviceLogs> { DeviceLogsScreen() }
annotatedEntry<AppRoute.Menu.LinkDesktop> { LinkDesktopScreen() }
annotatedEntry<AppRoute.Menu.AccountSelection> { AccountSelectionScreen() }

annotatedEntry<AppRoute.UserFlags> { UserFlagsScreen() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,8 @@ sealed interface AppRoute : NavKey, Parcelable {
@Serializable
data object DeviceLogs : Menu
@Serializable
data object LinkDesktop : Menu
@Serializable
data object AccountSelection : Menu
@Serializable
data object UserProfile : Menu
Expand Down
1 change: 0 additions & 1 deletion apps/flipcash/features/login/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ dependencies {
implementation(project(":apps:flipcash:shared:featureflags"))
implementation(project(":apps:flipcash:shared:permissions"))
implementation(project(":apps:flipcash:shared:userflags"))
implementation(project(":apps:flipcash:features:purchase"))
implementation(project(":libs:datetime"))
implementation(project(":libs:messaging"))
implementation(project(":libs:permissions:bindings"))
Expand Down
Loading
Loading