diff --git a/AGENTS.md b/AGENTS.md index b097b0883..2eaa131ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,9 @@ The role of this file is to describe common mistakes and confusion points that a - Use `cove_util::ResultExt::map_err_prefix` instead of `.map_err(|e| Error::Variant(format!("context: {e}")))` when the prefix is a static string — produces `"context: error_message"` - For long-lived UI-facing managers, prefer `dispatch(action:)` for user intents and keep named methods for reads, bootstrap/lifecycle hooks, and special service-style operations, use `state()` for the initial snapshot only, and send typed delta reconcile messages for ongoing UI updates instead of re-sending the whole state after every mutation - Prefer scoped blocks to release locks or borrows instead of explicit `drop(...)` unless explicit `drop` is actually needed +- Add blank lines between logical steps in function bodies across Rust, Swift, and Kotlin: after setup/result bindings before new control flow, after multi-line `if`/`match`/`guard`/`do`/`Task` blocks before the next independent statement, between state mutations and final returns, and between multi-line `match`/`switch` arms. Keep tightly related consecutive assignments together +- Put explanatory comments immediately above the statement or arm they describe, separated from the previous step by a blank line - never use `pub(in ...)` or `pub(super)`; if non-private visibility is needed, use `pub(crate)` or `pub` - never manually edit generated files - no mod.rs files use the other format module_name.rs module_name/new_module.rs +- generated UniFFI Kotlin enum readers have a tight ordinal contract with Rust enum variant order, for example `AppAction` ordinals in the generated `FfiConverterTypeAppAction` reader; never reorder, insert, or remove Rust variants without regenerating bindings and updating the generated checksum, because stale generated files can map ordinals like `SelectWallet`, `SelectLatestOrNewWallet`, and `ChangeNetwork` to the wrong action diff --git a/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt b/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt index 71a41d838..e3dd36fb2 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import org.bitcoinppl.cove.cloudbackup.CloudBackupManager @@ -29,6 +30,8 @@ class AppManager private constructor() : FfiReconcile { // Scope for UI-bound work; reconcile() hops to Main here private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private var navigationGeneration = 0L + private var pendingSidebarNavigationJob: Job? = null // rust bridge - not observable internal var rust: FfiApp = FfiApp() @@ -195,6 +198,10 @@ class AppManager private constructor() : FfiReconcile { * clears all cached data and reinitializes */ fun reset() { + pendingSidebarNavigationJob?.cancel() + pendingSidebarNavigationJob = null + advanceNavigationGeneration() + // close managers before clearing them walletManager?.close() sendFlowManager?.close() @@ -221,13 +228,39 @@ class AppManager private constructor() : FfiReconcile { */ fun selectWallet(id: WalletId) { try { - rust.selectWallet(id) - isSidebarVisible = false + selectWalletOrThrow(id) } catch (e: Exception) { Log.e(tag, "Unable to select wallet $id", e) } } + @Throws(Exception::class) + fun selectWalletOrThrow(id: WalletId) { + advanceNavigationGeneration() + selectWalletWithoutNavigationGeneration(id) + } + + @Throws(Exception::class) + private fun selectWalletWithoutNavigationGeneration(id: WalletId) { + rust.dispatch(AppAction.SelectWallet(id)) + isSidebarVisible = false + } + + fun trySelectLatestOrNewWallet() { + try { + selectLatestOrNewWallet() + } catch (e: Exception) { + Log.e(tag, "Unable to select latest wallet", e) + } + } + + @Throws(Exception::class) + fun selectLatestOrNewWallet() { + advanceNavigationGeneration() + rust.dispatch(AppAction.SelectLatestOrNewWallet) + isSidebarVisible = false + } + fun toggleSidebar() { isSidebarVisible = !isSidebarVisible } @@ -236,15 +269,55 @@ class AppManager private constructor() : FfiReconcile { wallets = runCatching { database.wallets().all() }.getOrElse { emptyList() } } - fun closeSidebarAndNavigate(action: suspend () -> Unit) { + fun closeSidebarAndSelectWallet(id: WalletId) { + closeSidebarThenNavigate { + try { + selectWalletWithoutNavigationGeneration(id) + } catch (e: Exception) { + Log.e(tag, "Unable to select wallet $id", e) + } + } + } + + fun closeSidebarAndOpenNewWallet() { + closeSidebarThenNavigate { + if (wallets.isEmpty()) { + resetRouteWithoutNavigationGeneration(RouteFactory().newWalletSelect()) + } else { + pushRouteWithoutNavigationGeneration(RouteFactory().newWalletSelect()) + } + } + } + + fun closeSidebarAndOpenSettings() { + closeSidebarThenNavigate { + pushRouteWithoutNavigationGeneration(Route.Settings(SettingsRoute.Main)) + } + } + + fun closeSidebarAndScanNfc() { + closeSidebarThenNavigate { + scanNfcWithoutNavigationGeneration() + } + } + + private fun closeSidebarThenNavigate(action: suspend () -> Unit) { + pendingSidebarNavigationJob?.cancel() + val generation = advanceNavigationGeneration() isSidebarVisible = false - mainScope.launch { + pendingSidebarNavigationJob = mainScope.launch { kotlinx.coroutines.delay(SIDEBAR_NAVIGATION_DELAY_MS) + if (!isNavigationGenerationCurrent(generation)) return@launch action() } } fun pushRoute(route: Route) { + advanceNavigationGeneration() + pushRouteWithoutNavigationGeneration(route) + } + + private fun pushRouteWithoutNavigationGeneration(route: Route) { Log.d(tag, "pushRoute: $route") isSidebarVisible = false val newRoutes = router.routes.toMutableList().apply { add(route) } @@ -257,6 +330,11 @@ class AppManager private constructor() : FfiReconcile { } fun pushRoutes(routes: List) { + advanceNavigationGeneration() + pushRoutesWithoutNavigationGeneration(routes) + } + + private fun pushRoutesWithoutNavigationGeneration(routes: List) { Log.d(tag, "pushRoutes: ${routes.size} routes") isSidebarVisible = false val newRoutes = router.routes.toMutableList().apply { addAll(routes) } @@ -269,6 +347,7 @@ class AppManager private constructor() : FfiReconcile { } fun popRoute() { + advanceNavigationGeneration() Log.d(tag, "popRoute") if (rust.canGoBack()) { val newRoutes = router.routes.dropLast(1) @@ -282,6 +361,7 @@ class AppManager private constructor() : FfiReconcile { } fun setRoute(routes: List) { + advanceNavigationGeneration() Log.d(tag, "setRoute: ${routes.size} routes") // only dispatch if routes actually changed @@ -292,14 +372,25 @@ class AppManager private constructor() : FfiReconcile { } fun scanQr() { + advanceNavigationGeneration() sheetState = TaggedItem(AppSheetState.Qr) } fun scanNfc() { + advanceNavigationGeneration() + scanNfcWithoutNavigationGeneration() + } + + private fun scanNfcWithoutNavigationGeneration() { sheetState = TaggedItem(AppSheetState.Nfc) } fun resetRoute(to: List) { + advanceNavigationGeneration() + resetRouteWithoutNavigationGeneration(to) + } + + private fun resetRouteWithoutNavigationGeneration(to: List) { if (to.size > 1) { rust.resetNestedRoutesTo(to[0], to.drop(1)) } else if (to.isNotEmpty()) { @@ -308,13 +399,38 @@ class AppManager private constructor() : FfiReconcile { } fun resetRoute(to: Route) { + advanceNavigationGeneration() + resetRouteWithoutNavigationGeneration(to) + } + + private fun resetRouteWithoutNavigationGeneration(to: Route) { rust.resetDefaultRouteTo(to) } fun loadAndReset(to: Route) { + advanceNavigationGeneration() rust.loadAndResetDefaultRoute(to) } + fun captureLoadAndResetGeneration(): Long = navigationGeneration + + fun resetAfterLoadingIfCurrent( + generation: Long, + route: Route.LoadAndReset, + nextRoutes: List, + ) { + if (!isNavigationGenerationCurrent(generation)) return + if (router.default != route) return + rust.resetAfterLoading(nextRoutes) + } + + private fun advanceNavigationGeneration(): Long { + navigationGeneration += 1 + return navigationGeneration + } + + private fun isNavigationGenerationCurrent(generation: Long): Boolean = generation == navigationGeneration + fun agreeToTerms() { dispatch(AppAction.AcceptTerms) isTermsAccepted = true @@ -413,7 +529,8 @@ class AppManager private constructor() : FfiReconcile { fun dispatch(action: AppAction) { Log.d(tag, "dispatch $action") - rust.dispatch(action) + runCatching { rust.dispatch(action) } + .onFailure { Log.e(tag, "Unable to dispatch app action $action", it) } } companion object { @@ -425,7 +542,7 @@ class AppManager private constructor() : FfiReconcile { * * allows sidebar dismiss animation to complete to avoid visual jump */ - private const val SIDEBAR_NAVIGATION_DELAY_MS = 300L + private const val SIDEBAR_NAVIGATION_DELAY_MS = 250L /** * minimum loading indicator visibility duration diff --git a/android/app/src/main/java/org/bitcoinppl/cove/AuthManager.kt b/android/app/src/main/java/org/bitcoinppl/cove/AuthManager.kt index 8385e0d89..8012bd709 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/AuthManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/AuthManager.kt @@ -137,7 +137,7 @@ class AuthManager private constructor() : AuthManagerReconciler { app.isLoading = true // select the latest (most recently used) wallet or navigate to new wallet flow - app.rust.selectLatestOrNewWallet() + app.trySelectLatestOrNewWallet() } /** diff --git a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt index 380c7b3ef..dd1cf8306 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt @@ -639,7 +639,7 @@ private fun GlobalAlertDialog( TextButton(onClick = { onDismiss() try { - app.rust.selectWallet(state.walletId) + app.selectWalletOrThrow(state.walletId) app.resetRoute(Route.SelectedWallet(state.walletId)) } catch (e: Exception) { Log.e("GlobalAlert", "Failed to select wallet", e) @@ -819,12 +819,13 @@ private fun GlobalAlertDialog( text = { Text(state.message()) }, confirmButton = { TextButton(onClick = { - onDismiss() try { - app.rust.selectWallet(state.walletId) + app.selectWalletOrThrow(state.walletId) + onDismiss() app.resetRoute(Route.SelectedWallet(state.walletId)) } catch (e: Exception) { Log.e("GlobalAlert", "Failed to select wallet", e) + app.alertState = TaggedItem(AppAlertState.UnableToSelectWallet) } }) { Text("Yes") } }, @@ -994,7 +995,7 @@ private fun GlobalAlertDialog( try { Wallet.newFromXpub(xpub = text.trim()).use { wallet -> val id = wallet.id() - app.rust.selectWallet(id) + app.selectWalletOrThrow(id) app.resetRoute(Route.SelectedWallet(id)) } } catch (e: Exception) { @@ -1055,7 +1056,7 @@ private fun GlobalAlertDialog( } TextButton(onClick = { onDismiss() - app.rust.selectLatestOrNewWallet() + app.trySelectLatestOrNewWallet() }) { Text("Cancel") } } }, diff --git a/android/app/src/main/java/org/bitcoinppl/cove/RouteView.kt b/android/app/src/main/java/org/bitcoinppl/cove/RouteView.kt index 8e9fa5444..9eaa18adc 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/RouteView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/RouteView.kt @@ -77,8 +77,7 @@ fun RouteView(app: AppManager, route: Route) { is Route.LoadAndReset -> { LoadAndResetContainer( app = app, - nextRoutes = route.resetTo.map { it.route() }, - loadingTimeMs = route.afterMillis.toLong(), + route = route, ) } } @@ -92,24 +91,21 @@ fun RouteView(app: AppManager, route: Route) { @Composable private fun LoadAndResetContainer( app: AppManager, - nextRoutes: List, - loadingTimeMs: Long, + route: Route.LoadAndReset, ) { + val nextRoutes = route.resetTo.map { it.route() } + val loadingTimeMs = route.afterMillis.toLong() + // show loading indicator Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } // execute reset after delay - LaunchedEffect(Unit) { + LaunchedEffect(route) { + val generation = app.captureLoadAndResetGeneration() delay(loadingTimeMs) - if (nextRoutes.size > 1) { - // nested routes: first route is default, rest are nested - app.resetRoute(nextRoutes) - } else if (nextRoutes.isNotEmpty()) { - // single route becomes new default - app.resetRoute(nextRoutes[0]) - } + app.resetAfterLoadingIfCurrent(generation, route, nextRoutes) } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/ScanManager.kt b/android/app/src/main/java/org/bitcoinppl/cove/ScanManager.kt index 8ea3503d1..235360cde 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/ScanManager.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/ScanManager.kt @@ -100,7 +100,7 @@ class ScanManager private constructor() { val manager = ImportWalletManager() try { val walletMetadata = manager.rust.importWallet(listOf(words)) - app.rust.selectWallet(walletMetadata.id) + app.selectWalletOrThrow(walletMetadata.id) } catch (e: ImportWalletException.InvalidWordGroup) { Log.d(tag, "Invalid word group detected") app.alertState = TaggedItem(AppAlertState.InvalidWordGroup) @@ -108,7 +108,7 @@ class ScanManager private constructor() { Log.w(tag, "Attempted to import words for an existing hot wallet: ${e.v1}") app.alertState = TaggedItem(AppAlertState.DuplicateWallet(e.v1)) try { - app.rust.selectWallet(e.v1) + app.selectWalletOrThrow(e.v1) } catch (selectError: Exception) { Log.e(tag, "Unable to select existing wallet", selectError) } @@ -132,7 +132,7 @@ class ScanManager private constructor() { app.alertState = TaggedItem(AppAlertState.ImportedSuccessfully) if (app.walletManager?.id != id) { - app.rust.selectWallet(id) + app.selectWalletOrThrow(id) } if (app.walletManager?.id == id && app.walletManager?.walletMetadata?.walletType != WalletType.HOT) { @@ -148,7 +148,7 @@ class ScanManager private constructor() { } catch (e: WalletException.WalletAlreadyExists) { app.alertState = TaggedItem(AppAlertState.DuplicateWallet(e.v1)) try { - app.rust.selectWallet(e.v1) + app.selectWalletOrThrow(e.v1) } catch (selectError: Exception) { Log.e(tag, "Unable to select existing wallet", selectError) app.alertState = TaggedItem(AppAlertState.UnableToSelectWallet) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/NewWalletSelectScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/NewWalletSelectScreen.kt index 300cc9526..4b3915f3e 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/NewWalletSelectScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/NewWalletSelectScreen.kt @@ -115,7 +115,7 @@ fun NewWalletSelectScreen( val id = wallet.id() android.util.Log.d("NewWalletSelectScreen", "Imported Wallet: $id") - app.rust.selectWallet(id = id) + app.selectWalletOrThrow(id) app.popRoute() app.alertState = TaggedItem(AppAlertState.ImportedSuccessfully) } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/cold_wallet/ColdWalletQrScanScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/cold_wallet/ColdWalletQrScanScreen.kt index 10b5c82bb..a5df14ec7 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/cold_wallet/ColdWalletQrScanScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/cold_wallet/ColdWalletQrScanScreen.kt @@ -81,7 +81,7 @@ fun ColdWalletQrScanScreen(app: AppManager, modifier: Modifier = Modifier) { Log.d("ColdWalletQrScanScreen", "Imported Wallet: $id") app.popRoute() - app.rust.selectWallet(id = id) + app.selectWalletOrThrow(id) app.alertState = TaggedItem(AppAlertState.ImportedSuccessfully) } catch (e: WalletException.WalletAlreadyExists) { app.popRoute() diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/cold_wallet/QrCodeImportScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/cold_wallet/QrCodeImportScreen.kt index 7dafa568e..e365bbc0a 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/cold_wallet/QrCodeImportScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/cold_wallet/QrCodeImportScreen.kt @@ -69,7 +69,7 @@ fun QrCodeImportScreen(app: AppManager, modifier: Modifier = Modifier) { wallet.close() Log.d("QrCodeImportScreen", "Imported Wallet: $id") - app.rust.selectWallet(id = id) + app.selectWalletOrThrow(id) app.popRoute() app.alertState = TaggedItem(AppAlertState.ImportedSuccessfully) } catch (e: WalletException.WalletAlreadyExists) { diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/hot_wallet/HotWalletImportScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/hot_wallet/HotWalletImportScreen.kt index 3b42a2ae8..ea71209d2 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/hot_wallet/HotWalletImportScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/NewWalletFlow/hot_wallet/HotWalletImportScreen.kt @@ -276,10 +276,8 @@ fun HotWalletImportScreen( try { val walletMetadata = manager.importWallet(enteredWords) app.clearWalletManager() - if (onImported != null) { - onImported.invoke(walletMetadata.id) - } else { - app.rust.selectWallet(walletMetadata.id) + onImported?.invoke(walletMetadata.id) ?: run { + app.selectWalletOrThrow(walletMetadata.id) app.resetRoute(Route.SelectedWallet(walletMetadata.id)) } } catch (e: ImportWalletException.InvalidWordGroup) { @@ -520,7 +518,7 @@ fun HotWalletImportScreen( app.clearWalletManager() manager.close() onImported?.invoke(walletId) ?: run { - app.rust.selectWallet(walletId) + app.selectWalletOrThrow(walletId) app.resetRoute(Route.SelectedWallet(walletId)) } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletContainer.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletContainer.kt index da48618d9..5ed10714d 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletContainer.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletContainer.kt @@ -91,7 +91,7 @@ fun SelectedWalletContainer( val otherWallet = wallets.firstOrNull { it.id != id } if (otherWallet != null) { - app.rust.selectWallet(otherWallet.id) + app.selectWalletOrThrow(otherWallet.id) } else { app.loadAndReset(RouteFactory().newWalletSelect()) } @@ -103,14 +103,13 @@ fun SelectedWalletContainer( // start wallet scan after loading (matches iOS .task) LaunchedEffect(manager) { - manager?.let { wm -> - try { - wm.rust.startWalletScan() - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - android.util.Log.e(tag, "wallet scan failed: ${e.message}", e) - } + val wm = manager ?: return@LaunchedEffect + try { + wm.rust.startWalletScan() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + android.util.Log.e(tag, "wallet scan failed: ${e.message}", e) } } @@ -122,10 +121,11 @@ fun SelectedWalletContainer( } // update app wallet manager when loaded - LaunchedEffect(manager?.loadState) { - val loadState = manager?.loadState - if (loadState is WalletLoadState.LOADED) { - manager?.let { app.setWalletManager(it) } + val loadedManager = manager + val loadState = loadedManager?.loadState + LaunchedEffect(loadedManager, loadState) { + if (loadedManager != null && loadState is WalletLoadState.LOADED) { + app.setWalletManager(loadedManager) } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletScreen.kt index 1c60e126c..838b277d3 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/SelectedWalletScreen.kt @@ -191,7 +191,7 @@ fun SelectedWalletScreen( } }, colors = - TopAppBarDefaults.centerAlignedTopAppBarColors( + TopAppBarDefaults.topAppBarColors( // gradual fade from transparent to midnight blue based on scroll progress containerColor = CoveColor.midnightBlue.copy(alpha = scrollProgress), titleContentColor = Color.White, diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt index 85bf78df8..1cef5ce8d 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt @@ -322,7 +322,7 @@ fun TransactionDetailsScreen( topBar = { CenterAlignedTopAppBar( colors = - TopAppBarDefaults.centerAlignedTopAppBarColors( + TopAppBarDefaults.topAppBarColors( containerColor = Color.Transparent, titleContentColor = fg, actionIconContentColor = fg, diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionsCardView.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionsCardView.kt index 13f91068b..ed27a3553 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionsCardView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionsCardView.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset @@ -61,16 +62,20 @@ import org.bitcoinppl.cove.ui.theme.isLight import org.bitcoinppl.cove.views.AutoSizeText import org.bitcoinppl.cove_core.AppAlertState import org.bitcoinppl.cove_core.FiatOrBtc +import org.bitcoinppl.cove_core.FiatAmount import org.bitcoinppl.cove_core.Route import org.bitcoinppl.cove_core.RouteFactory import org.bitcoinppl.cove_core.Transaction import org.bitcoinppl.cove_core.UnsignedTransaction +import org.bitcoinppl.cove_core.types.SentAndReceived import org.bitcoinppl.cove_core.types.TransactionDirection private const val SCROLL_THRESHOLD_INDEX = 5 enum class TransactionType { SENT, RECEIVED } +private enum class AmountPosition { PRIMARY, SECONDARY } + /** * Displays the list of transactions with a header * @@ -85,8 +90,8 @@ fun TransactionsCardView( fiatOrBtc: FiatOrBtc, sensitiveVisible: Boolean, showLabels: Boolean, - manager: WalletManager?, - app: AppManager?, + manager: WalletManager, + app: AppManager, listState: LazyListState = rememberLazyListState(), modifier: Modifier = Modifier, ) { @@ -104,8 +109,8 @@ fun TransactionsCardView( } // scroll to saved transaction when returning from details - LaunchedEffect(manager?.scrolledTransactionId, hasTransactions, transactions, unsignedTransactions) { - val targetId = manager?.scrolledTransactionId ?: return@LaunchedEffect + LaunchedEffect(manager.scrolledTransactionId, hasTransactions, transactions, unsignedTransactions) { + val targetId = manager.scrolledTransactionId ?: return@LaunchedEffect if (!hasTransactions) return@LaunchedEffect // find the index of the transaction with the matching ID @@ -268,8 +273,8 @@ fun TransactionsCardView( internal fun TransactionItem( txn: Transaction, index: Int, - manager: WalletManager?, - app: AppManager?, + manager: WalletManager, + app: AppManager, fiatOrBtc: FiatOrBtc, showLabels: Boolean, sensitiveVisible: Boolean, @@ -297,39 +302,10 @@ internal fun TransactionItem( ) } - val formattedAmount: String = - manager?.let { - when (fiatOrBtc) { - FiatOrBtc.BTC -> it.rust.displaySentAndReceivedAmount(txn.v1.sentAndReceived()) - FiatOrBtc.FIAT -> { - val fiatAmount = txn.v1.fiatAmount() - if (fiatAmount != null) { - it.rust.displayFiatAmountWithDirection(fiatAmount.amount, direction) - } else { - "---" - } - } - } - } ?: txn.v1.sentAndReceived().label() - - val secondaryAmount: String = - manager?.let { - when (fiatOrBtc) { - FiatOrBtc.BTC -> { - // primary is BTC, secondary is fiat - val fiatAmount = txn.v1.fiatAmount() - if (fiatAmount != null) { - it.rust.displayFiatAmountWithDirection(fiatAmount.amount, direction) - } else { - "---" - } - } - FiatOrBtc.FIAT -> { - // primary is fiat, secondary is BTC - it.rust.displaySentAndReceivedAmount(txn.v1.sentAndReceived()) - } - } - } ?: "---" + val formattedAmount = + formatAmountFor(fiatOrBtc, AmountPosition.PRIMARY, txn.v1.sentAndReceived(), txn.v1.fiatAmount(), direction, manager) + val secondaryAmount = + formatAmountFor(fiatOrBtc, AmountPosition.SECONDARY, txn.v1.sentAndReceived(), txn.v1.fiatAmount(), direction, manager) ConfirmedTransactionWidget( type = txType, @@ -367,39 +343,10 @@ internal fun TransactionItem( ) } - val formattedAmount: String = - manager?.let { - when (fiatOrBtc) { - FiatOrBtc.BTC -> it.rust.displaySentAndReceivedAmount(txn.v1.sentAndReceived()) - FiatOrBtc.FIAT -> { - val fiatAmount = txn.v1.fiatAmount() - if (fiatAmount != null) { - it.rust.displayFiatAmountWithDirection(fiatAmount.amount, direction) - } else { - "---" - } - } - } - } ?: txn.v1.sentAndReceived().label() - - val secondaryAmount: String = - manager?.let { - when (fiatOrBtc) { - FiatOrBtc.BTC -> { - // primary is BTC, secondary is fiat - val fiatAmount = txn.v1.fiatAmount() - if (fiatAmount != null) { - it.rust.displayFiatAmountWithDirection(fiatAmount.amount, direction) - } else { - "---" - } - } - FiatOrBtc.FIAT -> { - // primary is fiat, secondary is BTC - it.rust.displaySentAndReceivedAmount(txn.v1.sentAndReceived()) - } - } - } ?: "---" + val formattedAmount = + formatAmountFor(fiatOrBtc, AmountPosition.PRIMARY, txn.v1.sentAndReceived(), txn.v1.fiatAmount(), direction, manager) + val secondaryAmount = + formatAmountFor(fiatOrBtc, AmountPosition.SECONDARY, txn.v1.sentAndReceived(), txn.v1.fiatAmount(), direction, manager) UnconfirmedTransactionWidget( type = txType, @@ -418,6 +365,27 @@ internal fun TransactionItem( } } +private fun formatAmountFor( + fiatOrBtc: FiatOrBtc, + position: AmountPosition, + sentAndReceived: SentAndReceived, + fiatAmount: FiatAmount?, + direction: TransactionDirection, + manager: WalletManager, +): String { + val showFiat = + when (position) { + AmountPosition.PRIMARY -> fiatOrBtc == FiatOrBtc.FIAT + AmountPosition.SECONDARY -> fiatOrBtc == FiatOrBtc.BTC + } + + return if (showFiat) { + fiatAmount?.let { manager.rust.displayFiatAmountWithDirection(it.amount, direction) } ?: "---" + } else { + manager.rust.displaySentAndReceivedAmount(sentAndReceived) + } +} + @Composable internal fun ConfirmedTransactionWidget( type: TransactionType, @@ -429,8 +397,8 @@ internal fun ConfirmedTransactionWidget( primaryText: Color, secondaryText: Color, transaction: Transaction.Confirmed, - app: AppManager?, - manager: WalletManager?, + app: AppManager, + manager: WalletManager, sensitiveVisible: Boolean, ) { val scope = rememberCoroutineScope() @@ -448,20 +416,18 @@ internal fun ConfirmedTransactionWidget( .fillMaxWidth() .padding(vertical = 6.dp) .clickable { - if (app != null && manager != null) { - scope.launch { - try { - val details = manager.transactionDetails(transaction.v1.id()) - val walletId = manager.walletMetadata?.id - if (walletId != null) { - if (index > SCROLL_THRESHOLD_INDEX) { - manager.pendingScrollTransactionId = transaction.v1.id().toString() - } - app.pushRoute(Route.TransactionDetails(walletId, details)) + scope.launch { + try { + val details = manager.transactionDetails(transaction.v1.id()) + val walletId = manager.walletMetadata?.id + if (walletId != null) { + if (index > SCROLL_THRESHOLD_INDEX) { + manager.pendingScrollTransactionId = transaction.v1.id().toString() } - } catch (e: Exception) { - android.util.Log.e("ConfirmedTxWidget", "Failed to load transaction details", e) + app.pushRoute(Route.TransactionDetails(walletId, details)) } + } catch (e: Exception) { + android.util.Log.e("ConfirmedTxWidget", "Failed to load transaction details", e) } } }, @@ -537,8 +503,8 @@ internal fun UnconfirmedTransactionWidget( primaryText: Color, secondaryText: Color, transaction: Transaction.Unconfirmed, - app: AppManager?, - manager: WalletManager?, + app: AppManager, + manager: WalletManager, sensitiveVisible: Boolean, ) { val scope = rememberCoroutineScope() @@ -555,20 +521,18 @@ internal fun UnconfirmedTransactionWidget( .fillMaxWidth() .padding(vertical = 6.dp) .clickable { - if (app != null && manager != null) { - scope.launch { - try { - val details = manager.transactionDetails(transaction.v1.id()) - val walletId = manager.walletMetadata?.id - if (walletId != null) { - if (index > SCROLL_THRESHOLD_INDEX) { - manager.pendingScrollTransactionId = transaction.v1.id().toString() - } - app.pushRoute(Route.TransactionDetails(walletId, details)) + scope.launch { + try { + val details = manager.transactionDetails(transaction.v1.id()) + val walletId = manager.walletMetadata?.id + if (walletId != null) { + if (index > SCROLL_THRESHOLD_INDEX) { + manager.pendingScrollTransactionId = transaction.v1.id().toString() } - } catch (e: Exception) { - android.util.Log.e("UnconfirmedTxWidget", "Failed to load transaction details", e) + app.pushRoute(Route.TransactionDetails(walletId, details)) } + } catch (e: Exception) { + android.util.Log.e("UnconfirmedTxWidget", "Failed to load transaction details", e) } } }, @@ -636,8 +600,8 @@ internal fun UnsignedTransactionWidget( index: Int, primaryText: Color, secondaryText: Color, - app: AppManager?, - manager: WalletManager?, + app: AppManager, + manager: WalletManager, fiatOrBtc: FiatOrBtc, sensitiveVisible: Boolean, ) { @@ -650,7 +614,7 @@ internal fun UnsignedTransactionWidget( fiatAmount = null fiatAmount = try { - manager?.rust?.amountInFiat(txn.spendingAmount()) + manager.rust.amountInFiat(txn.spendingAmount()) } catch (e: Exception) { android.util.Log.d("UnsignedTxWidget", "Fiat fetch failed", e) null @@ -670,38 +634,30 @@ internal fun UnsignedTransactionWidget( // format the spending amount (unsigned transactions are always outgoing) val formattedAmount = - manager?.let { - when (fiatOrBtc) { - FiatOrBtc.BTC -> it.rust.displayAmountWithDirection(txn.spendingAmount(), TransactionDirection.OUTGOING) - FiatOrBtc.FIAT -> { - val amount = fiatAmount - if (amount != null) { - it.rust.displayFiatAmountWithDirection(amount, TransactionDirection.OUTGOING) - } else { - "---" - } + when (fiatOrBtc) { + FiatOrBtc.BTC -> manager.rust.displayAmountWithDirection(txn.spendingAmount(), TransactionDirection.OUTGOING) + FiatOrBtc.FIAT -> { + val amount = fiatAmount + if (amount != null) { + manager.rust.displayFiatAmountWithDirection(amount, TransactionDirection.OUTGOING) + } else { + "---" } } - } ?: txn.spendingAmount().satsStringWithUnit() + } val secondaryAmount = - manager?.let { - when (fiatOrBtc) { - FiatOrBtc.BTC -> { - // primary is BTC, secondary is fiat - val amount = fiatAmount - if (amount != null) { - it.rust.displayFiatAmountWithDirection(amount, TransactionDirection.OUTGOING) - } else { - "---" - } - } - FiatOrBtc.FIAT -> { - // primary is fiat, secondary is BTC - it.rust.displayAmountWithDirection(txn.spendingAmount(), TransactionDirection.OUTGOING) + when (fiatOrBtc) { + FiatOrBtc.BTC -> { + val amount = fiatAmount + if (amount != null) { + manager.rust.displayFiatAmountWithDirection(amount, TransactionDirection.OUTGOING) + } else { + "---" } } - } ?: "---" + FiatOrBtc.FIAT -> manager.rust.displayAmountWithDirection(txn.spendingAmount(), TransactionDirection.OUTGOING) + } Box { Row( @@ -710,10 +666,10 @@ internal fun UnsignedTransactionWidget( .fillMaxWidth() .combinedClickable( onClick = { - val walletId = manager?.walletMetadata?.id - if (app != null && walletId != null) { + val walletId = manager.walletMetadata?.id + if (walletId != null) { if (index > SCROLL_THRESHOLD_INDEX) { - manager?.pendingScrollTransactionId = txn.id().toString() + manager.pendingScrollTransactionId = txn.id().toString() } val route = RouteFactory().sendHardwareExport(walletId, txn.details()) app.pushRoute(route) @@ -805,18 +761,16 @@ internal fun UnsignedTransactionWidget( onClick = { showDeleteMenu = false try { - manager?.rust?.deleteUnsignedTransaction(txn.id()) + manager.rust.deleteUnsignedTransaction(txn.id()) } catch (e: Exception) { android.util.Log.e("UnsignedTxWidget", "Failed to delete unsigned transaction", e) - app?.let { - it.alertState = - TaggedItem( - AppAlertState.General( - title = "Delete Failed", - message = "Unable to delete transaction: ${e.localizedMessage ?: e.message ?: "Unknown error"}", - ), - ) - } + app.alertState = + TaggedItem( + AppAlertState.General( + title = "Delete Failed", + message = "Unable to delete transaction: ${e.localizedMessage ?: e.message ?: "Unknown error"}", + ), + ) } }, ) @@ -839,8 +793,8 @@ fun LazyListScope.transactionItems( fiatOrBtc: FiatOrBtc, sensitiveVisible: Boolean, showLabels: Boolean, - manager: WalletManager?, - app: AppManager?, + manager: WalletManager, + app: AppManager, primaryText: Color, secondaryText: Color, dividerColor: Color, @@ -984,31 +938,76 @@ fun LazyListScope.transactionItems( @Preview(showBackground = true) @Composable private fun TransactionsCardViewEmptyPreview() { - TransactionsCardView( - transactions = emptyList(), - unsignedTransactions = emptyList(), - isScanning = false, - isFirstScan = false, - fiatOrBtc = FiatOrBtc.BTC, - sensitiveVisible = true, - showLabels = false, - manager = null, - app = null, - ) + TransactionsPreviewShell(isScanning = false, isFirstScan = false) } @Preview(showBackground = true) @Composable private fun TransactionsCardViewLoadingPreview() { - TransactionsCardView( - transactions = emptyList(), - unsignedTransactions = emptyList(), - isScanning = true, - isFirstScan = true, - fiatOrBtc = FiatOrBtc.BTC, - sensitiveVisible = true, - showLabels = false, - manager = null, - app = null, - ) + TransactionsPreviewShell(isScanning = true, isFirstScan = true) +} + +@Composable +private fun TransactionsPreviewShell( + isScanning: Boolean, + isFirstScan: Boolean, +) { + val primaryText = MaterialTheme.colorScheme.onSurface + val secondaryText = MaterialTheme.colorScheme.onSurfaceVariant + + Column( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.title_transactions), + color = secondaryText, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + ) + + if (isScanning && isFirstScan) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(200.dp), + contentAlignment = Alignment.TopCenter, + ) { + CircularProgressIndicator( + modifier = Modifier.padding(top = 80.dp), + color = primaryText, + ) + } + } else { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = androidx.compose.ui.res.painterResource(R.drawable.icon_currency_bitcoin), + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = secondaryText, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.no_transactions_yet), + color = secondaryText, + fontWeight = FontWeight.Medium, + ) + Text( + text = stringResource(R.string.go_buy_some_bitcoin), + color = secondaryText.copy(alpha = 0.7f), + fontSize = 14.sp, + ) + } + } + } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowHardwareScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowHardwareScreen.kt index 4ef6aa3ae..831d5dc89 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowHardwareScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SendFlow/SendFlowHardwareScreen.kt @@ -228,7 +228,7 @@ fun SendFlowHardwareScreen( topBar = { CenterAlignedTopAppBar( colors = - TopAppBarDefaults.centerAlignedTopAppBarColors( + TopAppBarDefaults.topAppBarColors( containerColor = Color.Transparent, navigationIconContentColor = Color.White, actionIconContentColor = Color.White, diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/MainSettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/MainSettingsScreen.kt index 52bba27be..f9412eb0e 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/MainSettingsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/MainSettingsScreen.kt @@ -784,7 +784,7 @@ private fun SecurityAlertDialog( TextButton( onClick = { try { - app.rust.selectWallet(state.walletId) + app.selectWalletOrThrow(state.walletId) } catch (e: Exception) { Log.e("SecuritySection", "Failed to select wallet ${state.walletId}", e) } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NetworkSettingsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NetworkSettingsScreen.kt index 942084434..a71fe32f9 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NetworkSettingsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SettingsFlow/NetworkSettingsScreen.kt @@ -118,7 +118,7 @@ fun NetworkSettingsScreen( onClick = { pendingNetworkChange = null app.dispatch(AppAction.ChangeNetwork(network)) - app.rust.selectLatestOrNewWallet() + app.trySelectLatestOrNewWallet() app.popRoute() }, ) { diff --git a/android/app/src/main/java/org/bitcoinppl/cove/navigation/CoveNavDisplay.kt b/android/app/src/main/java/org/bitcoinppl/cove/navigation/CoveNavDisplay.kt index 581634da4..42ff6d84f 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/navigation/CoveNavDisplay.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/navigation/CoveNavDisplay.kt @@ -177,8 +177,7 @@ private fun RouteContent(app: AppManager, route: Route) { is Route.LoadAndReset -> { LoadAndResetContent( app = app, - nextRoutes = route.resetTo.map { it.route() }, - loadingTimeMs = route.afterMillis.toLong(), + route = route, ) } } @@ -190,15 +189,18 @@ private fun RouteContent(app: AppManager, route: Route) { @Composable private fun LoadAndResetContent( app: AppManager, - nextRoutes: List, - loadingTimeMs: Long, + route: Route.LoadAndReset, ) { + val nextRoutes = route.resetTo.map { it.route() } + val loadingTimeMs = route.afterMillis.toLong() + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } - LaunchedEffect(Unit) { + LaunchedEffect(route) { + val generation = app.captureLoadAndResetGeneration() delay(loadingTimeMs) - app.rust.resetAfterLoading(nextRoutes) + app.resetAfterLoadingIfCurrent(generation, route, nextRoutes) } } diff --git a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt index e05812275..e1c315da1 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/sidebar/SidebarView.kt @@ -42,9 +42,6 @@ import org.bitcoinppl.cove.AppManager import org.bitcoinppl.cove.R import org.bitcoinppl.cove.ui.theme.CoveColor import org.bitcoinppl.cove.views.AutoSizeText -import org.bitcoinppl.cove_core.Route -import org.bitcoinppl.cove_core.RouteFactory -import org.bitcoinppl.cove_core.SettingsRoute import org.bitcoinppl.cove_core.WalletColor import org.bitcoinppl.cove_core.WalletMetadata @@ -80,9 +77,7 @@ fun SidebarView( IconButton( onClick = { - app.closeSidebarAndNavigate { - app.scanNfc() - } + app.closeSidebarAndScanNfc() }, ) { Icon( @@ -122,9 +117,7 @@ fun SidebarView( WalletItem( wallet = wallet, onClick = { - app.closeSidebarAndNavigate { - app.rust.selectWallet(wallet.id) - } + app.closeSidebarAndSelectWallet(wallet.id) }, ) } @@ -145,13 +138,7 @@ fun SidebarView( Modifier .fillMaxWidth() .clickable { - app.closeSidebarAndNavigate { - if (app.wallets.isEmpty()) { - app.resetRoute(RouteFactory().newWalletSelect()) - } else { - app.pushRoute(RouteFactory().newWalletSelect()) - } - } + app.closeSidebarAndOpenNewWallet() }.padding(vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(20.dp), verticalAlignment = Alignment.CenterVertically, @@ -177,9 +164,7 @@ fun SidebarView( Modifier .fillMaxWidth() .clickable { - app.closeSidebarAndNavigate { - app.pushRoute(Route.Settings(SettingsRoute.Main)) - } + app.closeSidebarAndOpenSettings() }.padding(vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(20.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt index fcf4ed9d3..5796f2317 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt @@ -1178,10 +1178,6 @@ internal object IntegrityCheckingUniffiLib { ): Short external fun uniffi_cove_checksum_method_ffiapp_save_tap_signer_backup( ): Short - external fun uniffi_cove_checksum_method_ffiapp_select_latest_or_new_wallet( - ): Short - external fun uniffi_cove_checksum_method_ffiapp_select_wallet( - ): Short external fun uniffi_cove_checksum_method_ffiapp_state( ): Short external fun uniffi_cove_checksum_method_ffiapp_unverified_wallet_ids( @@ -1382,8 +1378,6 @@ internal object IntegrityCheckingUniffiLib { ): Short external fun uniffi_cove_checksum_method_rustauthmanager_validate_security_action( ): Short - external fun uniffi_cove_checksum_method_rustcloudbackupmanager_dispatch( - ): Short external fun uniffi_cove_checksum_method_rustcloudbackupmanager_backup_new_wallet( ): Short external fun uniffi_cove_checksum_method_rustcloudbackupmanager_backup_wallet_count( @@ -1416,6 +1410,8 @@ internal object IntegrityCheckingUniffiLib { ): Short external fun uniffi_cove_checksum_method_rustcloudbackupmanager_verify_backup_integrity( ): Short + external fun uniffi_cove_checksum_method_rustcloudbackupmanager_dispatch( + ): Short external fun uniffi_cove_checksum_method_rustcoincontrolmanager_button_presentation( ): Short external fun uniffi_cove_checksum_method_rustcoincontrolmanager_dispatch( @@ -2088,10 +2084,6 @@ internal object UniffiLib { ): Unit external fun uniffi_cove_fn_method_ffiapp_save_tap_signer_backup(`ptr`: Long,`tapSigner`: Long,`backup`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Byte - external fun uniffi_cove_fn_method_ffiapp_select_latest_or_new_wallet(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, - ): Unit - external fun uniffi_cove_fn_method_ffiapp_select_wallet(`ptr`: Long,`id`: RustBufferWalletId.ByValue,`nextRoute`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit external fun uniffi_cove_fn_method_ffiapp_state(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_cove_fn_method_ffiapp_unverified_wallet_ids(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -2420,8 +2412,6 @@ internal object UniffiLib { ): Unit external fun uniffi_cove_fn_constructor_rustcloudbackupmanager_new(uniffi_out_err: UniffiRustCallStatus, ): Long - external fun uniffi_cove_fn_method_rustcloudbackupmanager_dispatch(`ptr`: Long,`action`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, - ): Unit external fun uniffi_cove_fn_method_rustcloudbackupmanager_backup_new_wallet(`ptr`: Long,`metadata`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Unit external fun uniffi_cove_fn_method_rustcloudbackupmanager_backup_wallet_count(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -2454,6 +2444,8 @@ internal object UniffiLib { ): Unit external fun uniffi_cove_fn_method_rustcloudbackupmanager_verify_backup_integrity(`ptr`: Long, ): Long + external fun uniffi_cove_fn_method_rustcloudbackupmanager_dispatch(`ptr`: Long,`action`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Unit external fun uniffi_cove_fn_clone_rustcoincontrolmanager(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): Long external fun uniffi_cove_fn_free_rustcoincontrolmanager(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -3682,7 +3674,7 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_ffiapp_delete_corrupted_wallet() != 27181.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_cove_checksum_method_ffiapp_dispatch() != 37137.toShort()) { + if (lib.uniffi_cove_checksum_method_ffiapp_dispatch() != 7288.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if (lib.uniffi_cove_checksum_method_ffiapp_email_mailto() != 41824.toShort()) { @@ -3742,12 +3734,6 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_ffiapp_save_tap_signer_backup() != 24217.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_cove_checksum_method_ffiapp_select_latest_or_new_wallet() != 31849.toShort()) { - throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - } - if (lib.uniffi_cove_checksum_method_ffiapp_select_wallet() != 51673.toShort()) { - throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - } if (lib.uniffi_cove_checksum_method_ffiapp_state() != 49253.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -4048,9 +4034,6 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_rustauthmanager_validate_security_action() != 4302.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } - if (lib.uniffi_cove_checksum_method_rustcloudbackupmanager_dispatch() != 54131.toShort()) { - throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - } if (lib.uniffi_cove_checksum_method_rustcloudbackupmanager_backup_new_wallet() != 25342.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -4099,6 +4082,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_rustcloudbackupmanager_verify_backup_integrity() != 35162.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_checksum_method_rustcloudbackupmanager_dispatch() != 23570.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_checksum_method_rustcoincontrolmanager_button_presentation() != 24764.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -10044,17 +10030,6 @@ public interface FfiAppInterface { */ fun `saveTapSignerBackup`(`tapSigner`: TapSigner, `backup`: kotlin.ByteArray): kotlin.Boolean - /** - * Select the latest (most recently used) wallet or navigate to new wallet flow - * This selects the wallet with the most recent scan activity - */ - fun `selectLatestOrNewWallet`() - - /** - * Select a wallet - */ - fun `selectWallet`(`id`: WalletId, `nextRoute`: Route? = null) - fun `state`(): AppState /** @@ -10260,10 +10235,11 @@ open class FfiApp: Disposable, AutoCloseable, FfiAppInterface /** * Frontend calls this method to send events to the rust application logic - */override fun `dispatch`(`action`: AppAction) + */ + @Throws(AppException::class)override fun `dispatch`(`action`: AppAction) = callWithHandle { - uniffiRustCall() { _status -> + uniffiRustCallWithError(AppException) { _status -> UniffiLib.uniffi_cove_fn_method_ffiapp_dispatch( it, FfiConverterTypeAppAction.lower(`action`),_status) @@ -10565,38 +10541,6 @@ open class FfiApp: Disposable, AutoCloseable, FfiAppInterface } - - /** - * Select the latest (most recently used) wallet or navigate to new wallet flow - * This selects the wallet with the most recent scan activity - */override fun `selectLatestOrNewWallet`() - = - callWithHandle { - uniffiRustCall() { _status -> - UniffiLib.uniffi_cove_fn_method_ffiapp_select_latest_or_new_wallet( - it, - _status) -} - } - - - - - /** - * Select a wallet - */ - @Throws(DatabaseException::class)override fun `selectWallet`(`id`: WalletId, `nextRoute`: Route?) - = - callWithHandle { - uniffiRustCallWithError(DatabaseException) { _status -> - UniffiLib.uniffi_cove_fn_method_ffiapp_select_wallet( - it, - FfiConverterTypeWalletId.lower(`id`),FfiConverterOptionalTypeRoute.lower(`nextRoute`),_status) -} - } - - - override fun `state`(): AppState { return FfiConverterTypeAppState.lift( callWithHandle { @@ -17819,8 +17763,6 @@ public object FfiConverterTypeRustAuthManager: FfiConverter - UniffiLib.uniffi_cove_fn_method_rustcloudbackupmanager_dispatch( - it, - FfiConverterTypeCloudBackupManagerAction.lower(`action`),_status) -} - } - - - /** * Back up a newly created wallet, fire-and-forget @@ -18247,6 +18179,18 @@ open class RustCloudBackupManager: Disposable, AutoCloseable, RustCloudBackupMan ) } + override fun `dispatch`(`action`: CloudBackupManagerAction) + = + callWithHandle { + uniffiRustCall() { _status -> + UniffiLib.uniffi_cove_fn_method_rustcloudbackupmanager_dispatch( + it, + FfiConverterTypeCloudBackupManagerAction.lower(`action`),_status) +} + } + + + @@ -30302,6 +30246,18 @@ sealed class AppAction: Disposable { object PopRoute : AppAction() + data class SelectWallet( + val `id`: org.bitcoinppl.cove_core.types.WalletId) : AppAction() + + { + + + companion object + } + + object SelectLatestOrNewWallet : AppAction() + + data class ChangeNetwork( val `network`: org.bitcoinppl.cove_core.types.Network) : AppAction() @@ -30371,6 +30327,15 @@ sealed class AppAction: Disposable { } is AppAction.PopRoute -> {// Nothing to destroy } + is AppAction.SelectWallet -> { + + Disposable.destroy( + this.`id` + ) + + } + is AppAction.SelectLatestOrNewWallet -> {// Nothing to destroy + } is AppAction.ChangeNetwork -> { Disposable.destroy( @@ -30431,22 +30396,26 @@ public object FfiConverterTypeAppAction : FfiConverterRustBuffer{ FfiConverterTypeRoute.read(buf), ) 3 -> AppAction.PopRoute - 4 -> AppAction.ChangeNetwork( + 4 -> AppAction.SelectWallet( + FfiConverterTypeWalletId.read(buf), + ) + 5 -> AppAction.SelectLatestOrNewWallet + 6 -> AppAction.ChangeNetwork( FfiConverterTypeNetwork.read(buf), ) - 5 -> AppAction.ChangeColorScheme( + 7 -> AppAction.ChangeColorScheme( FfiConverterTypeColorSchemeSelection.read(buf), ) - 6 -> AppAction.ChangeFiatCurrency( + 8 -> AppAction.ChangeFiatCurrency( FfiConverterTypeFiatCurrency.read(buf), ) - 7 -> AppAction.SetSelectedNode( + 9 -> AppAction.SetSelectedNode( FfiConverterTypeNode.read(buf), ) - 8 -> AppAction.UpdateFiatPrices - 9 -> AppAction.UpdateFees - 10 -> AppAction.AcceptTerms - 11 -> AppAction.RefreshAfterImport + 10 -> AppAction.UpdateFiatPrices + 11 -> AppAction.UpdateFees + 12 -> AppAction.AcceptTerms + 13 -> AppAction.RefreshAfterImport else -> throw RuntimeException("invalid enum value, something is very wrong!!") } } @@ -30472,6 +30441,19 @@ public object FfiConverterTypeAppAction : FfiConverterRustBuffer{ 4UL ) } + is AppAction.SelectWallet -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterTypeWalletId.allocationSize(value.`id`) + ) + } + is AppAction.SelectLatestOrNewWallet -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + ) + } is AppAction.ChangeNetwork -> { // Add the size for the Int that specifies the variant plus the size needed for all fields ( @@ -30542,40 +30524,49 @@ public object FfiConverterTypeAppAction : FfiConverterRustBuffer{ buf.putInt(3) Unit } - is AppAction.ChangeNetwork -> { + is AppAction.SelectWallet -> { buf.putInt(4) + FfiConverterTypeWalletId.write(value.`id`, buf) + Unit + } + is AppAction.SelectLatestOrNewWallet -> { + buf.putInt(5) + Unit + } + is AppAction.ChangeNetwork -> { + buf.putInt(6) FfiConverterTypeNetwork.write(value.`network`, buf) Unit } is AppAction.ChangeColorScheme -> { - buf.putInt(5) + buf.putInt(7) FfiConverterTypeColorSchemeSelection.write(value.v1, buf) Unit } is AppAction.ChangeFiatCurrency -> { - buf.putInt(6) + buf.putInt(8) FfiConverterTypeFiatCurrency.write(value.v1, buf) Unit } is AppAction.SetSelectedNode -> { - buf.putInt(7) + buf.putInt(9) FfiConverterTypeNode.write(value.v1, buf) Unit } is AppAction.UpdateFiatPrices -> { - buf.putInt(8) + buf.putInt(10) Unit } is AppAction.UpdateFees -> { - buf.putInt(9) + buf.putInt(11) Unit } is AppAction.AcceptTerms -> { - buf.putInt(10) + buf.putInt(12) Unit } is AppAction.RefreshAfterImport -> { - buf.putInt(11) + buf.putInt(13) Unit } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } @@ -31544,6 +31535,14 @@ sealed class AppException: kotlin.Exception() { get() = "v1=${ v1 }" } + class WalletSelection( + + val v1: kotlin.String + ) : AppException() { + override val message + get() = "v1=${ v1 }" + } + @@ -31578,6 +31577,9 @@ public object FfiConverterTypeAppError : FfiConverterRustBuffer { 2 -> AppException.FeesException( FfiConverterString.read(buf), ) + 3 -> AppException.WalletSelection( + FfiConverterString.read(buf), + ) else -> throw RuntimeException("invalid error enum value, something is very wrong!!") } } @@ -31594,6 +31596,11 @@ public object FfiConverterTypeAppError : FfiConverterRustBuffer { 4UL + FfiConverterString.allocationSize(value.v1) ) + is AppException.WalletSelection -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) } } @@ -31609,6 +31616,11 @@ public object FfiConverterTypeAppError : FfiConverterRustBuffer { FfiConverterString.write(value.v1, buf) Unit } + is AppException.WalletSelection -> { + buf.putInt(3) + FfiConverterString.write(value.v1, buf) + Unit + } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } } @@ -49191,6 +49203,14 @@ sealed class WalletCreationException: kotlin.Exception() { get() = "v1=${ v1 }" } + class Unexpected( + + val v1: kotlin.String + ) : WalletCreationException() { + override val message + get() = "v1=${ v1 }" + } + class MultiFormat( val v1: MultiFormatException @@ -49242,7 +49262,10 @@ public object FfiConverterTypeWalletCreationError : FfiConverterRustBuffer WalletCreationException.Import( FfiConverterString.read(buf), ) - 6 -> WalletCreationException.MultiFormat( + 6 -> WalletCreationException.Unexpected( + FfiConverterString.read(buf), + ) + 7 -> WalletCreationException.MultiFormat( FfiConverterTypeMultiFormatError.read(buf), ) else -> throw RuntimeException("invalid error enum value, something is very wrong!!") @@ -49276,6 +49299,11 @@ public object FfiConverterTypeWalletCreationError : FfiConverterRustBuffer ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.v1) + ) is WalletCreationException.MultiFormat -> ( // Add the size for the Int that specifies the variant plus the size needed for all fields 4UL @@ -49311,8 +49339,13 @@ public object FfiConverterTypeWalletCreationError : FfiConverterRustBuffer { + is WalletCreationException.Unexpected -> { buf.putInt(6) + FfiConverterString.write(value.v1, buf) + Unit + } + is WalletCreationException.MultiFormat -> { + buf.putInt(7) FfiConverterTypeMultiFormatError.write(value.v1, buf) Unit } @@ -53780,38 +53813,6 @@ public object FfiConverterOptionalTypeOnboardingBranch: FfiConverterRustBuffer { - override fun read(buf: ByteBuffer): Route? { - if (buf.get().toInt() == 0) { - return null - } - return FfiConverterTypeRoute.read(buf) - } - - override fun allocationSize(value: Route?): ULong { - if (value == null) { - return 1UL - } else { - return 1UL + FfiConverterTypeRoute.allocationSize(value) - } - } - - override fun write(value: Route?, buf: ByteBuffer) { - if (value == null) { - buf.put(0) - } else { - buf.put(1) - FfiConverterTypeRoute.write(value, buf) - } - } -} - - - - /** * @suppress */ diff --git a/ios/Cove/AppManager.swift b/ios/Cove/AppManager.swift index 9f0c06b68..ef43990db 100644 --- a/ios/Cove/AppManager.swift +++ b/ios/Cove/AppManager.swift @@ -3,11 +3,16 @@ import Observation import SwiftUI private let walletModeChangeDelayMs = 250 +private let sidebarNavigationDelayMs = 250 @Observable final class AppManager: FfiReconcile { static let shared = makeShared() private let logger = Log(id: "AppManager") + @ObservationIgnored + private var navigationGeneration: UInt64 = 0 + @ObservationIgnored + private var pendingSidebarNavigationTask: Task? var rust: FfiApp var router: Router @@ -147,7 +152,10 @@ private let walletModeChangeDelayMs = 250 /// Reset the manager state public func reset() { - rust = FfiApp() + pendingSidebarNavigationTask?.cancel() + pendingSidebarNavigationTask = nil + advanceNavigationGeneration() + database = Database() walletManager = nil @@ -176,13 +184,36 @@ private let walletModeChangeDelayMs = 250 /// this will select the wallet and reset the route to the selectedWalletRoute func selectWallet(_ id: WalletId) { do { - try rust.selectWallet(id: id) - isSidebarVisible = false + try selectWalletOrThrow(id) } catch { Log.error("Unable to select wallet \(id), error: \(error)") } } + func selectWalletOrThrow(_ id: WalletId) throws { + advanceNavigationGeneration() + try selectWalletWithoutNavigationGeneration(id) + } + + private func selectWalletWithoutNavigationGeneration(_ id: WalletId) throws { + try rust.dispatch(action: .selectWallet(id: id)) + isSidebarVisible = false + } + + func trySelectLatestOrNewWallet() { + do { + try selectLatestOrNewWallet() + } catch { + Log.error("Unable to select latest wallet, error: \(error)") + } + } + + func selectLatestOrNewWallet() throws { + advanceNavigationGeneration() + try rust.dispatch(action: .selectLatestOrNewWallet) + isSidebarVisible = false + } + func toggleSidebar() { isSidebarVisible.toggle() } @@ -192,42 +223,163 @@ private let walletModeChangeDelayMs = 250 } func pushRoute(_ route: Route) { + advanceNavigationGeneration() + pushRouteWithoutNavigationGeneration(route) + } + + private func pushRouteWithoutNavigationGeneration(_ route: Route) { isSidebarVisible = false router.routes.append(route) } func pushRoutes(_ routes: [Route]) { + advanceNavigationGeneration() + pushRoutesWithoutNavigationGeneration(routes) + } + + private func pushRoutesWithoutNavigationGeneration(_ routes: [Route]) { isSidebarVisible = false router.routes.append(contentsOf: routes) } func popRoute() { - router.routes.removeLast() + advanceNavigationGeneration() + + if !router.routes.isEmpty { + router.routes.removeLast() + } } func setRoute(_ routes: [Route]) { + advanceNavigationGeneration() router.routes = routes } func scanQr() { + advanceNavigationGeneration() sheetState = TaggedItem(.qr) } + func scanNfc() { + advanceNavigationGeneration() + scanNfcWithoutNavigationGeneration() + } + + private func scanNfcWithoutNavigationGeneration() { + nfcReader.scan() + } + @MainActor func resetRoute(to routes: [Route]) { - guard routes.count > 1 else { return resetRoute(to: routes[0]) } - rust.resetNestedRoutesTo(defaultRoute: routes[0], nestedRoutes: Array(routes[1...])) + advanceNavigationGeneration() + resetRouteWithoutNavigationGeneration(to: routes) + } + + @MainActor + private func resetRouteWithoutNavigationGeneration(to routes: [Route]) { + if routes.count > 1 { + rust.resetNestedRoutesTo(defaultRoute: routes[0], nestedRoutes: Array(routes[1...])) + } else if let route = routes.first { + rust.resetDefaultRouteTo(route: route) + } } func resetRoute(to route: Route) { + advanceNavigationGeneration() + resetRouteWithoutNavigationGeneration(to: route) + } + + private func resetRouteWithoutNavigationGeneration(to route: Route) { rust.resetDefaultRouteTo(route: route) } @MainActor func loadAndReset(to route: Route) { + advanceNavigationGeneration() rust.loadAndResetDefaultRoute(route: route) } + @discardableResult + private func advanceNavigationGeneration() -> UInt64 { + navigationGeneration &+= 1 + + return navigationGeneration + } + + func closeSidebarAndSelectWallet(_ id: WalletId) { + closeSidebarThenNavigate { + do { + try self.selectWalletWithoutNavigationGeneration(id) + } catch { + Log.error("Unable to select wallet \(id), error: \(error)") + } + } + } + + func closeSidebarAndOpenNewWallet() { + closeSidebarThenNavigate { + if self.hasWallets { + self.pushRouteWithoutNavigationGeneration(RouteFactory().newWalletSelect()) + } else { + self.resetRouteWithoutNavigationGeneration(to: [RouteFactory().newWalletSelect()]) + } + } + } + + func closeSidebarAndOpenSettings() { + closeSidebarThenNavigate { + self.pushRouteWithoutNavigationGeneration(.settings(.main)) + } + } + + func closeSidebarAndOpenWalletSettings(_ id: WalletId) { + closeSidebarThenNavigate { + self.pushRoutesWithoutNavigationGeneration(RouteFactory().nestedWalletSettings(id: id)) + } + } + + func closeSidebarAndScanNfc() { + closeSidebarThenNavigate { + self.scanNfcWithoutNavigationGeneration() + } + } + + private func closeSidebarThenNavigate(_ action: @escaping @MainActor () -> Void) { + pendingSidebarNavigationTask?.cancel() + let generation = advanceNavigationGeneration() + + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + isSidebarVisible = false + } + + pendingSidebarNavigationTask = Task { @MainActor in + do { + try await Task.sleep(for: .milliseconds(sidebarNavigationDelayMs)) + } catch { + return + } + + guard isNavigationGenerationCurrent(generation) else { return } + action() + } + } + + @MainActor + func captureLoadAndResetGeneration() -> UInt64 { + navigationGeneration + } + + @MainActor + func resetAfterLoadingIfCurrent(generation: UInt64, route: Route, nextRoute: [Route]) { + guard isNavigationGenerationCurrent(generation) else { return } + guard router.default == route else { return } + rust.resetAfterLoading(to: nextRoute) + } + + private func isNavigationGenerationCurrent(_ generation: UInt64) -> Bool { + generation == navigationGeneration + } + func agreeToTerms() { self.dispatch(action: .acceptTerms) withAnimation { isTermsAccepted = true } @@ -311,6 +463,10 @@ private let walletModeChangeDelayMs = 250 public func dispatch(action: AppAction) { logger.debug("dispatch \(action)") - rust.dispatch(action: action) + do { + try rust.dispatch(action: action) + } catch { + logger.error("Unable to dispatch app action \(action), error: \(error)") + } } } diff --git a/ios/Cove/AuthManager.swift b/ios/Cove/AuthManager.swift index a5e239b74..4bc032b4c 100644 --- a/ios/Cove/AuthManager.swift +++ b/ios/Cove/AuthManager.swift @@ -99,7 +99,7 @@ enum UnlockMode { let db = Database() if let selectedWalletId = db.globalConfig().selectedWallet() { do { - try app.rust.selectWallet(id: selectedWalletId) + try app.selectWalletOrThrow(selectedWalletId) } catch { logger.error("Failed to select decoy wallet after auth fallback: \(error)") app.isLoading = false @@ -144,7 +144,7 @@ enum UnlockMode { let db = Database() if let selectedWalletId = db.globalConfig().selectedWallet() { do { - try app.rust.selectWallet(id: selectedWalletId) + try app.selectWalletOrThrow(selectedWalletId) } catch { logger.error("Failed to select main wallet after auth fallback: \(error)") app.isLoading = false diff --git a/ios/Cove/CoveMainView.swift b/ios/Cove/CoveMainView.swift index f8f06ed0b..51371c86b 100644 --- a/ios/Cove/CoveMainView.swift +++ b/ios/Cove/CoveMainView.swift @@ -40,7 +40,7 @@ struct CoveMainView: View { Button("OK") { app.alertState = .none app.isSidebarVisible = false - try? app.rust.selectWallet(id: walletId) + try? app.selectWalletOrThrow(walletId) } case let .hotWalletKeyMissing(walletId: walletId): if CloudBackupManager.shared.isCloudBackupEnabled { @@ -181,7 +181,7 @@ struct CoveMainView: View { if text.isEmpty { return } do { let wallet = try Wallet.newFromXpub(xpub: text) - try app.rust.selectWallet(id: wallet.id()) + try app.selectWalletOrThrow(wallet.id()) app.resetRoute(to: .selectedWallet(wallet.id())) } catch { DispatchQueue.main.async { @@ -221,7 +221,7 @@ struct CoveMainView: View { } Button("Cancel", role: .cancel) { app.alertState = .none - app.rust.selectLatestOrNewWallet() + app.trySelectLatestOrNewWallet() } case .invalidWordGroup, .errorImportingHotWallet, diff --git a/ios/Cove/Flows/NewWalletFlow/ColdWallet/QrCodeImportScreen.swift b/ios/Cove/Flows/NewWalletFlow/ColdWallet/QrCodeImportScreen.swift index b15ebf52c..a9402b609 100644 --- a/ios/Cove/Flows/NewWalletFlow/ColdWallet/QrCodeImportScreen.swift +++ b/ios/Cove/Flows/NewWalletFlow/ColdWallet/QrCodeImportScreen.swift @@ -156,8 +156,8 @@ struct QrCodeImportScreen: View { if let onImported { onImported(id) } else { + try app.selectWalletOrThrow(id) app.alertState = TaggedItem(.importedSuccessfully) - try app.rust.selectWallet(id: id) } } catch let WalletError.MultiFormat(error) { app.popRoute() @@ -253,7 +253,7 @@ struct QrCodeImportScreen: View { } self.alert = AlertItem(type: .success("Wallet already exists: \(id)")) - if (try? app.rust.selectWallet(id: id)) == nil { + if (try? app.selectWalletOrThrow(id)) == nil { app.popRoute() self.alert = AlertItem(type: .error("Unable to select wallet")) } diff --git a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportScreen.swift b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportScreen.swift index bcd3b064d..704cc3576 100644 --- a/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportScreen.swift +++ b/ios/Cove/Flows/NewWalletFlow/HotWallet/HotWalletImportScreen.swift @@ -224,7 +224,7 @@ struct HotWalletImportScreen: View { return } - try app.rust.selectWallet(id: walletMetadata.id) + try app.selectWalletOrThrow(walletMetadata.id) app.walletManager = nil app.resetRoute(to: .selectedWallet(walletMetadata.id)) } catch let error as ImportWalletError { @@ -513,8 +513,15 @@ struct HotWalletImportScreen: View { return } - try? app.rust.selectWallet(id: walletId) - app.resetRoute(to: .selectedWallet(walletId)) + do { + try app.selectWalletOrThrow(walletId) + app.resetRoute(to: .selectedWallet(walletId)) + } catch { + app.alertState = TaggedItem(.general( + title: "Unable to Select Wallet", + message: error.localizedDescription + )) + } } } ) diff --git a/ios/Cove/Flows/NewWalletFlow/NewWalletSelectScreen.swift b/ios/Cove/Flows/NewWalletFlow/NewWalletSelectScreen.swift index e782cea8f..7c8c650d0 100644 --- a/ios/Cove/Flows/NewWalletFlow/NewWalletSelectScreen.swift +++ b/ios/Cove/Flows/NewWalletFlow/NewWalletSelectScreen.swift @@ -171,8 +171,8 @@ struct NewWalletSelectScreen: View { let wallet = try Wallet.newFromXpub(xpub: xpub) let id = wallet.id() Log.debug("Imported Wallet: \(id)") + try app.selectWalletOrThrow(id) app.alertState = TaggedItem(.importedSuccessfully) - try app.rust.selectWallet(id: id) } catch { alert = AlertItem(type: .error(error.localizedDescription)) } diff --git a/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletContainer.swift b/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletContainer.swift index 9ccbc5e5d..dfae0c10f 100644 --- a/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletContainer.swift +++ b/ios/Cove/Flows/SelectedWalletFlow/SelectedWalletContainer.swift @@ -33,7 +33,7 @@ struct SelectedWalletContainer: View { let wallet = wallets.first(where: { $0.id != id }) if let wallet { - try app.rust.selectWallet(id: wallet.id) + try app.selectWalletOrThrow(wallet.id) } else { app.loadAndReset(to: Route.newWallet(.select)) } diff --git a/ios/Cove/Flows/SelectedWalletFlow/TransactionDetails/TransactionsDetailScreen.swift b/ios/Cove/Flows/SelectedWalletFlow/TransactionDetails/TransactionsDetailScreen.swift index 6d7c2bc45..f69d67bff 100644 --- a/ios/Cove/Flows/SelectedWalletFlow/TransactionDetails/TransactionsDetailScreen.swift +++ b/ios/Cove/Flows/SelectedWalletFlow/TransactionDetails/TransactionsDetailScreen.swift @@ -29,7 +29,7 @@ struct TransactionsDetailScreen: View { manager = try app.getWalletManager(id: id) } catch { Log.error("Something went very wrong: \(error)") - app.rust.selectLatestOrNewWallet() + app.trySelectLatestOrNewWallet() } } diff --git a/ios/Cove/Flows/SendFlow/SendFlowContainer.swift b/ios/Cove/Flows/SendFlow/SendFlowContainer.swift index 99c110ca4..bfb7adf1b 100644 --- a/ios/Cove/Flows/SendFlow/SendFlowContainer.swift +++ b/ios/Cove/Flows/SendFlow/SendFlowContainer.swift @@ -43,7 +43,7 @@ public struct SendFlowContainer: View { self.sendFlowManager = sendFlowManager } catch { Log.error("Something went very wrong: \(error)") - app.rust.selectLatestOrNewWallet() + app.trySelectLatestOrNewWallet() } } diff --git a/ios/Cove/Flows/SettingsFlow/MainSettingsScreen.swift b/ios/Cove/Flows/SettingsFlow/MainSettingsScreen.swift index 46eb4ae43..ab5b80f66 100644 --- a/ios/Cove/Flows/SettingsFlow/MainSettingsScreen.swift +++ b/ios/Cove/Flows/SettingsFlow/MainSettingsScreen.swift @@ -538,7 +538,7 @@ struct MainSettingsScreen: View { """, actions: { Button("Go To Wallet") { - try? app.rust.selectWallet(id: walletId) + try? app.selectWalletOrThrow(walletId) } Button("Cancel", role: .cancel) { alertState = .none } diff --git a/ios/Cove/Flows/SettingsFlow/SettingsContainer.swift b/ios/Cove/Flows/SettingsFlow/SettingsContainer.swift index 2730247e0..6895f5fd0 100644 --- a/ios/Cove/Flows/SettingsFlow/SettingsContainer.swift +++ b/ios/Cove/Flows/SettingsFlow/SettingsContainer.swift @@ -89,7 +89,7 @@ struct SettingsContainer: View { Button("Yes, Change Network") { if let network = pendingNetwork { app.dispatch(action: .changeNetwork(network: network)) - app.rust.selectLatestOrNewWallet() + app.trySelectLatestOrNewWallet() } pendingNetwork = nil } diff --git a/ios/Cove/Flows/SettingsFlow/WalletSettings/WalletSettingsContainer.swift b/ios/Cove/Flows/SettingsFlow/WalletSettings/WalletSettingsContainer.swift index 9d84fcf05..54609a023 100644 --- a/ios/Cove/Flows/SettingsFlow/WalletSettings/WalletSettingsContainer.swift +++ b/ios/Cove/Flows/SettingsFlow/WalletSettings/WalletSettingsContainer.swift @@ -58,7 +58,7 @@ struct WalletSettingsContainer: View { guard let error else { return } Log.error(error) try? await Task.sleep(for: .seconds(5)) - app.rust.selectLatestOrNewWallet() + app.trySelectLatestOrNewWallet() } .onAppear(perform: initOnAppear) } diff --git a/ios/Cove/HomeScreens/ListWalletsScreen.swift b/ios/Cove/HomeScreens/ListWalletsScreen.swift index d7749cf59..e9762e5a7 100644 --- a/ios/Cove/HomeScreens/ListWalletsScreen.swift +++ b/ios/Cove/HomeScreens/ListWalletsScreen.swift @@ -29,7 +29,7 @@ struct ListWalletsScreen: View { return app.loadAndReset(to: Route.newWallet(.select)) } - do { return try app.rust.selectWallet(id: wallet.id) + do { return try app.selectWalletOrThrow(wallet.id) } catch { app.loadAndReset(to: Route.newWallet(.select)) } } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/ios/Cove/LoadAndResetContainer.swift b/ios/Cove/LoadAndResetContainer.swift index d8471a25d..409400e0e 100644 --- a/ios/Cove/LoadAndResetContainer.swift +++ b/ios/Cove/LoadAndResetContainer.swift @@ -8,15 +8,21 @@ import SwiftUI struct LoadAndResetContainer: View { @Environment(AppManager.self) var app + let route: Route let nextRoute: [Route] let loadingTimeMs: Int var body: some View { ProgressView() - .task { + .task(id: route) { do { + let generation = await app.captureLoadAndResetGeneration() try await Task.sleep(for: .milliseconds(loadingTimeMs)) - app.rust.resetAfterLoading(to: nextRoute) + await app.resetAfterLoadingIfCurrent( + generation: generation, + route: route, + nextRoute: nextRoute + ) } catch {} } .tint(.primary) diff --git a/ios/Cove/RouteView.swift b/ios/Cove/RouteView.swift index aa9f5e4fd..aaa3190b0 100644 --- a/ios/Cove/RouteView.swift +++ b/ios/Cove/RouteView.swift @@ -35,7 +35,7 @@ struct RouteView: View { Group { switch route { case let .loadAndReset(resetTo: routes, afterMillis: time): - LoadAndResetContainer(nextRoute: routes.routes, loadingTimeMs: Int(time)) + LoadAndResetContainer(route: route, nextRoute: routes.routes, loadingTimeMs: Int(time)) case let .settings(route): SettingsContainer(route: route) case let .newWallet(route: route): diff --git a/ios/Cove/ScanManager.swift b/ios/Cove/ScanManager.swift index a20564d4d..3910cdf68 100644 --- a/ios/Cove/ScanManager.swift +++ b/ios/Cove/ScanManager.swift @@ -139,7 +139,7 @@ extension ScanManager { do { let manager = ImportWalletManager() let walletMetadata = try manager.rust.importWallet(enteredWords: [words]) - try app.rust.selectWallet(id: walletMetadata.id) + try app.selectWalletOrThrow(walletMetadata.id) } catch let error as ImportWalletError { switch error { case let .InvalidWordGroup(error): @@ -170,7 +170,7 @@ extension ScanManager { Log.debug("Imported Wallet: \(id)") app.alertState = TaggedItem(.importedSuccessfully) - if app.walletManager?.id != id { try app.rust.selectWallet(id: id) } + if app.walletManager?.id != id { try app.selectWalletOrThrow(id) } if app.walletManager?.id == id, app.walletManager?.walletMetadata.walletType != .hot { try app.walletManager?.rust.setWalletType(walletType: .cold) @@ -178,7 +178,7 @@ extension ScanManager { } catch let WalletError.WalletAlreadyExists(id) { app.alertState = TaggedItem(.duplicateWallet(walletId: id)) - if (try? app.rust.selectWallet(id: id)) == nil { + if (try? app.selectWalletOrThrow(id)) == nil { app.alertState = TaggedItem(.unableToSelectWallet) } } catch { diff --git a/ios/Cove/Views/SidebarView.swift b/ios/Cove/Views/SidebarView.swift index d1eefc1c7..992ec9b40 100644 --- a/ios/Cove/Views/SidebarView.swift +++ b/ios/Cove/Views/SidebarView.swift @@ -9,7 +9,6 @@ import SwiftUI struct SidebarView: View { @Environment(AppManager.self) private var app - @Environment(\.navigate) private var navigate let currentRoute: Route @@ -45,7 +44,7 @@ struct SidebarView: View { Spacer() - Button(action: app.nfcReader.scan) { + Button(action: app.closeSidebarAndScanNfc) { Image(systemName: "wave.3.right") } .foregroundStyle(.white) @@ -69,7 +68,7 @@ struct SidebarView: View { VStack(spacing: 12) { ForEach(app.wallets, id: \.id) { wallet in Button(action: { - goTo(Route.selectedWallet(wallet.id)) + app.closeSidebarAndSelectWallet(wallet.id) }) { HStack(spacing: 10) { Circle() @@ -95,8 +94,7 @@ struct SidebarView: View { ) .contextMenu { Button("Settings") { - app.isSidebarVisible = false - app.pushRoutes(RouteFactory().nestedWalletSettings(id: wallet.id)) + app.closeSidebarAndOpenWalletSettings(wallet.id) } } } @@ -109,7 +107,7 @@ struct SidebarView: View { .overlay(.coveLightGray) .opacity(0.50) - Button(action: { goTo(RouteFactory().newWalletSelect()) }) { + Button(action: app.closeSidebarAndOpenNewWallet) { HStack(spacing: 20) { Image(systemName: "wallet.bifold") Text("Add Wallet") @@ -120,7 +118,7 @@ struct SidebarView: View { .contentShape(Rectangle()) } - Button(action: { goTo(Route.settings(.main)) }) { + Button(action: app.closeSidebarAndOpenSettings) { HStack(spacing: 22) { Image(systemName: "gear") Text("Settings") @@ -137,37 +135,4 @@ struct SidebarView: View { .frame(maxWidth: .infinity) .background(.midnightBlue) } - - func goTo(_ route: Route) { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - app.isSidebarVisible = false - } - - Task { - try? await Task.sleep(for: .milliseconds(200)) - await navigateRoute(route) - } - } - - private func navigateRouteOnMain(_ route: Route) { - navigate(route) - } - - private func navigateRoute(_ route: Route) async { - do { - if case let Route.selectedWallet(id: id) = route { - try app.rust.selectWallet(id: id) - return - } - - if !app.hasWallets, route == Route.newWallet(.select) { - app.resetRoute(to: [RouteFactory().newWalletSelect()]) - return - } - } catch { - Log.error("Failed to select wallet \(error)") - } - - navigateRouteOnMain(route) - } } diff --git a/ios/CoveCore/Sources/CoveCore/generated/cove.swift b/ios/CoveCore/Sources/CoveCore/generated/cove.swift index 6c34a4005..9d3d8c549 100644 --- a/ios/CoveCore/Sources/CoveCore/generated/cove.swift +++ b/ios/CoveCore/Sources/CoveCore/generated/cove.swift @@ -2879,7 +2879,7 @@ public protocol FfiAppProtocol: AnyObject, Sendable { /** * Frontend calls this method to send events to the rust application logic */ - func dispatch(action: AppAction) + func dispatch(action: AppAction) throws func emailMailto(ios: String) -> String @@ -2960,17 +2960,6 @@ public protocol FfiAppProtocol: AnyObject, Sendable { */ func saveTapSignerBackup(tapSigner: TapSigner, backup: Data) -> Bool - /** - * Select the latest (most recently used) wallet or navigate to new wallet flow - * This selects the wallet with the most recent scan activity - */ - func selectLatestOrNewWallet() - - /** - * Select a wallet - */ - func selectWallet(id: WalletId, nextRoute: Route?) throws - func state() -> AppState /** @@ -3101,7 +3090,7 @@ open func deleteCorruptedWallet(id: WalletId) {try! rustCall() { /** * Frontend calls this method to send events to the rust application logic */ -open func dispatch(action: AppAction) {try! rustCall() { +open func dispatch(action: AppAction)throws {try rustCallWithError(FfiConverterTypeAppError_lift) { uniffi_cove_fn_method_ffiapp_dispatch( self.uniffiCloneHandle(), FfiConverterTypeAppAction_lower(action),$0 @@ -3317,29 +3306,6 @@ open func saveTapSignerBackup(tapSigner: TapSigner, backup: Data) -> Bool { FfiConverterData.lower(backup),$0 ) }) -} - - /** - * Select the latest (most recently used) wallet or navigate to new wallet flow - * This selects the wallet with the most recent scan activity - */ -open func selectLatestOrNewWallet() {try! rustCall() { - uniffi_cove_fn_method_ffiapp_select_latest_or_new_wallet( - self.uniffiCloneHandle(),$0 - ) -} -} - - /** - * Select a wallet - */ -open func selectWallet(id: WalletId, nextRoute: Route? = nil)throws {try rustCallWithError(FfiConverterTypeDatabaseError_lift) { - uniffi_cove_fn_method_ffiapp_select_wallet( - self.uniffiCloneHandle(), - FfiConverterTypeWalletId_lower(id), - FfiConverterOptionTypeRoute.lower(nextRoute),$0 - ) -} } open func state() -> AppState { @@ -7077,8 +7043,6 @@ public func FfiConverterTypeRustAuthManager_lower(_ value: RustAuthManager) -> U public protocol RustCloudBackupManagerProtocol: AnyObject, Sendable { - func dispatch(action: CloudBackupManagerAction) - /** * Back up a newly created wallet, fire-and-forget * @@ -7142,6 +7106,8 @@ public protocol RustCloudBackupManagerProtocol: AnyObject, Sendable { */ func verifyBackupIntegrity() async -> String? + func dispatch(action: CloudBackupManagerAction) + } open class RustCloudBackupManager: RustCloudBackupManagerProtocol, @unchecked Sendable { fileprivate let handle: UInt64 @@ -7203,14 +7169,6 @@ public convenience init() { -open func dispatch(action: CloudBackupManagerAction) {try! rustCall() { - uniffi_cove_fn_method_rustcloudbackupmanager_dispatch( - self.uniffiCloneHandle(), - FfiConverterTypeCloudBackupManagerAction_lower(action),$0 - ) -} -} - /** * Back up a newly created wallet, fire-and-forget * @@ -7375,6 +7333,14 @@ open func verifyBackupIntegrity()async -> String? { ) } +open func dispatch(action: CloudBackupManagerAction) {try! rustCall() { + uniffi_cove_fn_method_rustcloudbackupmanager_dispatch( + self.uniffiCloneHandle(), + FfiConverterTypeCloudBackupManagerAction_lower(action),$0 + ) +} +} + } @@ -16064,6 +16030,9 @@ public enum AppAction { case pushRoute(Route ) case popRoute + case selectWallet(id: WalletId + ) + case selectLatestOrNewWallet case changeNetwork(network: Network ) case changeColorScheme(ColorSchemeSelection @@ -16105,25 +16074,30 @@ public struct FfiConverterTypeAppAction: FfiConverterRustBuffer { case 3: return .popRoute - case 4: return .changeNetwork(network: try FfiConverterTypeNetwork.read(from: &buf) + case 4: return .selectWallet(id: try FfiConverterTypeWalletId.read(from: &buf) ) - case 5: return .changeColorScheme(try FfiConverterTypeColorSchemeSelection.read(from: &buf) + case 5: return .selectLatestOrNewWallet + + case 6: return .changeNetwork(network: try FfiConverterTypeNetwork.read(from: &buf) + ) + + case 7: return .changeColorScheme(try FfiConverterTypeColorSchemeSelection.read(from: &buf) ) - case 6: return .changeFiatCurrency(try FfiConverterTypeFiatCurrency.read(from: &buf) + case 8: return .changeFiatCurrency(try FfiConverterTypeFiatCurrency.read(from: &buf) ) - case 7: return .setSelectedNode(try FfiConverterTypeNode.read(from: &buf) + case 9: return .setSelectedNode(try FfiConverterTypeNode.read(from: &buf) ) - case 8: return .updateFiatPrices + case 10: return .updateFiatPrices - case 9: return .updateFees + case 11: return .updateFees - case 10: return .acceptTerms + case 12: return .acceptTerms - case 11: return .refreshAfterImport + case 13: return .refreshAfterImport default: throw UniffiInternalError.unexpectedEnumCase } @@ -16147,40 +16121,49 @@ public struct FfiConverterTypeAppAction: FfiConverterRustBuffer { writeInt(&buf, Int32(3)) - case let .changeNetwork(network): + case let .selectWallet(id): writeInt(&buf, Int32(4)) + FfiConverterTypeWalletId.write(id, into: &buf) + + + case .selectLatestOrNewWallet: + writeInt(&buf, Int32(5)) + + + case let .changeNetwork(network): + writeInt(&buf, Int32(6)) FfiConverterTypeNetwork.write(network, into: &buf) case let .changeColorScheme(v1): - writeInt(&buf, Int32(5)) + writeInt(&buf, Int32(7)) FfiConverterTypeColorSchemeSelection.write(v1, into: &buf) case let .changeFiatCurrency(v1): - writeInt(&buf, Int32(6)) + writeInt(&buf, Int32(8)) FfiConverterTypeFiatCurrency.write(v1, into: &buf) case let .setSelectedNode(v1): - writeInt(&buf, Int32(7)) + writeInt(&buf, Int32(9)) FfiConverterTypeNode.write(v1, into: &buf) case .updateFiatPrices: - writeInt(&buf, Int32(8)) + writeInt(&buf, Int32(10)) case .updateFees: - writeInt(&buf, Int32(9)) + writeInt(&buf, Int32(11)) case .acceptTerms: - writeInt(&buf, Int32(10)) + writeInt(&buf, Int32(12)) case .refreshAfterImport: - writeInt(&buf, Int32(11)) + writeInt(&buf, Int32(13)) } } @@ -16590,6 +16573,8 @@ enum AppError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError { ) case FeesError(String ) + case WalletSelection(String + ) @@ -16635,6 +16620,9 @@ public struct FfiConverterTypeAppError: FfiConverterRustBuffer { case 2: return .FeesError( try FfiConverterString.read(from: &buf) ) + case 3: return .WalletSelection( + try FfiConverterString.read(from: &buf) + ) default: throw UniffiInternalError.unexpectedEnumCase } @@ -16656,6 +16644,11 @@ public struct FfiConverterTypeAppError: FfiConverterRustBuffer { writeInt(&buf, Int32(2)) FfiConverterString.write(v1, into: &buf) + + case let .WalletSelection(v1): + writeInt(&buf, Int32(3)) + FfiConverterString.write(v1, into: &buf) + } } } @@ -30482,6 +30475,8 @@ enum WalletCreationError: Swift.Error, Equatable, Hashable, Foundation.Localized ) case Import(String ) + case Unexpected(String + ) case MultiFormat(MultiFormatError ) @@ -30538,7 +30533,10 @@ public struct FfiConverterTypeWalletCreationError: FfiConverterRustBuffer { case 5: return .Import( try FfiConverterString.read(from: &buf) ) - case 6: return .MultiFormat( + case 6: return .Unexpected( + try FfiConverterString.read(from: &buf) + ) + case 7: return .MultiFormat( try FfiConverterTypeMultiFormatError.read(from: &buf) ) @@ -30578,8 +30576,13 @@ public struct FfiConverterTypeWalletCreationError: FfiConverterRustBuffer { FfiConverterString.write(v1, into: &buf) - case let .MultiFormat(v1): + case let .Unexpected(v1): writeInt(&buf, Int32(6)) + FfiConverterString.write(v1, into: &buf) + + + case let .MultiFormat(v1): + writeInt(&buf, Int32(7)) FfiConverterTypeMultiFormatError.write(v1, into: &buf) } @@ -34745,30 +34748,6 @@ fileprivate struct FfiConverterOptionTypeOnboardingBranch: FfiConverterRustBuffe } } -#if swift(>=5.8) -@_documentation(visibility: private) -#endif -fileprivate struct FfiConverterOptionTypeRoute: FfiConverterRustBuffer { - typealias SwiftType = Route? - - public static func write(_ value: SwiftType, into buf: inout [UInt8]) { - guard let value = value else { - writeInt(&buf, Int8(0)) - return - } - writeInt(&buf, Int8(1)) - FfiConverterTypeRoute.write(value, into: &buf) - } - - public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { - switch try readInt(&buf) as Int8 { - case 0: return nil - case 1: return try FfiConverterTypeRoute.read(from: &buf) - default: throw UniffiInternalError.unexpectedOptionalTag - } - } -} - #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -36315,7 +36294,7 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_ffiapp_delete_corrupted_wallet() != 27181) { return InitializationResult.apiChecksumMismatch } - if (uniffi_cove_checksum_method_ffiapp_dispatch() != 37137) { + if (uniffi_cove_checksum_method_ffiapp_dispatch() != 7288) { return InitializationResult.apiChecksumMismatch } if (uniffi_cove_checksum_method_ffiapp_email_mailto() != 41824) { @@ -36375,12 +36354,6 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_ffiapp_save_tap_signer_backup() != 24217) { return InitializationResult.apiChecksumMismatch } - if (uniffi_cove_checksum_method_ffiapp_select_latest_or_new_wallet() != 31849) { - return InitializationResult.apiChecksumMismatch - } - if (uniffi_cove_checksum_method_ffiapp_select_wallet() != 51673) { - return InitializationResult.apiChecksumMismatch - } if (uniffi_cove_checksum_method_ffiapp_state() != 49253) { return InitializationResult.apiChecksumMismatch } @@ -36681,9 +36654,6 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_rustauthmanager_validate_security_action() != 4302) { return InitializationResult.apiChecksumMismatch } - if (uniffi_cove_checksum_method_rustcloudbackupmanager_dispatch() != 54131) { - return InitializationResult.apiChecksumMismatch - } if (uniffi_cove_checksum_method_rustcloudbackupmanager_backup_new_wallet() != 25342) { return InitializationResult.apiChecksumMismatch } @@ -36732,6 +36702,9 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_rustcloudbackupmanager_verify_backup_integrity() != 35162) { return InitializationResult.apiChecksumMismatch } + if (uniffi_cove_checksum_method_rustcloudbackupmanager_dispatch() != 23570) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_checksum_method_rustcoincontrolmanager_button_presentation() != 24764) { return InitializationResult.apiChecksumMismatch } diff --git a/rust/crates/cove-cspp/src/backup_data.rs b/rust/crates/cove-cspp/src/backup_data.rs index 8e8157d1c..7d41eac2a 100644 --- a/rust/crates/cove-cspp/src/backup_data.rs +++ b/rust/crates/cove-cspp/src/backup_data.rs @@ -45,6 +45,36 @@ pub fn wallet_record_id_from_filename(filename: &str) -> Option<&str> { filename.strip_prefix(WALLET_FILE_PREFIX).and_then(|rest| rest.strip_suffix(".json")) } +/// Supported encrypted master key backup versions +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MasterKeyBackupVersion { + V1, +} + +impl MasterKeyBackupVersion { + /// Returns the serialized backup version + pub const fn as_u32(self) -> u32 { + match self { + Self::V1 => 1, + } + } +} + +/// Encrypted master key backup version not supported by this app +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UnsupportedMasterKeyBackupVersion(pub u32); + +impl TryFrom for MasterKeyBackupVersion { + type Error = UnsupportedMasterKeyBackupVersion; + + fn try_from(version: u32) -> Result { + match version { + 1 => Ok(Self::V1), + version => Err(UnsupportedMasterKeyBackupVersion(version)), + } + } +} + /// Wallet data to be encrypted and uploaded to cloud backup #[derive(Debug, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)] pub struct WalletEntry { @@ -129,6 +159,15 @@ pub struct EncryptedMasterKeyBackup { pub ciphertext: Vec, } +impl EncryptedMasterKeyBackup { + /// Returns the parsed backup version when supported by this app + pub fn backup_version( + &self, + ) -> Result { + self.version.try_into() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/rust/crates/cove-cspp/src/master_key_crypto.rs b/rust/crates/cove-cspp/src/master_key_crypto.rs index afd4430ad..593a437f8 100644 --- a/rust/crates/cove-cspp/src/master_key_crypto.rs +++ b/rust/crates/cove-cspp/src/master_key_crypto.rs @@ -2,7 +2,7 @@ use chacha20poly1305::{ChaCha20Poly1305, KeyInit as _, aead::Aead as _}; use cove_util::ResultExt as _; use rand::RngExt as _; -use crate::backup_data::EncryptedMasterKeyBackup; +use crate::backup_data::{EncryptedMasterKeyBackup, MasterKeyBackupVersion}; use crate::error::CsppError; use crate::master_key::MasterKey; @@ -21,7 +21,12 @@ pub fn encrypt_master_key( let ciphertext = cipher.encrypt(nonce, master_key.as_bytes().as_slice()).map_err_str(CsppError::Encrypt)?; - Ok(EncryptedMasterKeyBackup { version: 1, prf_salt: *prf_salt, nonce: nonce_bytes, ciphertext }) + Ok(EncryptedMasterKeyBackup { + version: MasterKeyBackupVersion::V1.as_u32(), + prf_salt: *prf_salt, + nonce: nonce_bytes, + ciphertext, + }) } /// Decrypt a master key backup using a PRF-derived wrapping key diff --git a/rust/crates/cove-device/src/keychain.rs b/rust/crates/cove-device/src/keychain.rs index fb690ca00..cbaddeee6 100644 --- a/rust/crates/cove-device/src/keychain.rs +++ b/rust/crates/cove-device/src/keychain.rs @@ -218,6 +218,15 @@ impl Keychain { ]) } + /// Loads the stored CSPP passkey credential ID from the keychain + pub fn load_cspp_credential_id(&self) -> Option> { + self.get(CSPP_CREDENTIAL_ID_KEY.into()).and_then(|hex_str| { + hex::decode(hex_str) + .inspect_err(|error| warn!("Failed to decode stored credential_id: {error}")) + .ok() + }) + } + /// Saves CSPP passkey credentials and namespace ID to the keychain pub fn save_cspp_passkey_and_namespace( &self, @@ -739,6 +748,19 @@ mod tests { assert_eq!(kc.0.get(CSPP_NAMESPACE_ID_KEY.into()).as_deref(), Some("old_namespace")); } + #[test] + fn load_cspp_credential_id_returns_none_for_invalid_hex_and_decodes_valid_hex() { + let kc = make_keychain(MockKeychain::new()); + kc.0.save(CSPP_CREDENTIAL_ID_KEY.into(), "not-hex".into()).unwrap(); + + assert!(kc.load_cspp_credential_id().is_none()); + + let credential_id = vec![1, 2, 3, 254, 255]; + kc.0.save(CSPP_CREDENTIAL_ID_KEY.into(), hex::encode(&credential_id)).unwrap(); + + assert_eq!(kc.load_cspp_credential_id(), Some(credential_id)); + } + #[test] fn clear_cspp_passkey_removes_credential_and_salt_only() { let kc = make_keychain(MockKeychain::with_entries(vec![ diff --git a/rust/src/app.rs b/rust/src/app.rs index 9c8f25349..48c687908 100644 --- a/rust/src/app.rs +++ b/rust/src/app.rs @@ -25,7 +25,7 @@ use crate::{ wallet::metadata::{WalletId, WalletMetadata, WalletType}, }; use cove_macros::impl_default_for; -use eyre::{Context as _, ContextCompat as _}; +use cove_util::ResultExt as _; use flume::{Receiver, Sender}; use once_cell::sync::OnceCell; use parking_lot::RwLock; @@ -58,6 +58,8 @@ pub enum AppAction { UpdateRoute { routes: Vec }, PushRoute(Route), PopRoute, + SelectWallet { id: WalletId }, + SelectLatestOrNewWallet, ChangeNetwork { network: Network }, ChangeColorScheme(ColorSchemeSelection), ChangeFiatCurrency(FiatCurrency), @@ -75,6 +77,8 @@ pub enum AppError { PricesError(String), #[error("fees error: {0}")] FeesError(String), + #[error("wallet selection error: {0}")] + WalletSelection(String), } type Error = AppError; @@ -140,7 +144,14 @@ impl App { /// Handle event received from frontend pub fn handle_action(&self, event: AppAction) { - // Handle event + if let Err(error) = self.handle_action_result(event) { + error!("Unable to handle app action: {error}"); + } + } + + /// Handle event received from frontend and report action errors + pub fn handle_action_result(&self, event: AppAction) -> Result<(), AppError> { + // handle event let state = self.state.clone(); match event { AppAction::UpdateRoute { routes } => { @@ -228,6 +239,14 @@ impl App { Updater::send_update(AppMessage::RouteUpdated(routes)); } + AppAction::SelectWallet { id } => { + FfiApp::global().select_wallet(id, None).map_err_str(AppError::WalletSelection)?; + } + + AppAction::SelectLatestOrNewWallet => { + FfiApp::global().select_latest_or_new_wallet()?; + } + AppAction::AcceptTerms => { if let Err(error) = Database::global() .global_flag @@ -262,6 +281,8 @@ impl App { Updater::send_update(AppMessage::SelectedNodeChanged(config.selected_node())); } } + + Ok(()) } pub fn listen_for_updates(&self, updater: Box) { @@ -291,32 +312,6 @@ impl FfiApp { Arc::new(Self) } - /// Select a wallet - #[uniffi::method(default(next_route = None))] - pub fn select_wallet( - &self, - id: WalletId, - next_route: Option, - ) -> Result<(), DatabaseError> { - let mut deferred = DeferredDispatch::::new(); - deferred.queue(AppAction::UpdateFees); - deferred.queue(AppAction::UpdateFiatPrices); - - Database::global().global_config.select_wallet(id.clone())?; - - // update the router - if let Some(next_route) = next_route { - let wallet_route = Route::SelectedWallet(id.clone()); - let loading_route = - RouteFactory.load_and_reset_nested_to(wallet_route, vec![next_route]); - self.load_and_reset_default_route(loading_route); - } else { - self.go_to_selected_wallet(); - } - - Ok(()) - } - /// Find tapsigner wallet by card ident /// Get the backup for the tap signer #[uniffi::method] @@ -436,15 +431,6 @@ impl FfiApp { self.num_wallets() > 0 } - /// Select the latest (most recently used) wallet or navigate to new wallet flow - /// This selects the wallet with the most recent scan activity - pub fn select_latest_or_new_wallet(&self) { - if let Err(error) = self.select_latest_wallet() { - debug!("unable to select latest wallet: {error}"); - self.load_and_reset_default_route(Route::NewWallet(NewWalletRoute::default())); - } - } - /// Number of wallets pub fn num_wallets(&self) -> u16 { let network = Database::global().global_config.selected_network(); @@ -596,8 +582,8 @@ impl FfiApp { /// Frontend calls this method to send events to the rust application logic #[uniffi::method(name = "dispatch")] - fn ffi_dispatch(&self, action: AppAction) { - self.inner().handle_action(action); + fn ffi_dispatch(&self, action: AppAction) -> Result<(), Error> { + self.inner().handle_action_result(action) } pub fn listen_for_updates(&self, updater: Box) { @@ -641,20 +627,63 @@ impl FfiApp { App::global() } - fn select_latest_wallet(&self) -> Result<(), eyre::Error> { + pub(crate) fn select_wallet( + &self, + id: WalletId, + next_route: Option, + ) -> Result<(), DatabaseError> { + let mut deferred = DeferredDispatch::::new(); + deferred.queue(AppAction::UpdateFees); + deferred.queue(AppAction::UpdateFiatPrices); + + Database::global().global_config.select_wallet(id.clone())?; + + if let Some(next_route) = next_route { + let wallet_route = Route::SelectedWallet(id.clone()); + let loading_route = + RouteFactory.load_and_reset_nested_to(wallet_route, vec![next_route]); + self.load_and_reset_default_route(loading_route); + } else { + self.go_to_selected_wallet(); + } + + Ok(()) + } + + pub(crate) fn select_latest_or_new_wallet(&self) -> Result<(), AppError> { + match self.select_latest_wallet() { + Ok(()) => Ok(()), + Err(SelectLatestWalletError::NoWalletsFound) => { + self.load_and_reset_default_route(Route::NewWallet(NewWalletRoute::default())); + Ok(()) + } + Err(SelectLatestWalletError::WalletSelection(error)) => Err(error), + } + } + + fn select_latest_wallet(&self) -> Result<(), SelectLatestWalletError> { let database = Database::global(); - let wallets = - database.wallets().all_sorted_active().context("unable to get sorted wallets")?; - let latest_wallet = wallets.first().context("no wallets found")?; + let wallets = database + .wallets() + .all_sorted_active() + .map_err_prefix("unable to get sorted wallets", AppError::WalletSelection) + .map_err(SelectLatestWalletError::WalletSelection)?; + let latest_wallet = wallets.first().ok_or(SelectLatestWalletError::NoWalletsFound)?; self.select_wallet(latest_wallet.id.clone(), None) - .context("unable to select latest wallet")?; + .map_err_prefix("unable to select latest wallet", AppError::WalletSelection) + .map_err(SelectLatestWalletError::WalletSelection)?; Ok(()) } } +enum SelectLatestWalletError { + NoWalletsFound, + WalletSelection(AppError), +} + /// Initialize the global App instance (Updater, router, state) /// Must be called after storage bootstrap completes #[uniffi::export] diff --git a/rust/src/keys.rs b/rust/src/keys.rs index fcb8f568f..c5ff35052 100644 --- a/rust/src/keys.rs +++ b/rust/src/keys.rs @@ -162,25 +162,11 @@ impl Descriptor { keychain_kind: KeychainKind, network: Network, ) -> Self { - let derivable_key = &secret_key.0; + let (extended_descriptor, key_map, _) = Bip84(secret_key.xprv(), keychain_kind) + .build(network.into()) + .expect("bip84 descriptor template should build from mnemonic xprv"); - match derivable_key { - BdkDescriptorSecretKey::XPrv(descriptor_x_key) => { - let derivable_key = descriptor_x_key.xkey; - let (extended_descriptor, key_map, _) = - Bip84(derivable_key, keychain_kind).build(network.into()).unwrap(); - - Self { extended_descriptor, key_map } - } - - BdkDescriptorSecretKey::MultiXPrv(_) => { - unreachable!() - } - - BdkDescriptorSecretKey::Single(_) => { - unreachable!() - } - } + Self { extended_descriptor, key_map } } /// BIP49 for P2WPKH-nested-in-P2SH (Wrapped Segwit) @@ -189,22 +175,10 @@ impl Descriptor { keychain_kind: KeychainKind, network: Network, ) -> Self { - let derivable_key = &secret_key.0; - - match derivable_key { - BdkDescriptorSecretKey::Single(_) => { - unreachable!() - } - BdkDescriptorSecretKey::XPrv(descriptor_x_key) => { - let derivable_key = descriptor_x_key.xkey; - let (extended_descriptor, key_map, _) = - Bip49(derivable_key, keychain_kind).build(network.into()).unwrap(); - Self { extended_descriptor, key_map } - } - BdkDescriptorSecretKey::MultiXPrv(_) => { - unreachable!() - } - } + let (extended_descriptor, key_map, _) = Bip49(secret_key.xprv(), keychain_kind) + .build(network.into()) + .expect("bip49 descriptor template should build from mnemonic xprv"); + Self { extended_descriptor, key_map } } /// BIP44 for P2PKH (Legacy) @@ -213,22 +187,10 @@ impl Descriptor { keychain_kind: KeychainKind, network: Network, ) -> Self { - let derivable_key = &secret_key.0; - - match derivable_key { - BdkDescriptorSecretKey::Single(_) => { - unreachable!() - } - BdkDescriptorSecretKey::XPrv(descriptor_x_key) => { - let derivable_key = descriptor_x_key.xkey; - let (extended_descriptor, key_map, _) = - Bip44(derivable_key, keychain_kind).build(network.into()).unwrap(); - Self { extended_descriptor, key_map } - } - BdkDescriptorSecretKey::MultiXPrv(_) => { - unreachable!() - } - } + let (extended_descriptor, key_map, _) = Bip44(secret_key.xprv(), keychain_kind) + .build(network.into()) + .expect("bip44 descriptor template should build from mnemonic xprv"); + Self { extended_descriptor, key_map } } pub fn into_tuple(self) -> (ExtendedDescriptor, KeyMap) { @@ -237,6 +199,14 @@ impl Descriptor { } impl DescriptorSecretKey { + fn xprv(&self) -> bitcoin::bip32::Xpriv { + let BdkDescriptorSecretKey::XPrv(descriptor_x_key) = &self.0 else { + panic!("descriptor secret key must be an xprv") + }; + + descriptor_x_key.xkey + } + pub(crate) fn new(network: Network, mnemonic: Mnemonic, passphrase: Option) -> Self { let seed: Seed = mnemonic.to_seed(passphrase.as_deref().unwrap_or("")); let xkey: ExtendedKey = seed.into_extended_key().unwrap(); diff --git a/rust/src/manager.rs b/rust/src/manager.rs index 86e63c5f8..623ed4763 100644 --- a/rust/src/manager.rs +++ b/rust/src/manager.rs @@ -1,5 +1,4 @@ pub mod auth_manager; -pub mod cloud_backup_detail_manager; pub mod cloud_backup_manager; pub mod coin_control_manager; pub mod connectivity_manager; diff --git a/rust/src/manager/cloud_backup_manager.rs b/rust/src/manager/cloud_backup_manager.rs index f58393905..36976bbb1 100644 --- a/rust/src/manager/cloud_backup_manager.rs +++ b/rust/src/manager/cloud_backup_manager.rs @@ -1,4 +1,5 @@ mod cloud_inventory; +mod detail; mod ops; mod pending; mod prompt; @@ -43,15 +44,15 @@ use crate::wallet::metadata::{ }; use self::cloud_inventory::RemoteWalletTruth; +pub use self::detail::{ + CloudOnlyOperation, CloudOnlyState, RecoveryAction, RecoveryState, SyncState, VerificationState, +}; use self::prompt::CloudBackupPromptState; use self::runtime_actor::{CloudBackupOperation, CloudBackupRuntimeActor, RestoreOperation}; use self::wallets::wallet_metadata_change_requires_upload; use self::wallets::{ UnpersistedPrfKey, WalletBackupLookup, WalletBackupReader, all_local_wallets, count_all_wallets, }; -use super::cloud_backup_detail_manager::{ - CloudOnlyOperation, CloudOnlyState, RecoveryState, SyncState, VerificationState, -}; use super::connectivity_manager::CONNECTIVITY_MANAGER; type LocalWalletSecret = crate::backup::model::WalletSecret; @@ -544,6 +545,9 @@ pub(crate) enum CloudBackupError { #[error("internal error: {0}")] Internal(String), + #[error("compatibility error: {0}")] + Compatibility(String), + #[error("Passkey didn't match any backups, please try a new one")] PasskeyMismatch, @@ -649,6 +653,7 @@ impl RustCloudBackupManager { | CloudBackupError::Passkey(_) | CloudBackupError::Crypto(_) | CloudBackupError::Internal(_) + | CloudBackupError::Compatibility(_) | CloudBackupError::PasskeyMismatch | CloudBackupError::PasskeyDiscoveryCancelled | CloudBackupError::Cancelled => CloudStorageIssue::Other, diff --git a/rust/src/manager/cloud_backup_detail_manager.rs b/rust/src/manager/cloud_backup_manager/detail.rs similarity index 88% rename from rust/src/manager/cloud_backup_detail_manager.rs rename to rust/src/manager/cloud_backup_manager/detail.rs index 7f56d323a..18275ff5d 100644 --- a/rust/src/manager/cloud_backup_detail_manager.rs +++ b/rust/src/manager/cloud_backup_manager/detail.rs @@ -1,7 +1,7 @@ use act_zero::send; use tracing::error; -use super::cloud_backup_manager::{ +use super::{ CLOUD_BACKUP_MANAGER, CloudBackupError, CloudBackupManagerAction, CloudBackupPasskeyChoiceFlow, CloudBackupWalletItem, DeepVerificationFailure, DeepVerificationReport, DeepVerificationResult, RustCloudBackupManager, runtime_actor::CloudBackupOperation, @@ -60,53 +60,54 @@ pub enum CloudOnlyOperation { impl RustCloudBackupManager { #[uniffi::method] pub fn dispatch(&self, action: Action) { + use Action as A; match action { - Action::EnableCloudBackup => { + A::EnableCloudBackup => { self.clear_passkey_choice_prompt(); self.enable_cloud_backup(); } - Action::EnableCloudBackupForceNew => { + A::EnableCloudBackupForceNew => { self.clear_existing_backup_found_prompt(); self.enable_cloud_backup_force_new(); } - Action::EnableCloudBackupNoDiscovery => { + A::EnableCloudBackupNoDiscovery => { self.clear_existing_backup_found_prompt(); self.clear_passkey_choice_prompt(); self.enable_cloud_backup_no_discovery(); } - Action::DiscardPendingEnableCloudBackup => { + A::DiscardPendingEnableCloudBackup => { self.discard_pending_enable_cloud_backup(); } - Action::DismissPasskeyChoicePrompt => self.clear_passkey_choice_prompt(), - Action::DismissMissingPasskeyReminder => self.dismiss_missing_passkey_prompt(), - Action::RestoreFromCloudBackup => self.restore_from_cloud_backup(), - Action::CancelRestore => self.cancel_restore(), - Action::StartVerification => self.start_verification(), - Action::StartVerificationDiscoverable => self.start_verification_discoverable(), - Action::DismissVerificationPrompt => self.dismiss_verification_prompt(), - Action::RecreateManifest => { + A::DismissPasskeyChoicePrompt => self.clear_passkey_choice_prompt(), + A::DismissMissingPasskeyReminder => self.dismiss_missing_passkey_prompt(), + A::RestoreFromCloudBackup => self.restore_from_cloud_backup(), + A::CancelRestore => self.cancel_restore(), + A::StartVerification => self.start_verification(), + A::StartVerificationDiscoverable => self.start_verification_discoverable(), + A::DismissVerificationPrompt => self.dismiss_verification_prompt(), + A::RecreateManifest => { CLOUD_BACKUP_MANAGER.clone().spawn_recovery(RecoveryAction::RecreateManifest); } - Action::ReinitializeBackup => { + A::ReinitializeBackup => { CLOUD_BACKUP_MANAGER.clone().spawn_recovery(RecoveryAction::ReinitializeBackup); } - Action::RepairPasskey => { + A::RepairPasskey => { self.clear_passkey_choice_prompt(); CLOUD_BACKUP_MANAGER.clone().spawn_repair_passkey(false); } - Action::RepairPasskeyNoDiscovery => { + A::RepairPasskeyNoDiscovery => { self.clear_passkey_choice_prompt(); CLOUD_BACKUP_MANAGER.clone().spawn_repair_passkey(true); } - Action::SyncUnsynced => CLOUD_BACKUP_MANAGER.clone().spawn_sync(), - Action::FetchCloudOnly => CLOUD_BACKUP_MANAGER.clone().spawn_fetch_cloud_only(), - Action::RestoreCloudWallet { record_id } => { + A::SyncUnsynced => CLOUD_BACKUP_MANAGER.clone().spawn_sync(), + A::FetchCloudOnly => CLOUD_BACKUP_MANAGER.clone().spawn_fetch_cloud_only(), + A::RestoreCloudWallet { record_id } => { CLOUD_BACKUP_MANAGER.clone().spawn_restore_cloud_wallet(record_id); } - Action::DeleteCloudWallet { record_id } => { + A::DeleteCloudWallet { record_id } => { CLOUD_BACKUP_MANAGER.clone().spawn_delete_cloud_wallet(record_id); } - Action::RefreshDetail => CLOUD_BACKUP_MANAGER.clone().spawn_refresh_detail(), + A::RefreshDetail => CLOUD_BACKUP_MANAGER.clone().spawn_refresh_detail(), } } } @@ -223,10 +224,7 @@ impl RustCloudBackupManager { }; let should_auto_verify = match action { RecoveryAction::ReinitializeBackup => { - matches!( - self.current_status(), - super::cloud_backup_manager::CloudBackupStatus::Enabled - ) + matches!(self.current_status(), super::CloudBackupStatus::Enabled) } RecoveryAction::RecreateManifest | RecoveryAction::RepairPasskey => true, }; @@ -294,7 +292,7 @@ impl RustCloudBackupManager { async fn run_reinitialize_backup(&self) -> Result<(), CloudBackupError> { if !self.begin_background_operation( "reinitialize_cloud_backup", - Some(super::cloud_backup_manager::CloudBackupStatus::Enabling), + Some(super::CloudBackupStatus::Enabling), ) { return Err(CloudBackupError::RecoveryRequired( "cloud backup operation already running".into(), @@ -404,10 +402,10 @@ impl RustCloudBackupManager { self.refresh_sync_health(); if let Some(result) = self.refresh_cloud_backup_detail().await { match result { - super::cloud_backup_manager::CloudBackupDetailResult::Success(detail) => { + super::CloudBackupDetailResult::Success(detail) => { self.set_detail(Some(detail)); } - super::cloud_backup_manager::CloudBackupDetailResult::AccessError(error) => { + super::CloudBackupDetailResult::AccessError(error) => { error!("Failed to refresh detail: {error}"); } } diff --git a/rust/src/manager/cloud_backup_manager/ops.rs b/rust/src/manager/cloud_backup_manager/ops.rs index e4bf6aef3..e903c939a 100644 --- a/rust/src/manager/cloud_backup_manager/ops.rs +++ b/rust/src/manager/cloud_backup_manager/ops.rs @@ -9,11 +9,10 @@ use zeroize::Zeroizing; use super::cloud_inventory::CloudWalletInventory; use super::wallets::{ - DownloadedWalletBackup, NamespaceMatchOutcome, UnpersistedPrfKey, WalletBackupLookup, - WalletBackupReader, WalletRestoreSession, all_local_wallets, create_new_prf_key, - discover_or_create_prf_key_without_persisting, persist_enabled_cloud_backup_state, - persist_enabled_cloud_backup_state_reset_verification, try_match_namespace_with_passkey, - upload_all_wallets, + DownloadedWalletBackup, NamespaceMatch, NamespaceMatchOutcome, NamespacePasskeyMatcher, + PasskeyMaterialAcquirer, UnpersistedPrfKey, WalletBackupLookup, WalletBackupReader, + WalletRestoreSession, all_local_wallets, persist_enabled_cloud_backup_state, + persist_enabled_cloud_backup_state_reset_verification, upload_all_wallets, }; use super::{ @@ -42,6 +41,23 @@ enum EnablePasskeyAcquisition { Cancelled, } +struct RestorableNamespace { + namespace_id: String, + master_key: cove_cspp::master_key::MasterKey, + passkey: Option, +} + +#[derive(Clone)] +struct RestorableNamespacePasskey { + credential_id: Vec, + prf_salt: [u8; 32], +} + +struct RestoreDownloadProgress { + completed: u32, + total: u32, +} + impl RustCloudBackupManager { async fn lookup_wallet_backup( reader: WalletBackupReader, @@ -435,7 +451,7 @@ impl RustCloudBackupManager { } // no local master key — check iCloud for existing namespaces to recover - let namespaces = cloud + let mut namespaces = cloud .list_namespaces() .await .map_err(|error| { @@ -447,6 +463,7 @@ impl RustCloudBackupManager { ), ) })?; + namespaces.sort(); if namespaces.is_empty() { return self.do_enable_cloud_backup_create_new().await; @@ -454,8 +471,23 @@ impl RustCloudBackupManager { info!("Enable: found {} existing namespace(s), attempting recovery", namespaces.len()); - match try_match_namespace_with_passkey(&cloud, passkey, &namespaces).await? { - NamespaceMatchOutcome::Matched(matched) => { + let matcher = NamespacePasskeyMatcher::new(&cloud, passkey); + let match_outcome = matcher.match_namespaces(&namespaces).await?; + match match_outcome { + NamespaceMatchOutcome::Matched(matches) => { + let matched_count = matches.len(); + if matched_count > 1 { + return Err(CloudBackupError::Internal(format!( + "passkey matched {matched_count} cloud backup namespaces; choosing one is ambiguous" + ))); + } + + let Some(matched) = matches.into_iter().next() else { + self.set_existing_backup_found_prompt(); + self.clear_enable_progress(CloudBackupStatus::Disabled); + return Ok(()); + }; + self.complete_recovery(keychain, &cloud, &cspp, matched).await } @@ -561,7 +593,10 @@ impl RustCloudBackupManager { had_local_master_key, "Enable cancelled before passkey setup finished", "Enable failed before passkey setup finished", - || discover_or_create_prf_key_without_persisting(passkey_access), + || { + let acquirer = PasskeyMaterialAcquirer::new(passkey_access); + async move { acquirer.discover_or_create_for_enable().await } + }, ) .await? { @@ -599,7 +634,7 @@ impl RustCloudBackupManager { /// Same as `do_enable_cloud_backup_create_new` but skips passkey discovery, /// going straight to passkey registration - pub(super) async fn do_enable_cloud_backup_no_discovery(&self) -> Result<(), CloudBackupError> { + pub(crate) async fn do_enable_cloud_backup_no_discovery(&self) -> Result<(), CloudBackupError> { self.ensure_cloud_connectivity(BlockingCloudStep::Enable)?; if let Some(pending) = self.take_retry_pending_enable_session() { let (master_key, passkey) = pending.into_parts(); @@ -645,7 +680,10 @@ impl RustCloudBackupManager { had_local_master_key, "Enable (no discovery) cancelled before passkey setup finished", "Enable (no discovery) failed before passkey setup finished", - || create_new_prf_key(passkey_access, "Creating new passkey"), + || { + let acquirer = PasskeyMaterialAcquirer::new(passkey_access); + async move { acquirer.create_for_enable().await } + }, ) .await? { @@ -696,28 +734,23 @@ impl RustCloudBackupManager { // passkey matching first, local master key as fallback let passkey = PasskeyAccess::global(); - let (master_key, namespace_id) = match self - .restore_via_passkey_matching(&cloud, passkey) - .await - { - Ok(matched) => { - operation.run_result(|| { - cspp.save_master_key(&matched.master_key) - .map_err_prefix("save master key", CloudBackupError::Internal)?; - Ok(()) - })?; - operation.run_result(|| { - keychain - .save_cspp_passkey_and_namespace( - &matched.credential_id, - matched.prf_salt, - &matched.namespace_id, - ) - .map_err_prefix("save cspp credentials", CloudBackupError::Internal)?; - Ok(()) - })?; + let restorable_namespaces = match self.restore_via_passkey_matching(&cloud, passkey).await { + Ok(matches) => { + if matches.is_empty() { + return Err(CloudBackupError::PasskeyMismatch); + } - (matched.master_key, matched.namespace_id) + matches + .into_iter() + .map(|matched| RestorableNamespace { + namespace_id: matched.namespace_id, + master_key: matched.master_key, + passkey: Some(RestorableNamespacePasskey { + credential_id: matched.credential_id, + prf_salt: matched.prf_salt, + }), + }) + .collect::>() } Err(CloudBackupError::PasskeyDiscoveryCancelled) => { info!("Restore: passkey discovery cancelled"); @@ -733,26 +766,30 @@ impl RustCloudBackupManager { persist_namespace_id(keychain, &namespace_id)?; Ok(()) })?; - (master_key, namespace_id) + vec![RestorableNamespace { namespace_id, master_key, passkey: None }] } Err(e) => return Err(e), }; // download and restore wallets self.ensure_current_restore_operation(operation)?; - let wallet_record_ids = - cloud.list_wallet_backups(namespace_id.clone()).await.map_err(|error| { - self.blocking_cloud_error( - BlockingCloudStep::Restore, - CloudBackupError::cloud_storage_context("list wallet backups", error), - ) - })?; + let mut namespace_wallets = Vec::with_capacity(restorable_namespaces.len()); + let mut wallet_count = 0; + + for namespace in restorable_namespaces { + let wallet_record_ids = cloud + .list_wallet_backups(namespace.namespace_id.clone()) + .await + .map_err(|error| { + self.blocking_cloud_error( + BlockingCloudStep::Restore, + CloudBackupError::cloud_storage_context("list wallet backups", error), + ) + })?; + wallet_count += wallet_record_ids.len() as u32; + namespace_wallets.push((namespace, wallet_record_ids)); + } - let reader = WalletBackupReader::new( - cloud.clone(), - namespace_id.clone(), - Zeroizing::new(master_key.critical_data_key()), - ); let mut report = CloudBackupRestoreReport { wallets_restored: 0, wallets_failed: 0, @@ -764,10 +801,40 @@ impl RustCloudBackupManager { let existing_fingerprints = crate::backup::import::collect_existing_fingerprints() .map_err_prefix("collect fingerprints", CloudBackupError::Internal)?; let mut restore_session = WalletRestoreSession::new(existing_fingerprints); + let mut downloaded_wallets = Vec::new(); + let mut download_progress = RestoreDownloadProgress { completed: 0, total: wallet_count }; + + self.send_restore_progress( + operation, + CloudBackupRestoreStage::Downloading, + 0, + Some(wallet_count), + )?; + + for (namespace_index, (namespace, wallet_record_ids)) in + namespace_wallets.iter().enumerate() + { + let reader = WalletBackupReader::new( + cloud.clone(), + namespace.namespace_id.clone(), + Zeroizing::new(namespace.master_key.critical_data_key()), + ); + let namespace_downloaded = self + .download_wallets_for_restore( + operation, + &reader, + &namespace.namespace_id, + wallet_record_ids, + &mut report, + &mut download_progress, + ) + .await?; + + downloaded_wallets.extend( + namespace_downloaded.into_iter().map(|downloaded| (namespace_index, downloaded)), + ); + } - let downloaded_wallets = self - .download_wallets_for_restore(operation, &reader, &wallet_record_ids, &mut report) - .await?; let restore_total = downloaded_wallets.len() as u32; self.send_restore_progress( @@ -777,9 +844,12 @@ impl RustCloudBackupManager { Some(restore_total), )?; - for (index, (record_id, wallet)) in downloaded_wallets.iter().enumerate() { + let mut first_success_namespace_index = None; + for (index, (namespace_index, (record_id, wallet))) in downloaded_wallets.iter().enumerate() + { match operation.run_result(|| restore_session.restore_downloaded(wallet)) { Ok(outcome) => { + first_success_namespace_index.get_or_insert(*namespace_index); report.wallets_restored += 1; if let Some(warning) = outcome.labels_warning { report.labels_failed_wallet_names.push(warning.wallet_name); @@ -808,11 +878,6 @@ impl RustCloudBackupManager { return Err(CloudBackupError::Internal("all wallets failed to restore".into())); } - let wallet_count = cloud - .list_wallet_backups(namespace_id.clone()) - .await - .map(|record_ids| record_ids.len() as u32) - .unwrap_or(wallet_record_ids.len() as u32); let now = jiff::Timestamp::now().as_second().try_into().unwrap_or(0); let state = PersistedCloudBackupState { status: PersistedCloudBackupStatus::Enabled, @@ -828,6 +893,28 @@ impl RustCloudBackupManager { &state, "persist restored cloud backup state", )?; + if let Some(active_namespace_index) = first_success_namespace_index + && let Some((active, _)) = namespace_wallets.get(active_namespace_index) + { + operation.run_result(|| { + cspp.save_master_key(&active.master_key) + .map_err_prefix("save master key", CloudBackupError::Internal)?; + Ok(()) + })?; + + if let Some(passkey) = active.passkey.as_ref() { + operation.run_result(|| { + keychain + .save_cspp_passkey_and_namespace( + &passkey.credential_id, + passkey.prf_salt, + &active.namespace_id, + ) + .map_err_prefix("save cspp credentials", CloudBackupError::Internal)?; + Ok(()) + })?; + } + } self.set_restore_progress_for_restore_operation(operation, None)?; self.set_restore_report_for_restore_operation(operation, Some(report))?; @@ -841,18 +928,11 @@ impl RustCloudBackupManager { &self, operation: &RestoreOperation, reader: &WalletBackupReader, + namespace_id: &str, wallet_record_ids: &[String], report: &mut CloudBackupRestoreReport, + progress: &mut RestoreDownloadProgress, ) -> Result, CloudBackupError> { - let total = wallet_record_ids.len() as u32; - - self.send_restore_progress( - operation, - CloudBackupRestoreStage::Downloading, - 0, - Some(total), - )?; - let mut downloaded_wallets = Vec::with_capacity(wallet_record_ids.len()); let mut lookups = stream::iter( wallet_record_ids @@ -861,26 +941,27 @@ impl RustCloudBackupManager { .map(|record_id| Self::lookup_wallet_backup(reader.clone(), record_id)), ) .buffered(CLOUD_BACKUP_IO_CONCURRENCY); - let mut completed = 0; while let Some((record_id, lookup)) = lookups.next().await { self.ensure_current_restore_operation(operation)?; + let record_name = format!("{namespace_id}/{record_id}"); + match lookup { Ok(WalletBackupLookup::Found(wallet)) => { - downloaded_wallets.push((record_id.clone(), wallet)); + downloaded_wallets.push((record_name.clone(), wallet)); } Ok(WalletBackupLookup::NotFound) => { let error = - format!("wallet {record_id} was listed but missing from cloud backup"); - warn!("Failed to download wallet {record_id}: {error}"); + format!("wallet {record_name} was listed but missing from cloud backup"); + warn!("Failed to download wallet {record_name}: {error}"); report.wallets_failed += 1; report.failed_wallet_errors.push(error); } Ok(WalletBackupLookup::UnsupportedVersion(version)) => { let error = format!( - "wallet {record_id} uses unsupported wallet backup version {version}" + "wallet {record_name} uses unsupported wallet backup version {version}" ); - warn!("Failed to download wallet {record_id}: {error}"); + warn!("Failed to download wallet {record_name}: {error}"); report.wallets_failed += 1; report.failed_wallet_errors.push(error); } @@ -888,18 +969,19 @@ impl RustCloudBackupManager { if Self::is_connectivity_related_issue(self.cloud_backup_issue(&error)) { return Err(self.blocking_cloud_error(BlockingCloudStep::Restore, error)); } - warn!("Failed to download wallet {record_id}: {error}"); + warn!("Failed to download wallet {record_name}: {error}"); report.wallets_failed += 1; report.failed_wallet_errors.push(error.to_string()); } } - completed += 1; + + progress.completed += 1; self.send_restore_progress( operation, CloudBackupRestoreStage::Downloading, - completed, - Some(total), + progress.completed, + Some(progress.total), )?; } @@ -985,23 +1067,26 @@ impl RustCloudBackupManager { &self, cloud: &CloudStorageClient, passkey: &PasskeyAccess, - ) -> Result { - let namespaces = cloud.list_namespaces().await.map_err(|error| { + ) -> Result, CloudBackupError> { + let mut namespaces = cloud.list_namespaces().await.map_err(|error| { self.blocking_cloud_error( BlockingCloudStep::Restore, CloudBackupError::cloud_storage_context("list cloud backup namespaces", error), ) })?; + namespaces.sort(); if namespaces.is_empty() { return Err(CloudBackupError::Internal("no cloud backup namespaces found".into())); } info!("Restore: authenticating with passkey across {} namespace(s)", namespaces.len()); - match try_match_namespace_with_passkey(cloud, passkey, &namespaces).await? { - NamespaceMatchOutcome::Matched(m) => { - info!("Restore: matched namespace {}", m.namespace_id); - Ok(m) + let matcher = NamespacePasskeyMatcher::new(cloud, passkey); + let match_outcome = matcher.match_namespaces(&namespaces).await?; + match match_outcome { + NamespaceMatchOutcome::Matched(matches) => { + info!("Restore: matched {} namespace(s)", matches.len()); + Ok(matches) } NamespaceMatchOutcome::UserDeclined => Err(CloudBackupError::PasskeyDiscoveryCancelled), NamespaceMatchOutcome::NoMatch => Err(CloudBackupError::PasskeyMismatch), @@ -1093,7 +1178,6 @@ where } #[cfg(test)] -#[path = "ops/test_support.rs"] mod test_support; #[cfg(test)] @@ -1129,7 +1213,6 @@ mod tests { }; use crate::manager::connectivity_manager::CONNECTIVITY_MANAGER; use crate::manager::wallet_manager::RustWalletManager; - use crate::network::Network; use crate::wallet::{ Wallet, metadata::{WalletMetadata, WalletMode, WalletType}, @@ -1160,11 +1243,11 @@ mod tests { ); globals.passkey.set_discover_result(Err(PasskeyError::NoCredentialFound)); - let outcome = try_match_namespace_with_passkey( + let outcome = NamespacePasskeyMatcher::new( &CloudStorage::global_explicit_client(), PasskeyAccess::global(), - &[namespace], ) + .match_namespaces(&[namespace]) .await .unwrap(); @@ -1189,11 +1272,11 @@ mod tests { ); globals.passkey.set_discover_result(Err(PasskeyError::UserCancelled)); - let outcome = try_match_namespace_with_passkey( + let outcome = NamespacePasskeyMatcher::new( &CloudStorage::global_explicit_client(), PasskeyAccess::global(), - &[namespace], ) + .match_namespaces(&[namespace]) .await .unwrap(); @@ -1231,17 +1314,146 @@ mod tests { credential_id: vec![1, 2, 3], })); - let outcome = try_match_namespace_with_passkey( + let outcome = NamespacePasskeyMatcher::new( &CloudStorage::global_explicit_client(), PasskeyAccess::global(), - &[supported_namespace, unsupported_namespace], ) + .match_namespaces(&[supported_namespace, unsupported_namespace]) .await .unwrap(); assert!(matches!(outcome, NamespaceMatchOutcome::NoMatch)); } + #[tokio::test(flavor = "current_thread")] + async fn passkey_match_discovery_propagates_unsupported_provider() { + let _guard = test_lock().lock(); + cove_tokio::init(); + let globals = test_globals(); + globals.reset(); + + let master_key = cove_cspp::master_key::MasterKey::generate(); + let namespace = master_key.namespace_id(); + let encrypted_master = + cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &[7; 32], &[9; 32]) + .unwrap(); + globals.cloud.set_master_key_backup( + namespace.clone(), + serde_json::to_vec(&encrypted_master).unwrap(), + ); + globals.passkey.set_discover_result(Err(PasskeyError::PrfUnsupportedProvider)); + + let result = NamespacePasskeyMatcher::new( + &CloudStorage::global_explicit_client(), + PasskeyAccess::global(), + ) + .match_namespaces(&[namespace]) + .await; + let error = match result { + Ok(_) => panic!("expected unsupported passkey provider error"), + Err(error) => error, + }; + + assert!(matches!(error, CloudBackupError::UnsupportedPasskeyProvider)); + } + + #[tokio::test(flavor = "current_thread")] + async fn passkey_match_targeted_auth_propagates_unsupported_provider() { + let _guard = test_lock().lock(); + cove_tokio::init(); + let globals = test_globals(); + globals.reset(); + + let master_key = cove_cspp::master_key::MasterKey::generate(); + let first_namespace = format!("{}-first", master_key.namespace_id()); + let second_namespace = format!("{}-second", master_key.namespace_id()); + let first_encrypted = + cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &[7; 32], &[9; 32]) + .unwrap(); + let second_encrypted = + cove_cspp::master_key_crypto::encrypt_master_key(&master_key, &[8; 32], &[9; 32]) + .unwrap(); + globals.cloud.set_master_key_backup( + first_namespace.clone(), + serde_json::to_vec(&first_encrypted).unwrap(), + ); + globals.cloud.set_master_key_backup( + second_namespace.clone(), + serde_json::to_vec(&second_encrypted).unwrap(), + ); + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: vec![1; 32], + credential_id: vec![1, 2, 3], + })); + globals.passkey.set_authenticate_result(Err(PasskeyError::PrfUnsupportedProvider)); + + let result = NamespacePasskeyMatcher::new( + &CloudStorage::global_explicit_client(), + PasskeyAccess::global(), + ) + .match_namespaces(&[first_namespace, second_namespace]) + .await; + let error = match result { + Ok(_) => panic!("expected unsupported passkey provider error"), + Err(error) => error, + }; + + assert!(matches!(error, CloudBackupError::UnsupportedPasskeyProvider)); + } + + #[tokio::test(flavor = "current_thread")] + async fn passkey_match_allows_one_credential_to_match_multiple_namespaces() { + let _guard = test_lock().lock(); + cove_tokio::init(); + let globals = test_globals(); + globals.reset(); + + let prf_key = [7u8; 32]; + let first_master_key = cove_cspp::master_key::MasterKey::generate(); + let second_master_key = cove_cspp::master_key::MasterKey::generate(); + let first_namespace = first_master_key.namespace_id(); + let second_namespace = second_master_key.namespace_id(); + let first_encrypted = + cove_cspp::master_key_crypto::encrypt_master_key(&first_master_key, &prf_key, &[9; 32]) + .unwrap(); + let second_encrypted = cove_cspp::master_key_crypto::encrypt_master_key( + &second_master_key, + &prf_key, + &[8; 32], + ) + .unwrap(); + + globals.cloud.set_master_key_backup( + first_namespace.clone(), + serde_json::to_vec(&first_encrypted).unwrap(), + ); + globals.cloud.set_master_key_backup( + second_namespace.clone(), + serde_json::to_vec(&second_encrypted).unwrap(), + ); + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: prf_key.to_vec(), + credential_id: vec![1, 2, 3], + })); + globals.passkey.set_authenticate_result(Ok(prf_key.to_vec())); + + let outcome = NamespacePasskeyMatcher::new( + &CloudStorage::global_explicit_client(), + PasskeyAccess::global(), + ) + .match_namespaces(&[first_namespace.clone(), second_namespace.clone()]) + .await + .unwrap(); + + let NamespaceMatchOutcome::Matched(matches) = outcome else { + panic!("expected multiple namespace matches"); + }; + let matched_namespaces = + matches.into_iter().map(|matched| matched.namespace_id).collect::>(); + + assert_eq!(matched_namespaces, vec![first_namespace, second_namespace]); + } + #[tokio::test(flavor = "current_thread")] async fn mock_master_key_upload_persists_uploaded_bytes() { let _guard = test_lock().lock(); @@ -1273,7 +1485,6 @@ mod tests { let first_wallet = xpub_only_wallet_metadata(); let mut second_wallet = xpub_only_wallet_metadata(); - second_wallet.network = Network::Testnet; second_wallet.wallet_mode = WalletMode::Decoy; Database::global() @@ -1311,11 +1522,12 @@ mod tests { globals.reset(); globals.passkey.set_discover_result(Err(PasskeyError::PrfUnsupportedProvider)); - let error = - match discover_or_create_prf_key_without_persisting(PasskeyAccess::global()).await { - Ok(_) => panic!("expected unsupported passkey provider error"), - Err(error) => error, - }; + let acquirer = PasskeyMaterialAcquirer::new(PasskeyAccess::global()); + let discovery_result = acquirer.discover_or_create_for_wrapper_repair().await; + let error = match discovery_result { + Ok(_) => panic!("expected unsupported passkey provider error"), + Err(error) => error, + }; assert!(matches!(error, CloudBackupError::UnsupportedPasskeyProvider)); } @@ -1669,6 +1881,58 @@ mod tests { assert_eq!(manager.current_status(), CloudBackupStatus::Enabled); } + #[tokio::test(flavor = "current_thread")] + async fn enable_with_multiple_matching_namespaces_fails_without_picking_first() { + let _guard = test_lock().lock(); + cove_tokio::init(); + let globals = test_globals(); + let manager = RustCloudBackupManager::init(); + + reset_cloud_backup_test_state(&manager, globals); + CONNECTIVITY_MANAGER.set_connection_state(true); + + let prf_key = [7u8; 32]; + let first_master_key = cove_cspp::master_key::MasterKey::generate(); + let second_master_key = cove_cspp::master_key::MasterKey::generate(); + let first_namespace = first_master_key.namespace_id(); + let second_namespace = second_master_key.namespace_id(); + let first_encrypted = + cove_cspp::master_key_crypto::encrypt_master_key(&first_master_key, &prf_key, &[9; 32]) + .unwrap(); + let second_encrypted = cove_cspp::master_key_crypto::encrypt_master_key( + &second_master_key, + &prf_key, + &[8; 32], + ) + .unwrap(); + + globals.cloud.set_master_key_backup( + first_namespace.clone(), + serde_json::to_vec(&first_encrypted).unwrap(), + ); + globals.cloud.set_master_key_backup( + second_namespace.clone(), + serde_json::to_vec(&second_encrypted).unwrap(), + ); + globals.cloud.set_wallet_files(first_namespace, vec!["wallet-1.json".into()]); + globals.cloud.set_wallet_files(second_namespace, vec!["wallet-2.json".into()]); + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: prf_key.to_vec(), + credential_id: vec![1, 2, 3], + })); + globals.passkey.set_authenticate_result(Ok(prf_key.to_vec())); + + let error = manager.do_enable_cloud_backup().await.unwrap_err(); + + assert!(matches!( + error, + CloudBackupError::Internal(message) + if message.contains("passkey matched 2 cloud backup namespaces") + && message.contains("ambiguous") + )); + assert_eq!(Keychain::global().get(CSPP_NAMESPACE_ID_KEY.into()), None); + } + #[tokio::test(flavor = "current_thread")] async fn finalize_passkey_repair_keeps_existing_count_when_wallet_refresh_fails() { let _guard = test_lock().lock(); @@ -3229,6 +3493,49 @@ mod tests { )); } + #[tokio::test(flavor = "current_thread")] + async fn failed_pending_upload_without_remote_backup_remains_pending() { + let _guard = test_lock().lock(); + cove_tokio::init(); + let globals = test_globals(); + let manager = RustCloudBackupManager::init(); + let metadata = prepare_deep_verify_with_unsynced_wallet(&manager, globals); + let namespace_id = Keychain::global().get(CSPP_NAMESPACE_ID_KEY.into()).unwrap(); + let record_id = cove_cspp::backup_data::wallet_record_id(metadata.id.as_ref()); + + let result = manager.deep_verify_cloud_backup(true).await; + + assert!(matches!(result, DeepVerificationResult::AwaitingUploadConfirmation(_))); + assert!(manager.pending_verification_completion().is_some()); + + CloudStorage::global_silent_client() + .delete_wallet_backup(namespace_id.clone(), record_id.clone()) + .await + .unwrap(); + Database::global() + .cloud_blob_sync_states + .set(&PersistedCloudBlobSyncState { + kind: CloudUploadKind::BackupBlob, + namespace_id, + wallet_id: Some(metadata.id), + record_id: record_id.clone(), + state: PersistedCloudBlobState::Failed(CloudBlobFailedState { + revision_hash: Some("rev-1".into()), + retryable: false, + error: "terminal upload failure".into(), + issue: None, + failed_at: 10, + }), + }) + .unwrap(); + + let has_more_pending = manager.verify_pending_uploads_once_for_test().await; + + assert!(has_more_pending); + assert!(manager.pending_verification_completion().is_some()); + assert!(manager.has_pending_cloud_upload_verification()); + } + #[tokio::test(flavor = "current_thread")] async fn deep_verify_preserves_unsupported_remote_wallet_backups() { let _guard = test_lock().lock(); @@ -3721,6 +4028,134 @@ mod tests { ); } + #[tokio::test(flavor = "current_thread")] + async fn restore_with_one_passkey_restores_wallets_from_all_matching_namespaces() { + let _guard = test_lock().lock(); + cove_tokio::init(); + let globals = test_globals(); + let manager = RustCloudBackupManager::init(); + + reset_cloud_backup_test_state(&manager, globals); + + let prf_key = [7u8; 32]; + let first_master_key = cove_cspp::master_key::MasterKey::generate(); + let second_master_key = cove_cspp::master_key::MasterKey::generate(); + let first_namespace = first_master_key.namespace_id(); + let second_namespace = second_master_key.namespace_id(); + let first_encrypted = + cove_cspp::master_key_crypto::encrypt_master_key(&first_master_key, &prf_key, &[9; 32]) + .unwrap(); + let second_encrypted = cove_cspp::master_key_crypto::encrypt_master_key( + &second_master_key, + &prf_key, + &[8; 32], + ) + .unwrap(); + + globals.cloud.set_master_key_backup( + first_namespace.clone(), + serde_json::to_vec(&first_encrypted).unwrap(), + ); + globals.cloud.set_master_key_backup( + second_namespace.clone(), + serde_json::to_vec(&second_encrypted).unwrap(), + ); + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: prf_key.to_vec(), + credential_id: vec![1, 2, 3], + })); + globals.passkey.set_authenticate_result(Ok(prf_key.to_vec())); + + let first_wallet = xpub_only_wallet_metadata(); + let second_wallet = xpub_only_wallet_metadata(); + Keychain::global() + .save_wallet_xpub(&first_wallet.id, sample_xpub(&first_wallet).parse().unwrap()) + .unwrap(); + Keychain::global() + .save_wallet_xpub(&second_wallet.id, sample_xpub(&second_wallet).parse().unwrap()) + .unwrap(); + + let first_record_id = cove_cspp::backup_data::wallet_record_id(first_wallet.id.as_ref()); + let second_record_id = cove_cspp::backup_data::wallet_record_id(second_wallet.id.as_ref()); + globals.cloud.set_wallet_backup( + first_namespace.clone(), + first_record_id.clone(), + encrypted_wallet_backup_bytes(&first_wallet, &first_master_key, "first-revision", 1) + .await, + ); + globals.cloud.set_wallet_backup( + second_namespace.clone(), + second_record_id.clone(), + encrypted_wallet_backup_bytes(&second_wallet, &second_master_key, "second-revision", 1) + .await, + ); + globals.cloud.set_wallet_files( + first_namespace, + vec![wallet_filename_from_record_id(&first_record_id)], + ); + globals.cloud.set_wallet_files( + second_namespace, + vec![wallet_filename_from_record_id(&second_record_id)], + ); + + let operation = new_restore_operation_for_test(&manager).await; + manager.do_restore_from_cloud_backup(&operation).await.unwrap(); + + let report = manager.state().restore_report.expect("expected restore report"); + assert_eq!(report.wallets_restored, 2); + assert_eq!(report.wallets_failed, 0); + assert!(report.failed_wallet_errors.is_empty(), "{:?}", report.failed_wallet_errors); + assert_eq!(Database::global().cloud_backup_state.get().unwrap().wallet_count, Some(2)); + } + + #[tokio::test(flavor = "current_thread")] + async fn restore_does_not_persist_first_passkey_match_before_restore_work_succeeds() { + let _guard = test_lock().lock(); + cove_tokio::init(); + let globals = test_globals(); + let manager = RustCloudBackupManager::init(); + + reset_cloud_backup_test_state(&manager, globals); + + let prf_key = [7u8; 32]; + let first_master_key = cove_cspp::master_key::MasterKey::generate(); + let second_master_key = cove_cspp::master_key::MasterKey::generate(); + let first_namespace = first_master_key.namespace_id(); + let second_namespace = second_master_key.namespace_id(); + let first_encrypted = + cove_cspp::master_key_crypto::encrypt_master_key(&first_master_key, &prf_key, &[9; 32]) + .unwrap(); + let second_encrypted = cove_cspp::master_key_crypto::encrypt_master_key( + &second_master_key, + &prf_key, + &[8; 32], + ) + .unwrap(); + + globals.cloud.set_master_key_backup( + first_namespace.clone(), + serde_json::to_vec(&first_encrypted).unwrap(), + ); + globals.cloud.set_master_key_backup( + second_namespace.clone(), + serde_json::to_vec(&second_encrypted).unwrap(), + ); + globals.cloud.set_wallet_files(first_namespace, vec!["wallet-1.json".into()]); + globals.cloud.set_wallet_files(second_namespace, vec!["wallet-2.json".into()]); + globals.cloud.fail_list_wallet_files("list failed"); + globals.passkey.set_discover_result(Ok(DiscoveredPasskeyResult { + prf_output: prf_key.to_vec(), + credential_id: vec![1, 2, 3], + })); + globals.passkey.set_authenticate_result(Ok(prf_key.to_vec())); + + let operation = new_restore_operation_for_test(&manager).await; + let error = manager.do_restore_from_cloud_backup(&operation).await.unwrap_err(); + + assert!(error.to_string().contains("list failed"), "{error}"); + assert_eq!(Keychain::global().get(CSPP_NAMESPACE_ID_KEY.into()), None); + } + #[tokio::test(flavor = "current_thread")] async fn restore_counts_listed_missing_wallet_backups_as_failures() { let _guard = test_lock().lock(); diff --git a/rust/src/manager/cloud_backup_manager/pending/detail.rs b/rust/src/manager/cloud_backup_manager/pending/detail.rs index 31592a2e8..f4a22f5db 100644 --- a/rust/src/manager/cloud_backup_manager/pending/detail.rs +++ b/rust/src/manager/cloud_backup_manager/pending/detail.rs @@ -2,8 +2,8 @@ use cove_device::cloud_storage::{CloudStorage, CloudStorageError}; use tracing::{info, warn}; use super::super::{ - BlockingCloudStep, CloudBackupDetailResult, CloudBackupStatus, RustCloudBackupManager, - cloud_inventory::RemoteWalletTruth, + BlockingCloudStep, CloudBackupDetailResult, CloudBackupError, CloudBackupStatus, + RustCloudBackupManager, cloud_inventory::RemoteWalletTruth, }; use crate::database::Database; use crate::database::cloud_backup::{CloudBlobConfirmedState, PersistedCloudBlobState}; @@ -33,17 +33,15 @@ impl RustCloudBackupManager { info!("refresh_cloud_backup_detail: listing wallets for namespace {namespace}"); let cloud = CloudStorage::global_explicit_client(); - let wallet_record_ids = match cloud.list_wallet_backups(namespace.clone()).await { + let wallet_record_ids = match cloud.list_wallet_backups(namespace).await { Ok(ids) => ids, Err(CloudStorageError::NotFound(_)) => Vec::new(), Err(error) => { - if RustCloudBackupManager::is_connectivity_related_issue( - RustCloudBackupManager::cloud_storage_issue(&error), - ) { - return Some(CloudBackupDetailResult::AccessError( - self.offline_error_for_step(BlockingCloudStep::DetailRefresh).to_string(), - )); - } + let error = self.blocking_cloud_error( + BlockingCloudStep::DetailRefresh, + CloudBackupError::cloud_storage_context("list wallet backups", error), + ); + return Some(CloudBackupDetailResult::AccessError(error.to_string())); } }; @@ -53,6 +51,7 @@ impl RustCloudBackupManager { Ok(remote_wallet_truth) => remote_wallet_truth, Err(error) => return Some(CloudBackupDetailResult::AccessError(error.to_string())), }; + self.cleanup_confirmed_pending_blobs(&remote_wallet_truth); match self @@ -89,10 +88,14 @@ impl RustCloudBackupManager { continue; } - let PersistedCloudBlobState::UploadedPendingConfirmation(pending_state) = &state.state - else { - continue; + let pending_state = match &state.state { + PersistedCloudBlobState::UploadedPendingConfirmation(pending_state) => { + pending_state + } + + _ => continue, }; + if !remote_wallet_revision_matches( remote_wallet_truth, &state.record_id, @@ -119,6 +122,7 @@ impl RustCloudBackupManager { continue; } }; + if !persisted { continue; } diff --git a/rust/src/manager/cloud_backup_manager/pending/queue_processor.rs b/rust/src/manager/cloud_backup_manager/pending/queue_processor.rs index 142bdd548..5ce15d566 100644 --- a/rust/src/manager/cloud_backup_manager/pending/queue_processor.rs +++ b/rust/src/manager/cloud_backup_manager/pending/queue_processor.rs @@ -28,12 +28,13 @@ enum PendingUploadRunOutcome { StillPending, Confirmed, Failed, + BlockedOnAuthorization, } -pub(super) struct PendingUploadVerifier(pub(super) RustCloudBackupManager); - const MAX_PENDING_WALLET_UPLOAD_CONFIRMATION_ATTEMPTS: u32 = 3; +pub(super) struct PendingUploadVerifier(pub(super) RustCloudBackupManager); + impl PendingUploadVerifier { pub(super) async fn run_once(&self) -> PendingUploadVerificationStatus { let table = &Database::global().cloud_blob_sync_states; @@ -48,15 +49,16 @@ impl PendingUploadVerifier { let mut had_pending = false; let mut any_failed = false; let mut blocked_on_authorization = false; + for sync_state in &states { - let PersistedCloudBlobState::UploadedPendingConfirmation(state) = &sync_state.state - else { - continue; + let current_state = match &sync_state.state { + PersistedCloudBlobState::UploadedPendingConfirmation(state) => state.clone(), + _ => continue, }; - let current_state = state.clone(); had_pending = true; let result = self.check_blob(sync_state, ¤t_state).await; + if let BlobCheckResult::AuthorizationRequired { error } = &result { warn!( "Pending upload verification: paused until cloud authorization is restored record_id={} error={error}", @@ -65,6 +67,7 @@ impl PendingUploadVerifier { blocked_on_authorization = true; break; } + let next_state = Self::apply_blob_result(sync_state, ¤t_state, &result); let persisted = match table.set_if_current(sync_state, &next_state) { Ok(persisted) => persisted, @@ -73,6 +76,7 @@ impl PendingUploadVerifier { return PendingUploadVerificationStatus::Pending; } }; + if !persisted { continue; } @@ -88,13 +92,14 @@ impl PendingUploadVerifier { if !blocked_on_authorization { self.0.finalize_pending_verification_if_ready().await; } + let has_pending = self.0.has_pending_cloud_upload_verification(); self.send_pending_state(has_pending); self.0.refresh_sync_health(); - if blocked_on_authorization { - return PendingUploadVerificationStatus::BlockedOnAuthorization; - } - match Self::run_outcome(had_pending, has_pending, any_failed) { + + let outcome = + Self::run_outcome(blocked_on_authorization, had_pending, has_pending, any_failed); + match outcome { PendingUploadRunOutcome::Idle => { info!("Pending upload verification: no pending blobs"); } @@ -107,29 +112,43 @@ impl PendingUploadVerifier { PendingUploadRunOutcome::Failed => { warn!("Pending upload verification: completed with failures"); } + PendingUploadRunOutcome::BlockedOnAuthorization => {} } - if has_pending { - PendingUploadVerificationStatus::Pending - } else { - PendingUploadVerificationStatus::Idle + match outcome { + PendingUploadRunOutcome::BlockedOnAuthorization => { + PendingUploadVerificationStatus::BlockedOnAuthorization + } + PendingUploadRunOutcome::StillPending => PendingUploadVerificationStatus::Pending, + PendingUploadRunOutcome::Idle + | PendingUploadRunOutcome::Confirmed + | PendingUploadRunOutcome::Failed => PendingUploadVerificationStatus::Idle, } } fn run_outcome( + blocked_on_authorization: bool, had_pending: bool, has_pending: bool, any_failed: bool, ) -> PendingUploadRunOutcome { + if blocked_on_authorization { + return PendingUploadRunOutcome::BlockedOnAuthorization; + } + if has_pending { - PendingUploadRunOutcome::StillPending - } else if any_failed { - PendingUploadRunOutcome::Failed - } else if had_pending { - PendingUploadRunOutcome::Confirmed - } else { - PendingUploadRunOutcome::Idle + return PendingUploadRunOutcome::StillPending; + } + + if any_failed { + return PendingUploadRunOutcome::Failed; } + + if had_pending { + return PendingUploadRunOutcome::Confirmed; + } + + PendingUploadRunOutcome::Idle } async fn check_blob( @@ -172,10 +191,12 @@ impl PendingUploadVerifier { sync_state.namespace_id.clone(), Zeroizing::new(master_key.critical_data_key()), ); - let wallet_json = match CloudStorage::global_silent_client() + + let wallt_download_result = CloudStorage::global_silent_client() .download_wallet_backup(sync_state.namespace_id.clone(), sync_state.record_id.clone()) - .await - { + .await; + + let wallet_json = match wallt_download_result { Ok(wallet_json) => wallet_json, Err(CloudStorageError::NotFound(_)) => return BlobCheckResult::NotYetUploaded, Err(error) => return cloud_storage_failure_result(error), @@ -400,7 +421,7 @@ mod tests { let current = match &blob.state { PersistedCloudBlobState::UploadedPendingConfirmation(state) => state.clone(), - _ => unreachable!(), + _ => panic!("expected uploaded pending confirmation state"), }; let blob = @@ -428,7 +449,7 @@ mod tests { let current = match &blob.state { PersistedCloudBlobState::UploadedPendingConfirmation(state) => state.clone(), - _ => unreachable!(), + _ => panic!("expected uploaded pending confirmation state"), }; let blob = PendingUploadVerifier::apply_blob_result( @@ -464,7 +485,7 @@ mod tests { let current = match &blob.state { PersistedCloudBlobState::UploadedPendingConfirmation(state) => state.clone(), - _ => unreachable!(), + _ => panic!("expected uploaded pending confirmation state"), }; let blob = PendingUploadVerifier::apply_blob_result( @@ -501,7 +522,7 @@ mod tests { let current = match &blob.state { PersistedCloudBlobState::UploadedPendingConfirmation(state) => state.clone(), - _ => unreachable!(), + _ => panic!("expected uploaded pending confirmation state"), }; let blob = PendingUploadVerifier::apply_blob_result( @@ -532,7 +553,7 @@ mod tests { let current = match &blob.state { PersistedCloudBlobState::UploadedPendingConfirmation(state) => state.clone(), - _ => unreachable!(), + _ => panic!("expected uploaded pending confirmation state"), }; let blob = PendingUploadVerifier::apply_blob_result( @@ -563,7 +584,7 @@ mod tests { let current = match &blob.state { PersistedCloudBlobState::UploadedPendingConfirmation(state) => state.clone(), - _ => unreachable!(), + _ => panic!("expected uploaded pending confirmation state"), }; let blob = PendingUploadVerifier::apply_blob_result( @@ -585,16 +606,20 @@ mod tests { #[test] fn run_outcome_treats_failures_as_distinct_from_confirmed() { assert_eq!( - PendingUploadVerifier::run_outcome(true, false, true), + PendingUploadVerifier::run_outcome(false, true, false, true), PendingUploadRunOutcome::Failed ); assert_eq!( - PendingUploadVerifier::run_outcome(true, false, false), + PendingUploadVerifier::run_outcome(false, true, false, false), PendingUploadRunOutcome::Confirmed ); assert_eq!( - PendingUploadVerifier::run_outcome(false, false, false), + PendingUploadVerifier::run_outcome(false, false, false, false), PendingUploadRunOutcome::Idle ); + assert_eq!( + PendingUploadVerifier::run_outcome(true, true, true, true), + PendingUploadRunOutcome::BlockedOnAuthorization + ); } } diff --git a/rust/src/manager/cloud_backup_manager/prompt.rs b/rust/src/manager/cloud_backup_manager/prompt.rs index b1ee9615d..6475e6ffb 100644 --- a/rust/src/manager/cloud_backup_manager/prompt.rs +++ b/rust/src/manager/cloud_backup_manager/prompt.rs @@ -1,9 +1,6 @@ -use crate::manager::cloud_backup_detail_manager::{ - RecoveryAction, RecoveryState, VerificationState, -}; - use super::{ CloudBackupPasskeyChoiceFlow, CloudBackupPromptIntent, CloudBackupState, CloudBackupStatus, + RecoveryAction, RecoveryState, VerificationState, }; #[derive(Debug, Clone, Default)] @@ -47,6 +44,7 @@ impl CloudBackupPromptState { return CloudBackupPromptIntent::PasskeyChoice(flow.clone()); } + // show a reminder while cloud backup needs a passkey, unless repair is already underway if matches!(state.status, CloudBackupStatus::PasskeyMissing) && !self.missing_passkey_dismissed && !matches!(state.recovery, RecoveryState::Recovering(RecoveryAction::RepairPasskey)) @@ -54,31 +52,28 @@ impl CloudBackupPromptState { return CloudBackupPromptIntent::MissingPasskeyReminder; } - if state.has_pending_upload_verification - && matches!(state.verification, VerificationState::Verifying) - { - return CloudBackupPromptIntent::None; + // decide whether verification needs user attention while accounting for background upload checks + use VerificationState as Vs; + match (&state.verification, state.should_prompt_verification) { + (Vs::Verifying, _) if state.has_pending_upload_verification => { + CloudBackupPromptIntent::None + } + (Vs::Verifying | Vs::Failed(_), _) => CloudBackupPromptIntent::VerificationPrompt, + (Vs::Idle | Vs::Verified(_) | Vs::PasskeyConfirmed | Vs::Cancelled, true) => { + CloudBackupPromptIntent::VerificationPrompt + } + (Vs::Idle | Vs::Verified(_) | Vs::PasskeyConfirmed | Vs::Cancelled, false) => { + CloudBackupPromptIntent::None + } } - - if matches!(state.verification, VerificationState::Verifying | VerificationState::Failed(_)) - || state.should_prompt_verification - { - return CloudBackupPromptIntent::VerificationPrompt; - } - - CloudBackupPromptIntent::None } } #[cfg(test)] mod tests { - use crate::manager::cloud_backup_detail_manager::{ - RecoveryAction, RecoveryState, VerificationState, - }; - use super::{ CloudBackupPasskeyChoiceFlow, CloudBackupPromptIntent, CloudBackupPromptState, - CloudBackupState, CloudBackupStatus, + CloudBackupState, CloudBackupStatus, RecoveryAction, RecoveryState, VerificationState, }; use crate::manager::cloud_backup_manager::{DeepVerificationFailure, VerificationFailureKind}; diff --git a/rust/src/manager/cloud_backup_manager/runtime_actor.rs b/rust/src/manager/cloud_backup_manager/runtime_actor.rs index b0e1f0feb..8c0a0d7ce 100644 --- a/rust/src/manager/cloud_backup_manager/runtime_actor.rs +++ b/rust/src/manager/cloud_backup_manager/runtime_actor.rs @@ -13,13 +13,12 @@ use super::pending::{ build_pending_upload_backoff, }; use super::{ - CloudBackupError, CloudBackupStatus, PendingVerificationCompletion, RustCloudBackupManager, - WalletId, live_upload_retry_delay_for_attempt, + CloudBackupError, CloudBackupStatus, PendingVerificationCompletion, RecoveryAction, + RustCloudBackupManager, WalletId, live_upload_retry_delay_for_attempt, }; use crate::database::cloud_backup::{ CloudBlobFailedState, CloudBlobFailureIssue, PersistedCloudBlobState, }; -use crate::manager::cloud_backup_detail_manager::RecoveryAction; #[derive(Debug, Clone)] pub(crate) enum CloudBackupOperation { @@ -64,6 +63,7 @@ impl RestoreOperation { } } +// serializes restore cancellation against restore state mutations in spawned tasks #[derive(Clone, Debug, Default)] struct RestoreOperationCoordinator(Arc>); diff --git a/rust/src/manager/cloud_backup_manager/verify.rs b/rust/src/manager/cloud_backup_manager/verify.rs index 85911b391..99d11e846 100644 --- a/rust/src/manager/cloud_backup_manager/verify.rs +++ b/rust/src/manager/cloud_backup_manager/verify.rs @@ -4,28 +4,27 @@ mod pending_completion; mod session; mod wrapper_repair; -use cove_cspp::CsppStore as _; -use cove_cspp::backup_data::EncryptedMasterKeyBackup; +use cove_cspp::backup_data::{EncryptedMasterKeyBackup, MasterKeyBackupVersion}; use cove_cspp::master_key::MasterKey; use cove_cspp::master_key_crypto; use cove_device::cloud_storage::{CloudStorage, CloudStorageError}; -use cove_device::keychain::{CSPP_CREDENTIAL_ID_KEY, Keychain}; +use cove_device::keychain::Keychain; use cove_device::passkey::PasskeyAccess; use cove_util::ResultExt as _; use tracing::{error, info, warn}; -use self::passkey_auth::{PasskeyAuthOutcome, PasskeyAuthPolicy, authenticate_with_policy}; +use self::passkey_auth::{PasskeyAuthOutcome, PasskeyAuthPolicy, PasskeyAuthenticator}; use self::session::VerificationSession; use self::wrapper_repair::{WrapperRepairOperation, WrapperRepairStrategy}; use super::wallets::persist_enabled_cloud_backup_state; use super::{ BlockingCloudStep, CloudBackupDetailResult, CloudBackupError, CloudBackupStatus, DeepVerificationFailure, DeepVerificationReport, DeepVerificationResult, - PendingVerificationCompletion, RustCloudBackupManager, VerificationFailureKind, + PendingVerificationCompletion, RecoveryState, RustCloudBackupManager, VerificationFailureKind, + VerificationState, }; use crate::database::Database; use crate::database::cloud_backup::{PersistedCloudBackupState, PersistedCloudBackupStatus}; -use crate::manager::cloud_backup_detail_manager::{RecoveryState, VerificationState}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum IntegrityDowngrade { @@ -259,25 +258,28 @@ impl RustCloudBackupManager { let encrypted: EncryptedMasterKeyBackup = serde_json::from_slice(&master_json).map_err_str(CloudBackupError::Internal)?; - if encrypted.version != 1 { - let version = encrypted.version; - return Err(CloudBackupError::Internal(format!( - "master key backup version {version} is not supported", - ))); + match encrypted.backup_version() { + Ok(MasterKeyBackupVersion::V1) => {} + Err(unsupported) => { + let version = unsupported.0; + return Err(CloudBackupError::Compatibility(format!( + "master key backup version {version} is not supported", + ))); + } } - let authenticated = - match authenticate_with_policy(keychain, passkey, &encrypted.prf_salt, auth_policy) - .await? - { - PasskeyAuthOutcome::Authenticated(result) => result, - PasskeyAuthOutcome::UserCancelled => { - return Err(CloudBackupError::Passkey("user cancelled".into())); - } - PasskeyAuthOutcome::NoCredentialFound => { - return Err(CloudBackupError::RecoveryRequired(recovery_message.into())); - } - }; + let authenticator = PasskeyAuthenticator::new(keychain, passkey); + let auth_outcome = + authenticator.authenticate_with_policy(&encrypted.prf_salt, auth_policy).await?; + let authenticated = match auth_outcome { + PasskeyAuthOutcome::Authenticated(result) => result, + PasskeyAuthOutcome::UserCancelled => { + return Err(CloudBackupError::Passkey("user cancelled".into())); + } + PasskeyAuthOutcome::NoCredentialFound => { + return Err(CloudBackupError::RecoveryRequired(recovery_message.into())); + } + }; let master_key = master_key_crypto::decrypt_master_key(&encrypted, &authenticated.prf_key) .map_err(|_| match auth_policy { @@ -302,14 +304,6 @@ impl RustCloudBackupManager { } } -pub(super) fn load_stored_credential_id(keychain: &Keychain) -> Option> { - keychain.get(CSPP_CREDENTIAL_ID_KEY.into()).and_then(|hex_str| { - hex::decode(hex_str) - .inspect_err(|error| warn!("Failed to decode stored credential_id: {error}")) - .ok() - }) -} - fn downgrade_cloud_backup_state( current: &PersistedCloudBackupState, downgrade: IntegrityDowngrade, diff --git a/rust/src/manager/cloud_backup_manager/verify/integrity.rs b/rust/src/manager/cloud_backup_manager/verify/integrity.rs index ea333970e..b465a8e2a 100644 --- a/rust/src/manager/cloud_backup_manager/verify/integrity.rs +++ b/rust/src/manager/cloud_backup_manager/verify/integrity.rs @@ -4,10 +4,9 @@ use cove_device::keychain::{CSPP_PRF_SALT_KEY, Keychain}; use tracing::{error, info, warn}; use super::super::cloud_inventory::CloudWalletInventory; -use super::super::wallets::{count_all_wallets, persist_enabled_cloud_backup_state}; +use super::super::wallets::count_all_wallets; use super::{ CloudBackupStatus, IntegrityDowngrade, RustCloudBackupManager, downgrade_cloud_backup_state, - load_stored_credential_id, }; use crate::database::Database; @@ -63,7 +62,7 @@ impl RustCloudBackupManager { let mut downgrade = None; let has_prf_salt = keychain.get(CSPP_PRF_SALT_KEY.into()).is_some(); - let stored_credential_id = load_stored_credential_id(keychain); + let stored_credential_id = keychain.load_cspp_credential_id(); // keep launch integrity checks non-interactive so app startup never presents passkey UI if stored_credential_id.is_none() { @@ -88,15 +87,16 @@ impl RustCloudBackupManager { return self.finish_backup_integrity_check(&issues, downgrade); } - self.verify_wallet_backups(namespace, &mut issues).await; + self.verify_wallet_backups_for_integrity_check(namespace, &mut issues).await; self.finish_backup_integrity_check(&issues, downgrade) } - async fn verify_wallet_backups( + async fn verify_wallet_backups_for_integrity_check( &self, namespace: String, issues: &mut Vec, ) { + // use a silent client because startup integrity checks must not present iCloud UI let cloud = CloudStorage::global_silent_client(); let wallet_record_ids = match cloud.list_wallet_backups(namespace.clone()).await { Ok(wallet_record_ids) => wallet_record_ids, @@ -135,10 +135,11 @@ impl RustCloudBackupManager { let unsynced = inventory.upload_candidate_wallets(); let handled_unsynced = !unsynced.is_empty(); + let mut backup_failed = false; if handled_unsynced { let backup_result = self.do_backup_wallets(&unsynced).await; - self.refresh_integrity_detail(&namespace, &wallet_record_ids).await; + self.refresh_integrity_check_detail(&namespace, &wallet_record_ids).await; if let Err(error) = backup_result { error!("Backup integrity: auto-sync failed: {error}"); issues.push(BackupIntegrityIssue::WalletsNotBackedUp); @@ -146,25 +147,22 @@ impl RustCloudBackupManager { } } + if backup_failed || handled_unsynced || !wallet_record_ids.is_empty() { + return; + } + let db = Database::global(); - if db.cloud_backup_state.get().ok().and_then(|state| state.wallet_count).is_none() { - match count_all_wallets(&db) { - Ok(local_count) => { - let _ = persist_enabled_cloud_backup_state(&db, local_count); - } - Err(error) => { - warn!("Backup integrity: local wallet count failed: {error}"); - } + let has_local_wallets = match count_all_wallets(&db) { + Ok(local_count) => local_count > 0, + Err(error) => { + warn!("Backup integrity: local wallet count failed: {error}"); + false } - } + }; - if !backup_failed - && !handled_unsynced - && wallet_record_ids.is_empty() - && count_all_wallets(&db).unwrap_or_default() > 0 - { + if has_local_wallets { let sync_result = self.do_sync_unsynced_wallets().await; - self.refresh_integrity_detail(&namespace, &wallet_record_ids).await; + self.refresh_integrity_check_detail(&namespace, &wallet_record_ids).await; if let Err(error) = sync_result { error!("Backup integrity: auto-sync failed: {error}"); issues.push(BackupIntegrityIssue::WalletsNotBackedUp); @@ -172,11 +170,12 @@ impl RustCloudBackupManager { } } - async fn refresh_integrity_detail( + async fn refresh_integrity_check_detail( &self, namespace: &str, fallback_wallet_record_ids: &[String], ) { + // use a silent client because startup integrity checks must not present iCloud UI let cloud = CloudStorage::global_silent_client(); let wallet_record_ids = match cloud.list_wallet_backups(namespace.to_string()).await { Ok(wallet_record_ids) => wallet_record_ids, @@ -224,6 +223,7 @@ impl RustCloudBackupManager { self.persist_integrity_downgrade(downgrade); let message = issues.iter().map(BackupIntegrityIssue::message).collect::>().join("; "); + error!("Backup integrity issues: {message}"); Some(message) } diff --git a/rust/src/manager/cloud_backup_manager/verify/passkey_auth.rs b/rust/src/manager/cloud_backup_manager/verify/passkey_auth.rs index b5b50a89b..38c1c00c9 100644 --- a/rust/src/manager/cloud_backup_manager/verify/passkey_auth.rs +++ b/rust/src/manager/cloud_backup_manager/verify/passkey_auth.rs @@ -5,115 +5,196 @@ use rand::RngExt as _; use tracing::info; use super::super::{CloudBackupError, PASSKEY_RP_ID}; -use super::load_stored_credential_id; use super::session::VerificationSession; #[derive(Debug, PartialEq)] -pub(super) struct AuthenticatedPasskey { - pub(super) prf_key: [u8; 32], - pub(super) credential_id: Vec, - pub(super) credential_recovered: bool, +pub(crate) struct AuthenticatedPasskey { + pub(crate) prf_key: [u8; 32], + pub(crate) credential_id: Vec, + pub(crate) credential_recovered: bool, } #[derive(Debug, PartialEq)] -pub(super) enum PasskeyAuthOutcome { +pub(crate) enum PasskeyAuthOutcome { Authenticated(AuthenticatedPasskey), UserCancelled, NoCredentialFound, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum PasskeyAuthPolicy { +pub(crate) enum PasskeyAuthPolicy { StoredOnly, StoredThenDiscover, DiscoverOnly, } -pub(super) async fn authenticate_with_policy( - keychain: &Keychain, - passkey: &PasskeyAccess, - prf_salt: &[u8; 32], - policy: PasskeyAuthPolicy, -) -> Result { - if matches!(policy, PasskeyAuthPolicy::StoredOnly | PasskeyAuthPolicy::StoredThenDiscover) { - if let Some(ref credential_id) = load_stored_credential_id(keychain) { - let passkey = passkey.clone(); - let credential_id = credential_id.clone(); - let auth_credential_id = credential_id.clone(); - let prf_salt = *prf_salt; - let auth_result = unblock::run_blocking(move || { - passkey.authenticate_with_prf( - PASSKEY_RP_ID.to_string(), - auth_credential_id, - prf_salt.to_vec(), - rand::rng().random::<[u8; 32]>().to_vec(), - ) - }) - .await; - - match auth_result { - Ok(prf_output) => { - let prf_key: [u8; 32] = prf_output.try_into().map_err(|_| { - CloudBackupError::Internal("PRF output is not 32 bytes".into()) - })?; - - return Ok(PasskeyAuthOutcome::Authenticated(AuthenticatedPasskey { - prf_key, - credential_id, - credential_recovered: false, - })); - } - Err(PasskeyError::UserCancelled) => { - return Ok(PasskeyAuthOutcome::UserCancelled); +enum StoredPasskeyAuthOutcome { + Authenticated(AuthenticatedPasskey), + UserCancelled, + Failed(PasskeyError), + NoCredentialFound, +} + +/// Authenticates backup passkeys against the PRF salt from a master-key backup +pub(crate) struct PasskeyAuthenticator { + keychain: Keychain, + passkey: PasskeyAccess, +} + +impl PasskeyAuthenticator { + /// Builds an authenticator from cheap device-service handles + pub(crate) fn new(keychain: &Keychain, passkey: &PasskeyAccess) -> Self { + Self { keychain: keychain.clone(), passkey: passkey.clone() } + } + + /// Authenticates according to the caller's stored/discoverable credential policy + pub(crate) async fn authenticate_with_policy( + &self, + prf_salt: &[u8; 32], + policy: PasskeyAuthPolicy, + ) -> Result { + match policy { + PasskeyAuthPolicy::StoredOnly => self.authenticate_stored_only(prf_salt).await, + PasskeyAuthPolicy::DiscoverOnly => self.authenticate_by_discovery(prf_salt).await, + + PasskeyAuthPolicy::StoredThenDiscover => { + self.authenticate_stored_then_discover(prf_salt).await + } + } + } + + async fn authenticate_stored_only( + &self, + prf_salt: &[u8; 32], + ) -> Result { + // try the known credential first so normal restores do not show an account picker + let stored_outcome = self.authenticate_by_stored_credential(prf_salt).await?; + match stored_outcome { + StoredPasskeyAuthOutcome::Authenticated(authenticated) => { + Ok(PasskeyAuthOutcome::Authenticated(authenticated)) + } + + StoredPasskeyAuthOutcome::UserCancelled => Ok(PasskeyAuthOutcome::UserCancelled), + + StoredPasskeyAuthOutcome::NoCredentialFound => { + Ok(PasskeyAuthOutcome::NoCredentialFound) + } + + StoredPasskeyAuthOutcome::Failed(error) => { + if matches!(error, PasskeyError::PrfUnsupportedProvider) { + return Err(CloudBackupError::UnsupportedPasskeyProvider); } - Err(error) => { - info!("Stored credential auth failed ({error})"); - if matches!(policy, PasskeyAuthPolicy::StoredOnly) { - return Ok(PasskeyAuthOutcome::NoCredentialFound); - } - info!("Trying discovery after stored credential auth failed"); + info!("Stored credential auth failed ({error})"); + Ok(PasskeyAuthOutcome::NoCredentialFound) + } + } + } + + async fn authenticate_stored_then_discover( + &self, + prf_salt: &[u8; 32], + ) -> Result { + // try the known credential first so normal restores do not show an account picker + let stored_outcome = self.authenticate_by_stored_credential(prf_salt).await?; + match stored_outcome { + StoredPasskeyAuthOutcome::Authenticated(authenticated) => { + Ok(PasskeyAuthOutcome::Authenticated(authenticated)) + } + + StoredPasskeyAuthOutcome::UserCancelled => Ok(PasskeyAuthOutcome::UserCancelled), + + // stored-then-discover falls back when the stored credential is missing + StoredPasskeyAuthOutcome::NoCredentialFound => { + info!("Trying discovery after stored credential auth failed"); + self.authenticate_by_discovery(prf_salt).await + } + + StoredPasskeyAuthOutcome::Failed(error) => { + if matches!(error, PasskeyError::PrfUnsupportedProvider) { + return Err(CloudBackupError::UnsupportedPasskeyProvider); } + + info!("Stored credential auth failed ({error})"); + info!("Trying discovery after stored credential auth failed"); + self.authenticate_by_discovery(prf_salt).await } - } else if matches!(policy, PasskeyAuthPolicy::StoredOnly) { - return Ok(PasskeyAuthOutcome::NoCredentialFound); } } - if matches!(policy, PasskeyAuthPolicy::StoredOnly) { - return Ok(PasskeyAuthOutcome::NoCredentialFound); + async fn authenticate_by_stored_credential( + &self, + prf_salt: &[u8; 32], + ) -> Result { + let Some(credential_id) = self.keychain.load_cspp_credential_id() else { + return Ok(StoredPasskeyAuthOutcome::NoCredentialFound); + }; + + let passkey = self.passkey.clone(); + let auth_credential_id = credential_id.clone(); + let prf_salt = *prf_salt; + let auth_result = unblock::run_blocking(move || { + passkey.authenticate_with_prf( + PASSKEY_RP_ID.to_string(), + auth_credential_id, + prf_salt.to_vec(), + rand::rng().random::<[u8; 32]>().to_vec(), + ) + }) + .await; + + let prf_output = match auth_result { + Ok(prf_output) => prf_output, + Err(PasskeyError::UserCancelled) => return Ok(StoredPasskeyAuthOutcome::UserCancelled), + Err(error) => return Ok(StoredPasskeyAuthOutcome::Failed(error)), + }; + + let prf_key: [u8; 32] = prf_output + .try_into() + .map_err(|_| CloudBackupError::Internal("PRF output is not 32 bytes".into()))?; + + Ok(StoredPasskeyAuthOutcome::Authenticated(AuthenticatedPasskey { + prf_key, + credential_id, + credential_recovered: false, + })) } - let passkey = passkey.clone(); - let prf_salt = *prf_salt; - let discovered_result = unblock::run_blocking(move || { - passkey.discover_and_authenticate_with_prf( - PASSKEY_RP_ID.to_string(), - prf_salt.to_vec(), - rand::rng().random::<[u8; 32]>().to_vec(), - ) - }) - .await; - - let discovered = match discovered_result { - Ok(discovered) => discovered, - Err(error) => return map_discovery_error(error), - }; - - let prf_key: [u8; 32] = discovered - .prf_output - .try_into() - .map_err(|_| CloudBackupError::Internal("PRF output is not 32 bytes".into()))?; - - Ok(PasskeyAuthOutcome::Authenticated(AuthenticatedPasskey { - prf_key, - credential_id: discovered.credential_id, - credential_recovered: true, - })) + async fn authenticate_by_discovery( + &self, + prf_salt: &[u8; 32], + ) -> Result { + let passkey = self.passkey.clone(); + let prf_salt = *prf_salt; + let discovered_result = unblock::run_blocking(move || { + passkey.discover_and_authenticate_with_prf( + PASSKEY_RP_ID.to_string(), + prf_salt.to_vec(), + rand::rng().random::<[u8; 32]>().to_vec(), + ) + }) + .await; + + let discovered = match discovered_result { + Ok(discovered) => discovered, + Err(error) => return map_discovery_error(error), + }; + + let prf_key: [u8; 32] = discovered + .prf_output + .try_into() + .map_err(|_| CloudBackupError::Internal("PRF output is not 32 bytes".into()))?; + + Ok(PasskeyAuthOutcome::Authenticated(AuthenticatedPasskey { + prf_key, + credential_id: discovered.credential_id, + credential_recovered: true, + })) + } } impl VerificationSession { - pub(super) async fn authenticate_with_fallback( + pub(crate) async fn authenticate_with_fallback( &self, prf_salt: &[u8; 32], ) -> Result { @@ -123,7 +204,9 @@ impl VerificationSession { PasskeyAuthPolicy::StoredThenDiscover }; - authenticate_with_policy(&self.keychain, &self.passkey, prf_salt, policy).await + PasskeyAuthenticator::new(&self.keychain, &self.passkey) + .authenticate_with_policy(prf_salt, policy) + .await } } @@ -131,6 +214,7 @@ fn map_discovery_error(error: PasskeyError) -> Result Ok(PasskeyAuthOutcome::UserCancelled), PasskeyError::NoCredentialFound => Ok(PasskeyAuthOutcome::NoCredentialFound), + PasskeyError::PrfUnsupportedProvider => Err(CloudBackupError::UnsupportedPasskeyProvider), other => Err(CloudBackupError::Passkey(other.to_string())), } } @@ -159,4 +243,10 @@ mod tests { matches!(error, CloudBackupError::Passkey(message) if message == "authentication failed: boom") ); } + + #[test] + fn map_discovery_error_preserves_unsupported_provider() { + let error = map_discovery_error(PasskeyError::PrfUnsupportedProvider).unwrap_err(); + assert!(matches!(error, CloudBackupError::UnsupportedPasskeyProvider)); + } } diff --git a/rust/src/manager/cloud_backup_manager/verify/pending_completion.rs b/rust/src/manager/cloud_backup_manager/verify/pending_completion.rs index d89697c45..c2972c755 100644 --- a/rust/src/manager/cloud_backup_manager/verify/pending_completion.rs +++ b/rust/src/manager/cloud_backup_manager/verify/pending_completion.rs @@ -2,7 +2,7 @@ use ahash::HashMap; use cove_device::cloud_storage::{CloudStorage, CloudStorageError}; use cove_device::keychain::Keychain; use cove_util::ResultExt as _; -use tracing::warn; +use tracing::{error, warn}; use zeroize::Zeroizing; use super::{ @@ -83,25 +83,54 @@ impl RustCloudBackupManager { revision_hash, .. })) => revision_hash.as_str() == upload.target_revision(sync_state), - Some(PersistedCloudBlobState::Failed(_)) => true, + + Some(PersistedCloudBlobState::Failed(_)) => { + Self::remote_pending_upload_exists_or_log(completion, upload).await + } + Some(PersistedCloudBlobState::UploadedPendingConfirmation(_)) => { - CloudStorage::global_silent_client() - .download_wallet_backup( - completion.namespace_id().to_string(), - upload.record_id().to_string(), - ) - .await - .map(|_| true) - .or_else(|error| match error { - CloudStorageError::NotFound(_) => Ok(false), - other => Err(other), - }) - .unwrap_or(false) + Self::remote_pending_upload_exists_or_log(completion, upload).await } + _ => false, } } + async fn remote_pending_upload_exists_or_log( + completion: &PendingVerificationCompletion, + upload: &PendingVerificationUpload, + ) -> bool { + match Self::remote_pending_upload_exists(completion, upload).await { + Ok(exists) => exists, + Err(error) => { + let namespace_id = completion.namespace_id(); + let record_id = upload.record_id(); + let expected_revision = upload.expected_revision(); + error!( + "remote_pending_upload_exists failed for namespace_id={namespace_id} record_id={record_id} expected_revision={expected_revision}: {error:?}" + ); + false + } + } + } + + async fn remote_pending_upload_exists( + completion: &PendingVerificationCompletion, + upload: &PendingVerificationUpload, + ) -> Result { + CloudStorage::global_silent_client() + .download_wallet_backup( + completion.namespace_id().to_string(), + upload.record_id().to_string(), + ) + .await + .map(|_| true) + .or_else(|error| match error { + CloudStorageError::NotFound(_) => Ok(false), + other => Err(other), + }) + } + async fn finalize_pending_verification( &self, completion: PendingVerificationCompletion, diff --git a/rust/src/manager/cloud_backup_manager/verify/session.rs b/rust/src/manager/cloud_backup_manager/verify/session.rs index 3dbfc7acc..ccb7cdd9a 100644 --- a/rust/src/manager/cloud_backup_manager/verify/session.rs +++ b/rust/src/manager/cloud_backup_manager/verify/session.rs @@ -1,4 +1,4 @@ -use cove_cspp::backup_data::EncryptedMasterKeyBackup; +use cove_cspp::backup_data::{EncryptedMasterKeyBackup, MasterKeyBackupVersion}; use cove_cspp::master_key::MasterKey; use cove_cspp::master_key_crypto; use cove_device::cloud_storage::{CloudStorage, CloudStorageClient, CloudStorageError}; @@ -16,7 +16,6 @@ use super::super::{ cloud_inventory::CloudWalletInventory, wallets::{WalletBackupLookup, WalletBackupReader, prepare_wallet_backup}, }; -use super::load_stored_credential_id; use super::passkey_auth::PasskeyAuthOutcome; use super::wrapper_repair::{WrapperRepairError, WrapperRepairOperation, WrapperRepairStrategy}; use crate::manager::cloud_backup_manager::pending::remote_wallet_revision_matches; @@ -36,6 +35,11 @@ enum MasterKeyResolution { Finished(DeepVerificationResult), } +enum RepairedMasterKeyResolution { + Verified(MasterKey), + Finished(DeepVerificationResult), +} + pub(crate) struct VerificationSession { pub(crate) manager: RustCloudBackupManager, pub(crate) keychain: Keychain, @@ -98,27 +102,30 @@ impl VerificationSession { let master_key = match self.resolve_master_key_step(encrypted_master.as_ref()).await? { MasterKeyResolution::VerifiedCloudMasterKey(master_key) => { let master_key = self.apply_verified_cloud_master_key(master_key)?; + + // a valid master key with no wallet inventory means the cloud manifest is missing if self.wallets_missing { return Ok(self.recreate_manifest_result()); } + master_key } + MasterKeyResolution::NeedsWrapperRepair { reuse_credential_id } => { let master_key = match self.repair_wrapper_from_local_key(reuse_credential_id).await? { - MasterKeyResolution::VerifiedCloudMasterKey(master_key) => master_key, - MasterKeyResolution::Finished(result) => return Ok(result), - MasterKeyResolution::NeedsWrapperRepair { .. } => { - unreachable!("wrapper repair must resolve master key") - } + RepairedMasterKeyResolution::Verified(master_key) => master_key, + RepairedMasterKeyResolution::Finished(result) => return Ok(result), }; + // a valid master key with no wallet inventory means the cloud manifest is missing if self.wallets_missing { return Ok(self.recreate_manifest_result()); } master_key } + MasterKeyResolution::Finished(result) => return Ok(result), }; @@ -137,13 +144,15 @@ impl VerificationSession { Ok(remote_wallet_truth) => remote_wallet_truth, Err(error) => return Some(self.remote_truth_retry_result(&error)), }; + self.manager.cleanup_confirmed_pending_blobs(&remote_wallet_truth); - let detail = match self + let detail_result = self .manager .build_cloud_backup_detail_with_remote_truth(&ids, remote_wallet_truth) - .await - { + .await; + + let detail = match detail_result { Ok(detail) => detail, Err(error) => return Some(self.local_inventory_retry_result(&error)), }; @@ -152,11 +161,13 @@ impl VerificationSession { self.wallet_record_ids = Some(ids); None } + Err(CloudStorageError::NotFound(_)) => { self.wallets_missing = true; self.wallet_record_ids = None; None } + Err(error) => { Some(self.retry_result(format!("failed to list wallet backups: {error}"))) } @@ -169,21 +180,25 @@ impl VerificationSession { let encrypted: EncryptedMasterKeyBackup = serde_json::from_slice(&json).map_err_str(CloudBackupError::Internal)?; - if encrypted.version != 1 { - return Ok(EncryptedMasterKeyStep::Finished(DeepVerificationResult::Failed( - DeepVerificationFailure { - kind: VerificationFailureKind::UnsupportedVersion, - message: format!( - "master key backup version {} is not supported", - encrypted.version - ), - detail: self.detail(), - }, - ))); + match encrypted.backup_version() { + Ok(MasterKeyBackupVersion::V1) => {} + Err(unsupported) => { + let version = unsupported.0; + return Ok(EncryptedMasterKeyStep::Finished( + DeepVerificationResult::Failed(DeepVerificationFailure { + kind: VerificationFailureKind::UnsupportedVersion, + message: format!( + "master key backup version {version} is not supported", + ), + detail: self.detail(), + }), + )); + } } Ok(EncryptedMasterKeyStep::Loaded(encrypted)) } + Err(CloudStorageError::NotFound(_)) => { if self.local_master_key.is_some() { return Ok(EncryptedMasterKeyStep::Missing); @@ -195,6 +210,7 @@ impl VerificationSession { ), )) } + Err(error) => Ok(EncryptedMasterKeyStep::Finished( self.retry_result(format!("failed to download master key backup: {error}")), )), @@ -231,6 +247,7 @@ impl VerificationSession { self.report.credential_recovered = authenticated.credential_recovered; match master_key_crypto::decrypt_master_key(encrypted_master, &authenticated.prf_key) { + // cloud wrapper decrypted, so this is the trusted master key for later wallet checks Ok(master_key) => { if let Err(error) = self.keychain.save_cspp_passkey(&authenticated.credential_id, prf_salt) @@ -242,11 +259,13 @@ impl VerificationSession { Ok(MasterKeyResolution::VerifiedCloudMasterKey(master_key)) } + // the passkey worked but the wrapper is stale; use the local key to replace it Err(_) if self.local_master_key.is_some() => { Ok(MasterKeyResolution::NeedsWrapperRepair { reuse_credential_id: Some(authenticated.credential_id), }) } + // without a local key there is no trusted source left to rebuild the cloud wrapper Err(_) => Ok(MasterKeyResolution::Finished(self.reinitialize_result( "could not decrypt cloud master key and no local key available", ))), @@ -258,6 +277,7 @@ impl VerificationSession { master_key: MasterKey, ) -> Result { match &self.local_master_key { + // restore the missing local key from the verified cloud backup None => { self.cspp .save_master_key(&master_key) @@ -265,6 +285,8 @@ impl VerificationSession { self.report.local_master_key_repaired = true; info!("Repaired local master key from cloud"); } + + // replace a stale local key after cloud decryption proves the cloud key is valid Some(local_key) if local_key.as_bytes() != master_key.as_bytes() => { self.cspp .save_master_key(&master_key) @@ -272,6 +294,8 @@ impl VerificationSession { self.report.local_master_key_repaired = true; info!("Repaired local master key to match cloud"); } + + // keep the local key when it already matches the verified cloud key Some(_) => {} } @@ -281,9 +305,9 @@ impl VerificationSession { async fn repair_wrapper_from_local_key( &mut self, reuse_credential_id: Option>, - ) -> Result { + ) -> Result { let Some(local_master_key) = self.local_master_key.as_ref() else { - return Ok(MasterKeyResolution::Finished( + return Ok(RepairedMasterKeyResolution::Finished( self.reinitialize_result("no local master key available for wrapper repair"), )); }; @@ -295,28 +319,32 @@ impl VerificationSession { &self.passkey, &self.namespace, ); + let strategy = match reuse_credential_id { Some(credential_id) => WrapperRepairStrategy::ReuseExisting(credential_id), None => WrapperRepairStrategy::CreateNew, }; - let wallet_record_ids = self.wallet_record_ids.as_deref().unwrap_or(&[]); + let wallet_record_ids = self.wallet_record_ids.as_deref().unwrap_or(&[]); match repair.run(local_master_key, wallet_record_ids, strategy).await { Ok(()) => { self.report.master_key_wrapper_repaired = true; info!("Repaired cloud master key wrapper"); - Ok(MasterKeyResolution::VerifiedCloudMasterKey(MasterKey::from_bytes( + Ok(RepairedMasterKeyResolution::Verified(MasterKey::from_bytes( *local_master_key.as_bytes(), ))) } + Err(WrapperRepairError::WrongKey) => { - Ok(MasterKeyResolution::Finished(self.reinitialize_result( + Ok(RepairedMasterKeyResolution::Finished(self.reinitialize_result( "local master key cannot decrypt existing cloud wallet backups", ))) } - Err(WrapperRepairError::Inconclusive) => Ok(MasterKeyResolution::Finished( + + Err(WrapperRepairError::Inconclusive) => Ok(RepairedMasterKeyResolution::Finished( self.retry_result("could not download any wallet to verify local key"), )), + Err(WrapperRepairError::Operation(error)) => Err(error), } } @@ -332,21 +360,19 @@ impl VerificationSession { self.report.wallets_verified = verified; self.report.wallets_failed = failed; self.report.wallets_unsupported = unsupported; - let remote_wallet_truth = match self - .manager - .load_remote_wallet_truth(&wallet_record_ids, self.cloud.clone()) - .await - { + let remote_wallet_truth_result = + self.manager.load_remote_wallet_truth(&wallet_record_ids, self.cloud.clone()).await; + + let remote_wallet_truth = match remote_wallet_truth_result { Ok(remote_wallet_truth) => remote_wallet_truth, Err(error) => return Some(self.remote_truth_retry_result(&error)), }; - let unsynced = match CloudWalletInventory::load_with_remote_truth( - &wallet_record_ids, - remote_wallet_truth, - ) - .await - { + let inventory_result = + CloudWalletInventory::load_with_remote_truth(&wallet_record_ids, remote_wallet_truth) + .await; + + let unsynced = match inventory_result { Ok(inventory) => { let detail = inventory.build_detail(); self.report.detail = Some(detail.clone()); @@ -358,6 +384,7 @@ impl VerificationSession { inventory.upload_candidate_wallets() } + Err(error) => return Some(self.local_inventory_retry_result(&error)), }; @@ -401,7 +428,9 @@ impl VerificationSession { Ok(remote_wallet_truth) => remote_wallet_truth, Err(error) => return Some(self.remote_truth_retry_result(&error)), }; + self.manager.cleanup_confirmed_pending_blobs(&remote_wallet_truth); + let unconfirmed_pending_uploads = pending_uploads .iter() .filter(|upload| { @@ -412,6 +441,7 @@ impl VerificationSession { ) }) .count(); + let inventory = match CloudWalletInventory::load_with_remote_truth(&updated_ids, remote_wallet_truth) .await @@ -419,16 +449,19 @@ impl VerificationSession { Ok(inventory) => inventory, Err(error) => return Some(self.local_inventory_retry_result(&error)), }; - let listed: std::collections::HashSet<_> = updated_ids.iter().cloned().collect(); + let listed: std::collections::HashSet<_> = updated_ids.iter().cloned().collect(); let remaining_unsynced = inventory.upload_candidate_wallets(); + self.report.detail = Some(inventory.build_detail()); self.wallet_record_ids = Some(updated_ids); + if inventory.has_unknown_remote_wallets() { return Some( self.retry_result("failed to refresh remote wallet truth for some wallets"), ); } + let missing_listed_uploads = pending_uploads.iter().any(|upload| !listed.contains(upload.record_id())); @@ -443,9 +476,11 @@ impl VerificationSession { let missing_count = pending_uploads.iter().filter(|upload| !listed.contains(upload.record_id())).count(); let stale_count = unconfirmed_pending_uploads.saturating_sub(missing_count); + warn!( "Deep verify: auto-sync finished but confirmation is still pending missing_listed={missing_count} stale={stale_count} stale_or_unsynced={remaining_count}" ); + self.manager.replace_pending_verification_completion(PendingVerificationCompletion::new( self.report.clone(), self.namespace.clone(), @@ -503,6 +538,7 @@ impl VerificationSession { self.retry_result(format!("failed to refresh remote wallet truth: {error}")) } + /// Builds a retryable verification failure while preserving the latest backup detail for UI recovery prompts fn retry_result(&self, message: impl Into) -> DeepVerificationResult { DeepVerificationResult::Failed(DeepVerificationFailure { kind: VerificationFailureKind::Retry, @@ -511,6 +547,7 @@ impl VerificationSession { }) } + /// Builds the failure shown when wallet blobs are missing but local data can recreate the manifest fn recreate_manifest_result(&self) -> DeepVerificationResult { DeepVerificationResult::Failed(DeepVerificationFailure { kind: VerificationFailureKind::RecreateManifest { warning: RECREATE_WARNING.into() }, @@ -519,6 +556,7 @@ impl VerificationSession { }) } + /// Builds the failure shown when the backup cannot be trusted and should be recreated from scratch fn reinitialize_result(&self, message: impl Into) -> DeepVerificationResult { DeepVerificationResult::Failed(DeepVerificationFailure { kind: VerificationFailureKind::ReinitializeBackup { @@ -534,8 +572,11 @@ impl VerificationSession { /// we avoid downgrading persisted state. If the credential is gone the /// passkey is durably missing and the user needs repair fn resolve_cancellation_outcome(&self) -> DeepVerificationResult { - if let Some(credential_id) = load_stored_credential_id(&self.keychain) { - match self.passkey.check_passkey_presence(PASSKEY_RP_ID.to_string(), credential_id) { + match self.keychain.load_cspp_credential_id() { + Some(credential_id) => match self + .passkey + .check_passkey_presence(PASSKEY_RP_ID.to_string(), credential_id) + { PasskeyCredentialPresence::Present => { info!("Passkey picker cancelled but stored credential still exists"); cancellation_outcome(PasskeyCredentialPresence::Present, self.detail()) @@ -551,14 +592,16 @@ impl VerificationSession { ); cancellation_outcome(PasskeyCredentialPresence::Indeterminate, self.detail()) } + }, + None => { + info!("Passkey picker cancelled and no stored credential found"); + DeepVerificationResult::PasskeyMissing(self.detail()) } - } else { - info!("Passkey picker cancelled and no stored credential found"); - DeepVerificationResult::PasskeyMissing(self.detail()) } } } +/// Maps passkey presence after cancellation to the verification result the UI should show fn cancellation_outcome( presence: PasskeyCredentialPresence, detail: Option, diff --git a/rust/src/manager/cloud_backup_manager/verify/wrapper_repair.rs b/rust/src/manager/cloud_backup_manager/verify/wrapper_repair.rs index 2f140391c..75ab0a630 100644 --- a/rust/src/manager/cloud_backup_manager/verify/wrapper_repair.rs +++ b/rust/src/manager/cloud_backup_manager/verify/wrapper_repair.rs @@ -13,8 +13,7 @@ use super::super::{ CloudBackupError, PASSKEY_RP_ID, RustCloudBackupManager, cspp_master_key_record_id, }; use crate::manager::cloud_backup_manager::wallets::{ - WalletBackupLookup, WalletBackupReader, create_prf_key_without_persisting, - discover_or_create_prf_key_without_persisting, + PasskeyMaterialAcquirer, WalletBackupLookup, WalletBackupReader, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -45,6 +44,7 @@ impl WrapperRepairError { } } +/// Chooses how wrapper repair should acquire passkey material #[derive(Debug)] pub(super) enum WrapperRepairStrategy { CreateNew, @@ -109,6 +109,7 @@ impl LocalKeyVerifier { } } +/// Repairs the cloud master-key wrapper after proving the local master key is valid pub(super) struct WrapperRepairOperation { manager: RustCloudBackupManager, keychain: Keychain, @@ -134,6 +135,7 @@ impl WrapperRepairOperation { } } + /// Verifies the local key, uploads a repaired wrapper, then persists the selected credential pub(super) async fn run( &self, local_master_key: &MasterKey, @@ -144,6 +146,7 @@ impl WrapperRepairOperation { let credentials = self.credentials(strategy).await.map_err(WrapperRepairError::Operation)?; + let encrypted_backup = master_key_crypto::encrypt_master_key( local_master_key, &credentials.prf_key, @@ -166,6 +169,7 @@ impl WrapperRepairOperation { .save_cspp_passkey(&credentials.credential_id, credentials.prf_salt) .map_err_prefix("save cspp credentials", CloudBackupError::Internal) .map_err(WrapperRepairError::Operation)?; + self.manager .mark_blob_uploaded_pending_confirmation( self.namespace.as_str(), @@ -202,24 +206,23 @@ impl WrapperRepairOperation { ) -> Result { match strategy { WrapperRepairStrategy::CreateNew => { - let new_prf = create_prf_key_without_persisting(&self.passkey).await?; + let new_prf = + PasskeyMaterialAcquirer::new(&self.passkey).create_for_wrapper_repair().await?; + let (prf_key, prf_salt, credential_id) = new_prf.into_parts(); - Ok(WrapperRepairCredentials { - prf_key: new_prf.prf_key, - prf_salt: new_prf.prf_salt, - credential_id: new_prf.credential_id.clone(), - }) + Ok(WrapperRepairCredentials { prf_key, prf_salt, credential_id }) } + WrapperRepairStrategy::DiscoverOrCreate => { - let passkey = discover_or_create_prf_key_without_persisting(&self.passkey).await?; + let passkey = PasskeyMaterialAcquirer::new(&self.passkey) + .discover_or_create_for_wrapper_repair() + .await?; info!("Using discovered-or-new passkey for wrapper repair"); + let (prf_key, prf_salt, credential_id) = passkey.into_parts(); - Ok(WrapperRepairCredentials { - prf_key: passkey.prf_key, - prf_salt: passkey.prf_salt, - credential_id: passkey.credential_id.clone(), - }) + Ok(WrapperRepairCredentials { prf_key, prf_salt, credential_id }) } + WrapperRepairStrategy::ReuseExisting(credential_id) => { let prf_salt: [u8; 32] = rand::rng().random(); let passkey = self.passkey.clone(); diff --git a/rust/src/manager/cloud_backup_manager/wallets.rs b/rust/src/manager/cloud_backup_manager/wallets.rs index 58cd79b68..77f2d3703 100644 --- a/rust/src/manager/cloud_backup_manager/wallets.rs +++ b/rust/src/manager/cloud_backup_manager/wallets.rs @@ -32,6 +32,12 @@ impl UnpersistedPrfKey { credential_id: self.credential_id.clone(), } } + + pub(crate) fn into_parts(mut self) -> ([u8; 32], [u8; 32], Vec) { + let credential_id = std::mem::take(&mut self.credential_id); + + (self.prf_key, self.prf_salt, credential_id) + } } pub(super) struct DownloadedWalletBackup { @@ -54,8 +60,7 @@ pub(crate) struct PreparedWalletBackup { } pub(crate) use passkey::{ - NamespaceMatch, NamespaceMatchOutcome, create_new_prf_key, create_prf_key_without_persisting, - discover_or_create_prf_key_without_persisting, try_match_namespace_with_passkey, + NamespaceMatch, NamespaceMatchOutcome, NamespacePasskeyMatcher, PasskeyMaterialAcquirer, }; #[cfg(test)] pub(crate) use payload::convert_cloud_secret; @@ -65,6 +70,7 @@ pub(crate) use payload::{ pub(crate) use restore::{ WalletBackupLookup, WalletBackupReader, WalletRestoreOutcome, WalletRestoreSession, }; + pub(crate) use upload::upload_all_wallets; pub(super) fn persist_enabled_cloud_backup_state( diff --git a/rust/src/manager/cloud_backup_manager/wallets/passkey.rs b/rust/src/manager/cloud_backup_manager/wallets/passkey.rs index 9b8d5d867..70a55a4d7 100644 --- a/rust/src/manager/cloud_backup_manager/wallets/passkey.rs +++ b/rust/src/manager/cloud_backup_manager/wallets/passkey.rs @@ -1,6 +1,6 @@ use std::time::Duration; -use cove_cspp::backup_data::EncryptedMasterKeyBackup; +use cove_cspp::backup_data::{EncryptedMasterKeyBackup, MasterKeyBackupVersion}; use cove_device::cloud_storage::CloudStorageClient; use cove_device::passkey::{PasskeyAccess, PasskeyError}; use cove_tokio::unblock; @@ -10,46 +10,6 @@ use tracing::{info, warn}; use super::super::{CloudBackupError, PASSKEY_RP_ID}; use super::UnpersistedPrfKey; -async fn create_new_prf_key_with_mapper( - passkey: &PasskeyAccess, - map_passkey_error: fn(PasskeyError) -> CloudBackupError, -) -> Result { - let prf_salt: [u8; 32] = rand::rng().random(); - let credential_id = { - let passkey = passkey.clone(); - unblock::run_blocking(move || { - passkey.create_passkey( - PASSKEY_RP_ID.to_string(), - rand::rng().random::<[u8; 16]>().to_vec(), - random_challenge(), - ) - }) - .await - .map_err(map_passkey_error)? - }; - - // wait briefly before targeted auth so iOS can settle after registration - // without probing for presence and flashing another native passkey sheet - delay_before_new_passkey_auth().await; - - let prf_output = { - let passkey = passkey.clone(); - let credential_id = credential_id.clone(); - unblock::run_blocking(move || { - passkey.authenticate_with_prf( - PASSKEY_RP_ID.to_string(), - credential_id, - prf_salt.to_vec(), - random_challenge(), - ) - }) - .await - .map_err(map_passkey_error)? - }; - - Ok(UnpersistedPrfKey { prf_key: prf_output_to_key(prf_output)?, prf_salt, credential_id }) -} - async fn delay_before_new_passkey_auth() { let delay = Duration::from_secs(3); info!("Waiting {delay:?} before authenticating new passkey"); @@ -64,157 +24,161 @@ pub struct NamespaceMatch { } pub enum NamespaceMatchOutcome { - Matched(NamespaceMatch), + Matched(Vec), UserDeclined, NoMatch, Inconclusive, UnsupportedVersions, } -/// Create a passkey and authenticate with PRF without persisting to keychain -/// -/// Used by the wrapper-repair path where we need to defer persistence until -/// after the cloud upload succeeds -pub async fn create_prf_key_without_persisting( - passkey: &PasskeyAccess, -) -> Result { - info!("Creating new passkey for wrapper repair"); - create_new_prf_key_with_mapper(passkey, map_wrapper_repair_passkey_error).await +struct PasskeyMaterialDiscoveryContext { + fallback_context: &'static str, + attempt_message: &'static str, + discovered_message: &'static str, + cancelled_message: &'static str, + missing_message: &'static str, + failed_message: &'static str, + create_for_enable: bool, } -/// Try to discover an existing passkey, fall back to creating a new one -/// -/// Used by wrapper repair so keychain persistence still happens only after -/// the repaired master-key wrapper upload succeeds -pub async fn discover_or_create_prf_key_without_persisting( - passkey: &PasskeyAccess, -) -> Result { - info!("Attempting passkey discovery before creating new wrapper-repair passkey"); - let prf_salt: [u8; 32] = rand::rng().random(); - - let discovery = { - let passkey = passkey.clone(); - unblock::run_blocking(move || { - passkey.discover_and_authenticate_with_prf( - PASSKEY_RP_ID.to_string(), - prf_salt.to_vec(), - random_challenge(), - ) - }) - .await - }; +/// Acquires passkey PRF material without persisting it to the keychain +pub struct PasskeyMaterialAcquirer { + passkey: PasskeyAccess, +} - match discovery { - Ok(discovered) => { - let prf_key = prf_output_to_key(discovered.prf_output)?; - info!("Discovered existing passkey for wrapper repair"); +impl PasskeyMaterialAcquirer { + /// Builds an acquirer from the passkey service handle + pub fn new(passkey: &PasskeyAccess) -> Self { + Self { passkey: passkey.clone() } + } - Ok(UnpersistedPrfKey { prf_key, prf_salt, credential_id: discovered.credential_id }) - } - Err(PasskeyError::UserCancelled) => { - info!("User cancelled passkey discovery for wrapper repair"); - Err(CloudBackupError::PasskeyDiscoveryCancelled) - } - Err(PasskeyError::NoCredentialFound) => { - info!("No existing passkey found for wrapper repair, creating new"); - create_prf_key_without_persisting(passkey).await - } - Err(PasskeyError::PrfUnsupportedProvider) => { - Err(CloudBackupError::UnsupportedPasskeyProvider) - } - Err(error) => { - warn!("Wrapper-repair discovery failed ({error}), falling back to create"); - create_prf_key_without_persisting(passkey).await - } + /// Creates a passkey for wrapper repair without persisting keychain state + pub async fn create_for_wrapper_repair(&self) -> Result { + info!("Creating new passkey for wrapper repair"); + self.create_new_prf_key_with_mapper(map_wrapper_repair_passkey_error).await } -} -/// Try to match the selected passkey against cloud namespaces -pub async fn try_match_namespace_with_passkey( - cloud: &CloudStorageClient, - passkey: &PasskeyAccess, - namespaces: &[String], -) -> Result { - if namespaces.is_empty() { - return Ok(NamespaceMatchOutcome::NoMatch); + /// Creates a passkey for enabling cloud backup without persisting keychain state + pub async fn create_for_enable(&self) -> Result { + info!("Creating new passkey for cloud backup enable"); + self.create_new_prf_key_with_mapper(map_enable_passkey_error).await } - let mut downloaded: Vec<(String, EncryptedMasterKeyBackup)> = - Vec::with_capacity(namespaces.len()); - let mut had_download_failures = false; - let mut had_unsupported_versions = false; + /// Discovers an existing passkey for wrapper repair or creates a new one + pub async fn discover_or_create_for_wrapper_repair( + &self, + ) -> Result { + self.discover_or_create(PasskeyMaterialDiscoveryContext { + fallback_context: "wrapper repair", + attempt_message: "Attempting passkey discovery before creating new wrapper-repair passkey", + discovered_message: "Discovered existing passkey for wrapper repair", + cancelled_message: "User cancelled passkey discovery for wrapper repair", + missing_message: "No existing passkey found for wrapper repair, creating new", + failed_message: "Wrapper-repair discovery failed", + create_for_enable: false, + }) + .await + } - for namespace in namespaces { - let Ok(master_json) = - cloud.download_master_key_backup(namespace.clone()).await.inspect_err(|error| { - warn!("Failed to download master key for namespace {namespace}: {error}"); - had_download_failures = true; - }) - else { - continue; - }; + /// Discovers an existing passkey for enabling cloud backup or creates a new one + pub async fn discover_or_create_for_enable( + &self, + ) -> Result { + self.discover_or_create(PasskeyMaterialDiscoveryContext { + fallback_context: "cloud backup enable", + attempt_message: + "Attempting passkey discovery before creating new cloud backup enable passkey", + discovered_message: "Discovered existing passkey for cloud backup enable", + cancelled_message: "User cancelled passkey discovery for cloud backup enable", + missing_message: "No existing passkey found for cloud backup enable, creating new", + failed_message: "Cloud backup enable discovery failed", + create_for_enable: true, + }) + .await + } + + async fn discover_or_create( + &self, + context: PasskeyMaterialDiscoveryContext, + ) -> Result { + info!("{}", context.attempt_message); + let prf_salt: [u8; 32] = rand::rng().random(); - let Ok(encrypted) = serde_json::from_slice::(&master_json) - .inspect_err(|error| { - warn!("Failed to deserialize master key for namespace {namespace}: {error}"); - had_download_failures = true; + let discovery = { + let passkey = self.passkey.clone(); + unblock::run_blocking(move || { + passkey.discover_and_authenticate_with_prf( + PASSKEY_RP_ID.to_string(), + prf_salt.to_vec(), + random_challenge(), + ) }) - else { - continue; + .await }; - if encrypted.version != 1 { - had_unsupported_versions = true; - continue; - } + match discovery { + Ok(discovered) => { + let prf_key = prf_output_to_key(discovered.prf_output)?; + info!("{}", context.discovered_message); - downloaded.push((namespace.clone(), encrypted)); + Ok(UnpersistedPrfKey { prf_key, prf_salt, credential_id: discovered.credential_id }) + } + Err(PasskeyError::UserCancelled) => { + info!("{}", context.cancelled_message); + Err(CloudBackupError::PasskeyDiscoveryCancelled) + } + Err(PasskeyError::NoCredentialFound) => { + info!("{}", context.missing_message); + self.create_for_context(context.create_for_enable).await + } + Err(PasskeyError::PrfUnsupportedProvider) => { + Err(CloudBackupError::UnsupportedPasskeyProvider) + } + Err(error) => { + let failed_message = context.failed_message; + let fallback_context = context.fallback_context; + warn!("{failed_message} ({error}), falling back to create for {fallback_context}"); + self.create_for_context(context.create_for_enable).await + } + } } - if downloaded.is_empty() && had_download_failures { - return Ok(NamespaceMatchOutcome::Inconclusive); - } + async fn create_for_context( + &self, + create_for_enable: bool, + ) -> Result { + if create_for_enable { + return self.create_for_enable().await; + } - if downloaded.is_empty() && had_unsupported_versions { - return Ok(NamespaceMatchOutcome::UnsupportedVersions); + self.create_for_wrapper_repair().await } - let (namespace_id, first_encrypted) = &downloaded[0]; - let discovery = { - let passkey = passkey.clone(); - let prf_salt = first_encrypted.prf_salt; - unblock::run_blocking(move || { - passkey.discover_and_authenticate_with_prf( - PASSKEY_RP_ID.to_string(), - prf_salt.to_vec(), - random_challenge(), - ) - }) - .await - }; - let discovered = match discovery { - Ok(discovered) => discovered, - Err(PasskeyError::UserCancelled) => return Ok(NamespaceMatchOutcome::UserDeclined), - Err(PasskeyError::NoCredentialFound) => return Ok(NamespaceMatchOutcome::NoMatch), - Err(error) => return Err(CloudBackupError::Passkey(error.to_string())), - }; - - let prf_key = prf_output_to_key(discovered.prf_output.clone())?; - let try_first = cove_cspp::master_key_crypto::decrypt_master_key(first_encrypted, &prf_key); - if let Ok(master_key) = try_first { - return Ok(NamespaceMatchOutcome::Matched(NamespaceMatch { - namespace_id: namespace_id.clone(), - master_key, - prf_salt: first_encrypted.prf_salt, - credential_id: discovered.credential_id, - })); - } + async fn create_new_prf_key_with_mapper( + &self, + map_passkey_error: fn(PasskeyError) -> CloudBackupError, + ) -> Result { + let prf_salt: [u8; 32] = rand::rng().random(); + let credential_id = { + let passkey = self.passkey.clone(); + unblock::run_blocking(move || { + passkey.create_passkey( + PASSKEY_RP_ID.to_string(), + rand::rng().random::<[u8; 16]>().to_vec(), + random_challenge(), + ) + }) + .await + .map_err(map_passkey_error)? + }; + + // wait briefly before targeted auth so iOS can settle after registration + // without probing for presence and flashing another native passkey sheet + delay_before_new_passkey_auth().await; - for (namespace_id, encrypted) in downloaded.iter().skip(1) { - let prf_output_result = { - let passkey = passkey.clone(); - let credential_id = discovered.credential_id.clone(); - let prf_salt = encrypted.prf_salt; + let prf_output = { + let passkey = self.passkey.clone(); + let credential_id = credential_id.clone(); unblock::run_blocking(move || { passkey.authenticate_with_prf( PASSKEY_RP_ID.to_string(), @@ -224,51 +188,177 @@ pub async fn try_match_namespace_with_passkey( ) }) .await + .map_err(map_passkey_error)? }; - let prf_output = match prf_output_result { - Ok(prf_output) => prf_output, - Err(PasskeyError::UserCancelled) => return Ok(NamespaceMatchOutcome::UserDeclined), - Err(error) => { - warn!("Failed targeted passkey auth for namespace {namespace_id}: {error}"); - had_download_failures = true; + Ok(UnpersistedPrfKey { prf_key: prf_output_to_key(prf_output)?, prf_salt, credential_id }) + } +} + +/// Matches a discoverable passkey against candidate cloud backup namespaces +pub struct NamespacePasskeyMatcher { + cloud: CloudStorageClient, + passkey: PasskeyAccess, +} + +impl NamespacePasskeyMatcher { + /// Builds a matcher from cloud and passkey service handles + pub fn new(cloud: &CloudStorageClient, passkey: &PasskeyAccess) -> Self { + Self { cloud: cloud.clone(), passkey: passkey.clone() } + } + + /// Downloads candidate wrappers and tries the selected passkey against each PRF salt + pub async fn match_namespaces( + &self, + namespaces: &[String], + ) -> Result { + if namespaces.is_empty() { + return Ok(NamespaceMatchOutcome::NoMatch); + } + + let mut downloaded: Vec<(String, EncryptedMasterKeyBackup)> = + Vec::with_capacity(namespaces.len()); + let mut had_download_failures = false; + let mut had_unsupported_versions = false; + + for namespace in namespaces { + let Ok(master_json) = + self.cloud.download_master_key_backup(namespace.clone()).await.inspect_err( + |error| { + warn!("Failed to download master key for namespace {namespace}: {error}"); + had_download_failures = true; + }, + ) + else { + continue; + }; + + let Ok(encrypted) = serde_json::from_slice::(&master_json) + .inspect_err(|error| { + warn!("Failed to deserialize master key for namespace {namespace}: {error}"); + had_download_failures = true; + }) + else { continue; + }; + + match encrypted.backup_version() { + Ok(MasterKeyBackupVersion::V1) => {} + Err(_) => { + had_unsupported_versions = true; + continue; + } } - }; - let prf_key = prf_output_to_key(prf_output)?; + downloaded.push((namespace.clone(), encrypted)); + } + + if downloaded.is_empty() && had_download_failures { + return Ok(NamespaceMatchOutcome::Inconclusive); + } + + if downloaded.is_empty() && had_unsupported_versions { + return Ok(NamespaceMatchOutcome::UnsupportedVersions); + } + + let (namespace_id, first_encrypted) = &downloaded[0]; + let discovery = { + let passkey = self.passkey.clone(); + let prf_salt = first_encrypted.prf_salt; + unblock::run_blocking(move || { + passkey.discover_and_authenticate_with_prf( + PASSKEY_RP_ID.to_string(), + prf_salt.to_vec(), + random_challenge(), + ) + }) + .await + }; + let discovered = match discovery { + Ok(discovered) => discovered, + Err(PasskeyError::UserCancelled) => return Ok(NamespaceMatchOutcome::UserDeclined), + Err(PasskeyError::NoCredentialFound) => return Ok(NamespaceMatchOutcome::NoMatch), + Err(PasskeyError::PrfUnsupportedProvider) => { + return Err(CloudBackupError::UnsupportedPasskeyProvider); + } + Err(error) => return Err(CloudBackupError::Passkey(error.to_string())), + }; - if let Ok(master_key) = - cove_cspp::master_key_crypto::decrypt_master_key(encrypted, &prf_key) - { - let matched = NamespaceMatch { + let mut matches = Vec::new(); + let prf_key = prf_output_to_key(discovered.prf_output.clone())?; + let try_first = cove_cspp::master_key_crypto::decrypt_master_key(first_encrypted, &prf_key); + if let Ok(master_key) = try_first { + matches.push(NamespaceMatch { namespace_id: namespace_id.clone(), master_key, - prf_salt: encrypted.prf_salt, + prf_salt: first_encrypted.prf_salt, credential_id: discovered.credential_id.clone(), + }); + } + + for (namespace_id, encrypted) in downloaded.iter().skip(1) { + let prf_output_result = { + let passkey = self.passkey.clone(); + let credential_id = discovered.credential_id.clone(); + let prf_salt = encrypted.prf_salt; + unblock::run_blocking(move || { + passkey.authenticate_with_prf( + PASSKEY_RP_ID.to_string(), + credential_id, + prf_salt.to_vec(), + random_challenge(), + ) + }) + .await + }; + + let prf_output = match prf_output_result { + Ok(prf_output) => prf_output, + Err(PasskeyError::UserCancelled) => { + if matches.is_empty() { + return Ok(NamespaceMatchOutcome::UserDeclined); + } + + break; + } + Err(PasskeyError::PrfUnsupportedProvider) => { + return Err(CloudBackupError::UnsupportedPasskeyProvider); + } + Err(error) => { + warn!("Failed targeted passkey auth for namespace {namespace_id}: {error}"); + had_download_failures = true; + continue; + } }; - return Ok(NamespaceMatchOutcome::Matched(matched)); + let prf_key = prf_output_to_key(prf_output)?; + + if let Ok(master_key) = + cove_cspp::master_key_crypto::decrypt_master_key(encrypted, &prf_key) + { + matches.push(NamespaceMatch { + namespace_id: namespace_id.clone(), + master_key, + prf_salt: encrypted.prf_salt, + credential_id: discovered.credential_id.clone(), + }); + } } - } - if had_download_failures { - return Ok(NamespaceMatchOutcome::Inconclusive); - } + if !matches.is_empty() { + return Ok(NamespaceMatchOutcome::Matched(matches)); + } - if downloaded.is_empty() && had_unsupported_versions { - return Ok(NamespaceMatchOutcome::UnsupportedVersions); - } + if had_download_failures { + return Ok(NamespaceMatchOutcome::Inconclusive); + } - Ok(NamespaceMatchOutcome::NoMatch) -} + if downloaded.is_empty() && had_unsupported_versions { + return Ok(NamespaceMatchOutcome::UnsupportedVersions); + } -pub async fn create_new_prf_key( - passkey: &PasskeyAccess, - log_message: &str, -) -> Result { - info!("{log_message}"); - create_new_prf_key_with_mapper(passkey, map_enable_passkey_error).await + Ok(NamespaceMatchOutcome::NoMatch) + } } fn map_wrapper_repair_passkey_error(error: PasskeyError) -> CloudBackupError { diff --git a/rust/src/manager/cloud_backup_manager/wallets/upload.rs b/rust/src/manager/cloud_backup_manager/wallets/upload.rs index c911378aa..b97ce78e0 100644 --- a/rust/src/manager/cloud_backup_manager/wallets/upload.rs +++ b/rust/src/manager/cloud_backup_manager/wallets/upload.rs @@ -459,6 +459,7 @@ fn is_upload_preparation_failure_retryable(error: &CloudBackupError) -> bool { | CloudBackupError::Passkey(_) | CloudBackupError::Crypto(_) | CloudBackupError::Internal(_) + | CloudBackupError::Compatibility(_) | CloudBackupError::PasskeyMismatch | CloudBackupError::PasskeyDiscoveryCancelled | CloudBackupError::Cancelled => false, diff --git a/rust/src/manager/onboarding_manager.rs b/rust/src/manager/onboarding_manager.rs index 68ea2afee..404ee813b 100644 --- a/rust/src/manager/onboarding_manager.rs +++ b/rust/src/manager/onboarding_manager.rs @@ -577,8 +577,7 @@ impl RustOnboardingManager { fn complete_onboarding(&self, target: CompletionTarget) { let result = match target { CompletionTarget::SelectLatestOrNew => { - FfiApp::global().select_latest_or_new_wallet(); - Ok(()) + FfiApp::global().select_latest_or_new_wallet().map_err_str(std::convert::identity) } CompletionTarget::SelectWallet { wallet_id, post_onboarding } => { let next_route = match post_onboarding { diff --git a/rust/src/manager/pending_wallet_manager.rs b/rust/src/manager/pending_wallet_manager.rs index 8e2b93b0a..aec5c3b68 100644 --- a/rust/src/manager/pending_wallet_manager.rs +++ b/rust/src/manager/pending_wallet_manager.rs @@ -73,6 +73,9 @@ pub enum WalletCreationError { #[error("failed to import hardware wallet: {0}")] Import(String), + #[error("unexpected wallet creation error: {0}")] + Unexpected(String), + #[error(transparent)] MultiFormat(#[from] MultiFormatError), } @@ -199,14 +202,20 @@ impl From for WalletCreationError { Self::Import(format!("wallet already exists: {id}")) } - WalletError::WalletNotFound => unreachable!("no wallet found in creation"), - WalletError::LoadError(error) => unreachable!("no loading in creation:{error}"), - WalletError::MetadataNotFound => unreachable!("no metadata found in creation"), + WalletError::WalletNotFound => { + Self::Unexpected("wallet not found during creation".to_string()) + } + WalletError::LoadError(error) => { + Self::Unexpected(format!("load error during creation: {error}")) + } + WalletError::MetadataNotFound => { + Self::Unexpected("wallet metadata not found during creation".to_string()) + } WalletError::UnsupportedWallet(error) => { - unreachable!("unreachable unsupported wallet: {error}") + Self::Unexpected(format!("unsupported wallet during creation: {error}")) } WalletError::DescriptorKeyParseError(error) => { - unreachable!("unreachable descriptor key parse error: {error}") + Self::Unexpected(format!("descriptor key parse error during creation: {error}")) } } } diff --git a/rust/src/qr_scanner.rs b/rust/src/qr_scanner.rs index 091780b37..9bfd82fd6 100644 --- a/rust/src/qr_scanner.rs +++ b/rust/src/qr_scanner.rs @@ -346,7 +346,10 @@ impl QrScanner { Self::scan_ur_part(*ur, &qr)? } - Self::Complete(_) => unreachable!("handled above"), + Self::Complete(result) => { + *self = Self::Complete(result.clone()); + return Ok(result); + } }; *self = new_state;