Require Face ID to open the app and to send

This commit is contained in:
2026-09-27 23:35:26 +08:00
parent b989b0f791
commit 78e1cefad4
8 changed files with 172 additions and 7 deletions
+33
View File
@@ -15,6 +15,18 @@ final class AppModel: ObservableObject {
@Published var moneroReady = false
@Published var moneroAddress: String?
@Published var moneroAccounts: [MoneroAccount] = []
/// Whether the app is behind the biometric lock screen right now.
@Published var locked = false
/// Face ID/Touch ID lock. On by default when the device has biometrics;
/// persisted across launches.
var faceIDEnabled: Bool {
get { UserDefaults.standard.object(forKey: "koins.faceID") as? Bool ?? true }
set {
UserDefaults.standard.set(newValue, forKey: "koins.faceID")
objectWillChange.send()
}
}
/// Rough time remaining for the Monero sync, derived from observed block rate.
@Published var syncEta: String?
@@ -41,6 +53,22 @@ final class AppModel: ObservableObject {
var symbol: String { chain.symbol }
// MARK: - Biometric lock
func authenticate() async {
if await Biometrics.authenticate(reason: "Unlock Koins") {
locked = false
}
}
/// Requires biometrics for sensitive actions when the lock is enabled; a
/// device without biometrics is not asked (the wallet is still usable).
func requireAuth(reason: String) async throws {
guard faceIDEnabled, Biometrics.isAvailable else { return }
let ok = await Biometrics.authenticate(reason: reason)
if !ok { throw XRPError.rpc("Authentication failed") }
}
// MARK: - Cache
/// Restores the last-known state for the chain on screen instantly; the
@@ -78,6 +106,9 @@ final class AppModel: ObservableObject {
if xrpWallet != nil || moneroReady {
chain = xrpWallet != nil ? .xrp : .xmr
}
if xrpWallet != nil || xmrWallet != nil, faceIDEnabled, Biometrics.isAvailable {
locked = true
}
showCached(chain)
await refresh(force: true)
startAutoRefresh()
@@ -334,6 +365,7 @@ final class AppModel: ObservableObject {
}
func revealSeed() async throws -> String {
try await requireAuth(reason: "Reveal your recovery phrase")
if chain == .xrp {
guard let seed = WalletStore.secret(.xrp) else {
throw XRPError.rpc("No stored secret for this wallet")
@@ -347,6 +379,7 @@ final class AppModel: ObservableObject {
// MARK: - Sends
func send(to: String, amount: String, tag: String, priority: TransactionPriority) async throws {
try await requireAuth(reason: "Authorize this payment")
let destinationTag = UInt32(tag.trimmingCharacters(in: .whitespaces))
if chain == .xrp {
guard let seed = WalletStore.secret(.xrp) else {