feat: add iOS support and harden receipt scanning

This commit is contained in:
Timo
2026-08-20 23:48:26 +02:00
parent f5e06c32b0
commit cf1b799e1b
107 changed files with 11755 additions and 122 deletions

View File

@@ -0,0 +1,73 @@
import Foundation
import Security
/// Persists the session token issued by `POST /api/auth/login` /
/// `/api/auth/signup` in the iOS Keychain never UserDefaults, which is
/// unencrypted on disk. This is the native counterpart to the web app's
/// httpOnly session cookie (see `src/lib/auth/session.ts`); the backend has
/// no idea which storage a given Bearer token came from.
final class KeychainTokenStore {
static let shared = KeychainTokenStore()
private let service = "app.scan-receipts.ios.session"
private let account = "session-token"
private init() {}
var token: String? {
get { read() }
set {
if let newValue {
save(newValue)
} else {
delete()
}
}
}
private func query(extra: [String: Any] = [:]) -> [String: Any] {
var q: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
extra.forEach { q[$0] = $1 }
return q
}
private func read() -> String? {
var query = self.query()
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
private func save(_ token: String) {
let data = Data(token.utf8)
// Overwrite-or-insert: a plain SecItemAdd fails with errSecDuplicateItem
// on every login after the first, since the (service, account) pair is
// stable by design.
let attributes: [String: Any] = [
kSecValueData as String: data,
// Available as soon as the device is unlocked once after boot
// matches what a background-launched app (e.g. from a push
// notification) needs, without requiring the passcode be entered
// in *this* unlock cycle the way `WhenUnlocked` would.
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
]
let updateStatus = SecItemUpdate(query() as CFDictionary, attributes as CFDictionary)
if updateStatus == errSecItemNotFound {
var insert = query()
attributes.forEach { insert[$0] = $1 }
SecItemAdd(insert as CFDictionary, nil)
}
}
private func delete() {
SecItemDelete(query() as CFDictionary)
}
}