同步 Android 启动页到 iOS,并在冷启动期间静默恢复登录态。
新增 SplashView、SplashCoordinator 与 Launch Screen 资源,移除冷启动 Lottie;LoginView 加载 App 配置。同时将 token 存储迁移至 UserDefaults,并更新相关测试与文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -6,33 +6,21 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import Security
|
||||
|
||||
/// 登录 token 存储错误实体,表示 Keychain 读写失败的具体状态。
|
||||
enum SessionTokenStoreError: LocalizedError {
|
||||
case unexpectedStatus(OSStatus)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case let .unexpectedStatus(status):
|
||||
"登录凭证存储失败(\(status))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 正式登录 token 存储服务,封装 Keychain 读写并避免业务层接触安全 API。
|
||||
/// 正式登录 token 存储服务,封装 UserDefaults 读写并保持业务层不接触缓存细节。
|
||||
final class SessionTokenStore {
|
||||
private let service: String
|
||||
private let account: String
|
||||
private let defaults: UserDefaults
|
||||
private let key: String
|
||||
|
||||
/// 初始化 token 存储服务,默认按 App Bundle 隔离 Keychain 项。
|
||||
/// 初始化 token 存储服务,默认按 App Bundle 和账号名隔离 UserDefaults 键。
|
||||
init(
|
||||
defaults: UserDefaults = .standard,
|
||||
key: String? = nil,
|
||||
service: String = Bundle.main.bundleIdentifier ?? "com.yuanzhixiang.suixinkan",
|
||||
account: String = "session.token"
|
||||
) {
|
||||
self.service = service
|
||||
self.account = account
|
||||
self.defaults = defaults
|
||||
self.key = key ?? "suixinkan.session.token.\(service).\(account)"
|
||||
}
|
||||
|
||||
/// 保存正式 token,空 token 会被视为清空凭证。
|
||||
@ -43,38 +31,12 @@ final class SessionTokenStore {
|
||||
return
|
||||
}
|
||||
|
||||
let data = Data(trimmedToken.utf8)
|
||||
let query = baseQuery()
|
||||
let updateAttributes: [String: Any] = [kSecValueData as String: data]
|
||||
|
||||
let updateStatus = SecItemUpdate(query as CFDictionary, updateAttributes as CFDictionary)
|
||||
if updateStatus == errSecSuccess {
|
||||
return
|
||||
}
|
||||
guard updateStatus == errSecItemNotFound else {
|
||||
throw SessionTokenStoreError.unexpectedStatus(updateStatus)
|
||||
}
|
||||
|
||||
var addQuery = query
|
||||
updateAttributes.forEach { addQuery[$0.key] = $0.value }
|
||||
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
throw SessionTokenStoreError.unexpectedStatus(addStatus)
|
||||
}
|
||||
defaults.set(trimmedToken, forKey: key)
|
||||
}
|
||||
|
||||
/// 读取本地正式 token,读取失败或无值时返回 nil。
|
||||
func load() -> String? {
|
||||
var query = baseQuery()
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess,
|
||||
let data = result as? Data,
|
||||
let token = String(data: data, encoding: .utf8)?
|
||||
guard let token = defaults.string(forKey: key)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!token.isEmpty else {
|
||||
return nil
|
||||
@ -84,18 +46,6 @@ final class SessionTokenStore {
|
||||
|
||||
/// 清空本地正式 token。
|
||||
func clear() throws {
|
||||
let status = SecItemDelete(baseQuery() as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw SessionTokenStoreError.unexpectedStatus(status)
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造当前 App 使用的 Keychain 查询条件。
|
||||
private func baseQuery() -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account
|
||||
]
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user