Replace the JavaScript mobile app with a native Swift iOS wallet
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class AppModel: ObservableObject {
|
||||
@Published var chain: Chain = .xrp
|
||||
@Published var xrpWallet: WalletRecord?
|
||||
@Published var xmrWallet: WalletRecord?
|
||||
@Published var balance = Balance(total: "0", unlocked: "", funded: false)
|
||||
@Published var transactions: [TransactionRecord] = []
|
||||
@Published var syncStatus: SyncStatus?
|
||||
@Published var isBusy = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var statusMessage: String?
|
||||
@Published var moneroReady = false
|
||||
@Published var moneroAddress: String?
|
||||
/// Rough time remaining for the Monero sync, derived from observed block rate.
|
||||
@Published var syncEta: String?
|
||||
|
||||
private var moneroOpened = false
|
||||
private var refreshTick = 0
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
private var lastSample: (height: UInt64, date: Date)?
|
||||
|
||||
var wallet: WalletRecord? {
|
||||
chain == .xrp ? xrpWallet : xmrWallet
|
||||
}
|
||||
|
||||
var hasWallet: Bool { wallet != nil }
|
||||
|
||||
/// Address for the chain currently on screen. Kept chain-aware because the
|
||||
/// Monero address stays cached once its wallet is open.
|
||||
var displayAddress: String {
|
||||
chain == .xrp ? (xrpWallet?.address ?? "") : (moneroAddress ?? xmrWallet?.address ?? "")
|
||||
}
|
||||
|
||||
var symbol: String { chain.symbol }
|
||||
|
||||
// MARK: - Bootstrap
|
||||
|
||||
func bootstrap() async {
|
||||
xrpWallet = WalletStore.wallet(.xrp)
|
||||
xmrWallet = WalletStore.wallet(.xmr)
|
||||
if xmrWallet == nil, let orphan = MoneroService.orphanedWalletNames().first {
|
||||
// Wallet file on disk with no stored record (for example after the
|
||||
// preferences were lost): re-attach it so funds are not stranded.
|
||||
await adoptMoneroWallet(named: orphan)
|
||||
}
|
||||
if let xmrWallet {
|
||||
await openMonero(afterRestore: false, wallet: xmrWallet)
|
||||
}
|
||||
if xrpWallet != nil || moneroReady {
|
||||
chain = xrpWallet != nil ? .xrp : .xmr
|
||||
}
|
||||
await refresh(force: true)
|
||||
startAutoRefresh()
|
||||
}
|
||||
|
||||
private func adoptMoneroWallet(named name: String) async {
|
||||
do {
|
||||
let address = try await MoneroService.shared.openWallet(name: name)
|
||||
let record = WalletRecord(id: "xmr_\(Int(Date().timeIntervalSince1970))",
|
||||
name: name,
|
||||
address: address,
|
||||
createdAt: Date())
|
||||
xmrWallet = record
|
||||
WalletStore.saveWallet(record, chain: .xmr)
|
||||
moneroOpened = true
|
||||
moneroReady = true
|
||||
moneroAddress = address
|
||||
statusMessage = "Recovered the existing Monero wallet “\(name)” from this device."
|
||||
} catch {
|
||||
errorMessage = "Found a Monero wallet file but could not open it: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls the wallet while the app is in the foreground. Monero sync progress
|
||||
/// changes constantly, so it is refreshed far more often than XRP.
|
||||
func startAutoRefresh() {
|
||||
guard refreshTask == nil else { return }
|
||||
refreshTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
guard let self else { return }
|
||||
let seconds: UInt64 = self.chain == .xmr ? 5 : 30
|
||||
try? await Task.sleep(nanoseconds: seconds * 1_000_000_000)
|
||||
if Task.isCancelled { return }
|
||||
await self.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func select(_ newChain: Chain) async {
|
||||
chain = newChain
|
||||
errorMessage = nil
|
||||
statusMessage = nil
|
||||
balance = Balance(total: "0", unlocked: "", funded: false)
|
||||
transactions = []
|
||||
syncStatus = nil
|
||||
|
||||
if newChain == .xmr, let xmrWallet, !moneroOpened {
|
||||
await openMonero(afterRestore: false, wallet: xmrWallet)
|
||||
}
|
||||
await refresh(force: true)
|
||||
}
|
||||
|
||||
// MARK: - Monero lifecycle
|
||||
|
||||
private func openMonero(afterRestore: Bool, wallet: WalletRecord) async {
|
||||
isBusy = true
|
||||
defer { isBusy = false }
|
||||
do {
|
||||
let address = try await MoneroService.shared.openWallet(name: wallet.name)
|
||||
moneroOpened = true
|
||||
moneroReady = true
|
||||
moneroAddress = address.isEmpty ? wallet.address : address
|
||||
if let address = moneroAddress, address != wallet.address {
|
||||
var updated = wallet
|
||||
updated.address = address
|
||||
xmrWallet = updated
|
||||
WalletStore.saveWallet(updated, chain: .xmr)
|
||||
}
|
||||
// Always try to catch up, not only right after a restore.
|
||||
_ = try? await MoneroService.shared.startSync()
|
||||
} catch {
|
||||
// Keep the stored wallet so a transient daemon failure is not fatal.
|
||||
moneroOpened = false
|
||||
moneroReady = false
|
||||
moneroAddress = nil
|
||||
errorMessage = "Monero wallet could not be opened: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Create / import
|
||||
|
||||
func createWallet(name: String) async throws -> String {
|
||||
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { throw XRPError.rpc("Enter a wallet name") }
|
||||
isBusy = true
|
||||
defer { isBusy = false }
|
||||
|
||||
if chain == .xrp {
|
||||
let (record, seed) = try XRPWalletService.shared.createWallet(name: trimmed)
|
||||
xrpWallet = record
|
||||
await refresh()
|
||||
return seed
|
||||
}
|
||||
|
||||
let result = try await MoneroService.shared.createWallet(name: trimmed)
|
||||
let record = WalletRecord(id: "xmr_\(Int(Date().timeIntervalSince1970))",
|
||||
name: trimmed,
|
||||
address: result.address,
|
||||
createdAt: Date())
|
||||
xmrWallet = record
|
||||
WalletStore.saveWallet(record, chain: .xmr)
|
||||
if !result.mnemonic.isEmpty {
|
||||
WalletStore.saveSecret(result.mnemonic, chain: .xmr)
|
||||
}
|
||||
moneroOpened = true
|
||||
moneroReady = true
|
||||
moneroAddress = result.address
|
||||
_ = try? await MoneroService.shared.startSync()
|
||||
await refresh(force: true)
|
||||
return result.mnemonic
|
||||
}
|
||||
|
||||
func importWallet(name: String, secret: String, restoreHeight: UInt64?) async throws {
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedSecret = secret.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedName.isEmpty else { throw XRPError.rpc("Enter a wallet name") }
|
||||
guard !trimmedSecret.isEmpty else { throw XRPError.rpc("Enter your seed or secret") }
|
||||
isBusy = true
|
||||
defer { isBusy = false }
|
||||
|
||||
if chain == .xrp {
|
||||
xrpWallet = try XRPWalletService.shared.importWallet(name: trimmedName, secret: trimmedSecret)
|
||||
await refresh(force: true)
|
||||
return
|
||||
}
|
||||
|
||||
let address = try await MoneroService.shared.restoreWallet(name: trimmedName,
|
||||
seed: trimmedSecret,
|
||||
restoreHeight: restoreHeight ?? 0)
|
||||
let record = WalletRecord(id: "xmr_\(Int(Date().timeIntervalSince1970))",
|
||||
name: trimmedName,
|
||||
address: address,
|
||||
createdAt: Date())
|
||||
xmrWallet = record
|
||||
WalletStore.saveWallet(record, chain: .xmr)
|
||||
WalletStore.saveSecret(trimmedSecret, chain: .xmr)
|
||||
moneroOpened = true
|
||||
moneroReady = true
|
||||
moneroAddress = address
|
||||
_ = try? await MoneroService.shared.startSync()
|
||||
await refresh(force: true)
|
||||
}
|
||||
|
||||
// MARK: - Reads
|
||||
|
||||
func refresh(force: Bool = false) async {
|
||||
guard hasWallet else { return }
|
||||
errorMessage = nil
|
||||
if chain == .xrp, let wallet = xrpWallet {
|
||||
do {
|
||||
async let balanceCall = XRPWalletService.shared.balance(address: wallet.address)
|
||||
async let txCall = XRPWalletService.shared.transactions(address: wallet.address)
|
||||
balance = try await balanceCall
|
||||
transactions = try await txCall
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
// The Monero wallet keeps scanning while XRP is on screen, so keep its
|
||||
// progress current here too.
|
||||
if moneroReady, let status = try? await MoneroService.shared.syncStatus() {
|
||||
updateSyncStatus(status)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard moneroReady else { return }
|
||||
do {
|
||||
// Sync progress is cheap and polled every tick; the balance and transfer
|
||||
// list touch the wallet's data files, so they are refreshed less often.
|
||||
updateSyncStatus(try await MoneroService.shared.syncStatus())
|
||||
refreshTick += 1
|
||||
if force || refreshTick == 1 || refreshTick % 4 == 0 {
|
||||
async let balanceCall = MoneroService.shared.balance()
|
||||
async let txCall = MoneroService.shared.transactions()
|
||||
balance = try await balanceCall
|
||||
transactions = try await txCall
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks how fast blocks are being scanned so the UI can show an estimate.
|
||||
private func updateSyncStatus(_ status: SyncStatus) {
|
||||
syncStatus = status
|
||||
let now = Date()
|
||||
defer { lastSample = (status.height, now) }
|
||||
|
||||
guard status.syncing,
|
||||
let sample = lastSample,
|
||||
status.height > sample.height,
|
||||
status.daemonHeight > status.height else {
|
||||
if !status.syncing { syncEta = nil }
|
||||
return
|
||||
}
|
||||
|
||||
let elapsed = now.timeIntervalSince(sample.date)
|
||||
guard elapsed > 1 else { return }
|
||||
let blocksPerSecond = Double(status.height - sample.height) / elapsed
|
||||
guard blocksPerSecond > 0 else { return }
|
||||
|
||||
let remaining = Double(status.daemonHeight - status.height) / blocksPerSecond
|
||||
syncEta = Self.formatDuration(remaining)
|
||||
}
|
||||
|
||||
private static func formatDuration(_ seconds: Double) -> String {
|
||||
guard seconds.isFinite, seconds > 0 else { return "unknown" }
|
||||
let total = Int(seconds.rounded())
|
||||
let hours = total / 3600
|
||||
let minutes = (total % 3600) / 60
|
||||
if hours >= 24 { return "\(hours / 24)d \(hours % 24)h" }
|
||||
if hours > 0 { return "\(hours)h \(minutes)m" }
|
||||
if minutes > 0 { return "\(minutes)m" }
|
||||
return "under a minute"
|
||||
}
|
||||
|
||||
func startMoneroSync() async {
|
||||
guard moneroReady else {
|
||||
if let wallet = xmrWallet { await openMonero(afterRestore: true, wallet: wallet) }
|
||||
return
|
||||
}
|
||||
_ = try? await MoneroService.shared.startSync()
|
||||
await refresh(force: true)
|
||||
}
|
||||
|
||||
func revealSeed() async throws -> String {
|
||||
if chain == .xrp {
|
||||
guard let seed = WalletStore.secret(.xrp) else {
|
||||
throw XRPError.rpc("No stored secret for this wallet")
|
||||
}
|
||||
return seed
|
||||
}
|
||||
if let stored = WalletStore.secret(.xmr), !stored.isEmpty { return stored }
|
||||
return try await MoneroService.shared.seed()
|
||||
}
|
||||
|
||||
// MARK: - Sends
|
||||
|
||||
func send(to: String, amount: String, tag: String, priority: TransactionPriority) async throws {
|
||||
let destinationTag = UInt32(tag.trimmingCharacters(in: .whitespaces))
|
||||
if chain == .xrp {
|
||||
guard let seed = WalletStore.secret(.xrp) else {
|
||||
throw XRPError.rpc("Wallet secret is unavailable")
|
||||
}
|
||||
statusMessage = nil
|
||||
_ = try await XRPWalletService.shared.send(secret: seed,
|
||||
to: to,
|
||||
amountXRP: amount,
|
||||
destinationTag: destinationTag)
|
||||
statusMessage = "Payment sent"
|
||||
await refresh(force: true)
|
||||
return
|
||||
}
|
||||
_ = try await MoneroService.shared.send(address: to, amountXMR: amount, priority: priority)
|
||||
statusMessage = "Payment sent"
|
||||
await refresh(force: true)
|
||||
}
|
||||
|
||||
// MARK: - Delete
|
||||
|
||||
func deleteWallet() async {
|
||||
if chain == .xrp {
|
||||
WalletStore.removeWallet(.xrp)
|
||||
xrpWallet = nil
|
||||
} else {
|
||||
if let wallet = xmrWallet { await MoneroService.shared.removeWallet(name: wallet.name) }
|
||||
WalletStore.removeWallet(.xmr)
|
||||
xmrWallet = nil
|
||||
moneroOpened = false
|
||||
moneroReady = false
|
||||
moneroAddress = nil
|
||||
syncStatus = nil
|
||||
}
|
||||
balance = Balance(total: "0", unlocked: "", funded: false)
|
||||
transactions = []
|
||||
errorMessage = nil
|
||||
statusMessage = nil
|
||||
}
|
||||
|
||||
func clearMessages() {
|
||||
errorMessage = nil
|
||||
statusMessage = nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user