Keep Monero scan progress across app restarts

This commit is contained in:
2026-09-19 22:17:00 +08:00
parent f4037a188a
commit 807388ea6b
4 changed files with 60 additions and 19 deletions
+19
View File
@@ -21,6 +21,7 @@ final class AppModel: ObservableObject {
private var refreshTick = 0 private var refreshTick = 0
private var refreshTask: Task<Void, Never>? private var refreshTask: Task<Void, Never>?
private var lastSample: (height: UInt64, date: Date)? private var lastSample: (height: UInt64, date: Date)?
private var lastCheckpoint = Date.distantPast
var wallet: WalletRecord? { var wallet: WalletRecord? {
chain == .xrp ? xrpWallet : xmrWallet chain == .xrp ? xrpWallet : xmrWallet
@@ -212,6 +213,7 @@ final class AppModel: ObservableObject {
// progress current here too. // progress current here too.
if moneroReady, let status = try? await MoneroService.shared.syncStatus() { if moneroReady, let status = try? await MoneroService.shared.syncStatus() {
updateSyncStatus(status) updateSyncStatus(status)
await checkpointWalletIfSyncing()
} }
return return
} }
@@ -222,6 +224,7 @@ final class AppModel: ObservableObject {
// list touch the wallet's data files, so they are refreshed less often. // list touch the wallet's data files, so they are refreshed less often.
updateSyncStatus(try await MoneroService.shared.syncStatus()) updateSyncStatus(try await MoneroService.shared.syncStatus())
refreshTick += 1 refreshTick += 1
await checkpointWalletIfSyncing()
if force || refreshTick == 1 || refreshTick % 4 == 0 { if force || refreshTick == 1 || refreshTick % 4 == 0 {
async let balanceCall = MoneroService.shared.balance() async let balanceCall = MoneroService.shared.balance()
async let txCall = MoneroService.shared.transactions() async let txCall = MoneroService.shared.transactions()
@@ -334,4 +337,20 @@ final class AppModel: ObservableObject {
errorMessage = nil errorMessage = nil
statusMessage = 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()
}
} }
@@ -145,6 +145,16 @@ static void connectDaemon(monero_wallet_full *w) {
delete g_wallet; delete g_wallet;
g_wallet = nullptr; 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"]) { } else if ([op isEqualToString:@"getBalance"]) {
if (g_wallet) { if (g_wallet) {
uint64_t balance = 0, unlocked = 0; uint64_t balance = 0, unlocked = 0;
+26 -19
View File
@@ -17,6 +17,7 @@ struct SeedBox: Identifiable {
struct RootView: View { struct RootView: View {
@EnvironmentObject private var model: AppModel @EnvironmentObject private var model: AppModel
@Environment(\.scenePhase) private var scenePhase
@State private var route: Route = .home @State private var route: Route = .home
@State private var seedBox: SeedBox? @State private var seedBox: SeedBox?
@@ -54,6 +55,11 @@ struct RootView: View {
.onChange(of: model.chain) { _ in .onChange(of: model.chain) { _ in
if route == .dashboard { route = .home } 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) { HStack(spacing: 10) {
SecondaryButton(title: "Refresh") { Task { await model.refresh(force: true) } } SecondaryButton(title: "Refresh") { Task { await model.refresh(force: true) } }
SecondaryButton(title: "Delete") { showDeleteConfirm = true } SecondaryButton(title: "Delete") { showDeleteConfirm = true }
@@ -183,25 +209,6 @@ struct HomeView: View {
.foregroundStyle(Theme.muted) .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)
}
}
}
} }
} }
@@ -158,6 +158,11 @@ final class MoneroService {
_ = try? await invoke(["op": "closeWallet"]) _ = 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 { func seed() async throws -> String {
let response = try await invoke(["op": "getSeed"]) let response = try await invoke(["op": "getSeed"])
return response["mnemonic"] as? String ?? "" return response["mnemonic"] as? String ?? ""