1eb21c71ce
- Swift 6, SwiftUI, MVVM + async/await architecture - iOS 17.0 minimum deployment target - Two targets: Mayday app + MaydayLiveActivity widget extension - Models: UserResponse, TokenPair, AppNotification, SessionResponse, AlertAttributes - Services: HTTPClient (actor), AuthService, KeychainService, NotificationsAPIService, PushNotificationService - ViewModels: AuthViewModel, NotificationsViewModel, SettingsViewModel - Views: Login/Register/VerifyEmail, NotificationsList/Detail, Settings/ChangePassword/Sessions - APNs push notifications with UIApplicationDelegate - ActivityKit Live Activities for Dynamic Island + Lock Screen - Keychain (Security framework) token storage - 30-second polling with pagination for notifications - Xcode project file (project.pbxproj) with correct build phases for both targets Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
75 lines
2.3 KiB
Swift
75 lines
2.3 KiB
Swift
import Foundation
|
|
import Security
|
|
|
|
final class KeychainService: Sendable {
|
|
static let shared = KeychainService()
|
|
|
|
private let accessTokenKey = "mayday.access_token"
|
|
private let refreshTokenKey = "mayday.refresh_token"
|
|
private let expiresAtKey = "mayday.expires_at"
|
|
|
|
private init() {}
|
|
|
|
func saveTokens(_ tokens: TokenPair) throws {
|
|
try save(tokens.accessToken, forKey: accessTokenKey)
|
|
try save(tokens.refreshToken, forKey: refreshTokenKey)
|
|
let expiresAtString = ISO8601DateFormatter().string(from: tokens.expiresAt)
|
|
try save(expiresAtString, forKey: expiresAtKey)
|
|
}
|
|
|
|
func loadAccessToken() -> String? {
|
|
load(forKey: accessTokenKey)
|
|
}
|
|
|
|
func loadRefreshToken() -> String? {
|
|
load(forKey: refreshTokenKey)
|
|
}
|
|
|
|
func clearTokens() {
|
|
delete(forKey: accessTokenKey)
|
|
delete(forKey: refreshTokenKey)
|
|
delete(forKey: expiresAtKey)
|
|
}
|
|
|
|
private func save(_ value: String, forKey key: String) throws {
|
|
let data = Data(value.utf8)
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrAccount as String: key,
|
|
kSecValueData as String: data
|
|
]
|
|
SecItemDelete(query as CFDictionary)
|
|
let status = SecItemAdd(query as CFDictionary, nil)
|
|
guard status == errSecSuccess else {
|
|
throw KeychainError.saveFailed(status)
|
|
}
|
|
}
|
|
|
|
private func load(forKey key: String) -> String? {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrAccount as String: key,
|
|
kSecReturnData as String: true,
|
|
kSecMatchLimit as String: kSecMatchLimitOne
|
|
]
|
|
var result: AnyObject?
|
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
guard status == errSecSuccess,
|
|
let data = result as? Data,
|
|
let string = String(data: data, encoding: .utf8) else { return nil }
|
|
return string
|
|
}
|
|
|
|
private func delete(forKey key: String) {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrAccount as String: key
|
|
]
|
|
SecItemDelete(query as CFDictionary)
|
|
}
|
|
}
|
|
|
|
enum KeychainError: Error {
|
|
case saveFailed(OSStatus)
|
|
}
|