diff --git a/apps/ios/Koins/AppModel.swift b/apps/ios/Koins/AppModel.swift index 749a7c9..fc9f4cb 100644 --- a/apps/ios/Koins/AppModel.swift +++ b/apps/ios/Koins/AppModel.swift @@ -21,6 +21,7 @@ final class AppModel: ObservableObject { private var refreshTick = 0 private var refreshTask: Task? private var lastSample: (height: UInt64, date: Date)? + private var lastCheckpoint = Date.distantPast var wallet: WalletRecord? { chain == .xrp ? xrpWallet : xmrWallet @@ -212,6 +213,7 @@ final class AppModel: ObservableObject { // progress current here too. if moneroReady, let status = try? await MoneroService.shared.syncStatus() { updateSyncStatus(status) + await checkpointWalletIfSyncing() } return } @@ -222,6 +224,7 @@ final class AppModel: ObservableObject { // list touch the wallet's data files, so they are refreshed less often. updateSyncStatus(try await MoneroService.shared.syncStatus()) refreshTick += 1 + await checkpointWalletIfSyncing() if force || refreshTick == 1 || refreshTick % 4 == 0 { async let balanceCall = MoneroService.shared.balance() async let txCall = MoneroService.shared.transactions() @@ -334,4 +337,20 @@ final class AppModel: ObservableObject { errorMessage = nil statusMessage = nil } + + /// Called when the app leaves the foreground: iOS can suspend or kill it at + /// any point, so flush the wallet while there is still time. + func persistWallet() async { + guard moneroReady else { return } + await MoneroService.shared.save() + } + + /// monero-cpp only writes the wallet file from save(), and its sync loop + /// never does, so without this a restart re-scans from the beginning. + private func checkpointWalletIfSyncing() async { + guard moneroReady, syncStatus?.syncing == true else { return } + guard Date().timeIntervalSince(lastCheckpoint) >= 60 else { return } + lastCheckpoint = Date() + await MoneroService.shared.save() + } } \ No newline at end of file diff --git a/apps/ios/Koins/Bridge/KoinsMoneroWallet.mm b/apps/ios/Koins/Bridge/KoinsMoneroWallet.mm index 3e58d0b..ddea5a8 100644 --- a/apps/ios/Koins/Bridge/KoinsMoneroWallet.mm +++ b/apps/ios/Koins/Bridge/KoinsMoneroWallet.mm @@ -145,6 +145,16 @@ static void connectDaemon(monero_wallet_full *w) { delete g_wallet; g_wallet = nullptr; } + } else if ([op isEqualToString:@"saveWallet"]) { + // monero-cpp never persists while syncing, so scan progress survives a + // restart only if this is called explicitly. save() pauses the sync + // thread while it writes. + if (g_wallet) { + g_wallet->save(); + resp[@"saved"] = @YES; + } else { + resp[@"error"] = @"No wallet open"; + } } else if ([op isEqualToString:@"getBalance"]) { if (g_wallet) { uint64_t balance = 0, unlocked = 0; diff --git a/apps/ios/Koins/Views/RootView.swift b/apps/ios/Koins/Views/RootView.swift index 6bbc2f7..472d85e 100644 --- a/apps/ios/Koins/Views/RootView.swift +++ b/apps/ios/Koins/Views/RootView.swift @@ -17,6 +17,7 @@ struct SeedBox: Identifiable { struct RootView: View { @EnvironmentObject private var model: AppModel + @Environment(\.scenePhase) private var scenePhase @State private var route: Route = .home @State private var seedBox: SeedBox? @@ -54,6 +55,11 @@ struct RootView: View { .onChange(of: model.chain) { _ in if route == .dashboard { route = .home } } + .onChange(of: scenePhase) { phase in + if phase != .active { + Task { await model.persistWallet() } + } + } } } @@ -158,6 +164,26 @@ struct HomeView: View { } } + if model.chain == .xrp, model.moneroReady, let status = model.syncStatus, status.syncing { + Card { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Circle() + .fill(status.connected ? Theme.success : Theme.danger) + .frame(width: 7, height: 7) + Text("Monero is syncing in the background") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Theme.text) + Spacer() + Text("\(Int(status.progress * 100))%") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Theme.muted) + } + SyncProgress(status: status, eta: model.syncEta) + } + } + } + HStack(spacing: 10) { SecondaryButton(title: "Refresh") { Task { await model.refresh(force: true) } } SecondaryButton(title: "Delete") { showDeleteConfirm = true } @@ -183,25 +209,6 @@ struct HomeView: View { .foregroundStyle(Theme.muted) } - if model.chain == .xrp, model.moneroReady, let status = model.syncStatus, status.syncing { - Card { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - Circle() - .fill(status.connected ? Theme.success : Theme.danger) - .frame(width: 7, height: 7) - Text("Monero is syncing in the background") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(Theme.text) - Spacer() - Text("\(Int(status.progress * 100))%") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(Theme.muted) - } - SyncProgress(status: status, eta: model.syncEta) - } - } - } } } diff --git a/apps/ios/Koins/Wallet/MoneroService.swift b/apps/ios/Koins/Wallet/MoneroService.swift index aa80fa8..ea16f1c 100644 --- a/apps/ios/Koins/Wallet/MoneroService.swift +++ b/apps/ios/Koins/Wallet/MoneroService.swift @@ -158,6 +158,11 @@ final class MoneroService { _ = try? await invoke(["op": "closeWallet"]) } + /// Writes the wallet file, which is how scan progress survives a restart. + func save() async { + _ = try? await invoke(["op": "saveWallet"]) + } + func seed() async throws -> String { let response = try await invoke(["op": "getSeed"]) return response["mnemonic"] as? String ?? ""