同步 Android 启动页到 iOS,并在冷启动期间静默恢复登录态。

新增 SplashView、SplashCoordinator 与 Launch Screen 资源,移除冷启动 Lottie;LoginView 加载 App 配置。同时将 token 存储迁移至 UserDefaults,并更新相关测试与文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 14:59:44 +08:00
parent 08492df6ce
commit 5bdf4a7dbf
29 changed files with 501 additions and 91 deletions

View File

@ -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)
}
}