初始提交

This commit is contained in:
2026-06-22 11:28:01 +08:00
commit 0a0d4fbd79
84 changed files with 8899 additions and 0 deletions

View File

@ -0,0 +1,86 @@
//
// AccountSnapshotStore.swift
// suixinkan
//
// Created by Codex on 2026/6/20.
//
import Foundation
import Observation
///
struct AccountSnapshot: Codable, Equatable {
var profile: AccountProfile?
var accountType: String?
var businessUserId: Int?
var currentRoleId: Int?
var scenicScopes: [BusinessScope]
var storeScopes: [BusinessScope]
var currentScenicId: Int?
var currentStoreId: Int?
///
init(
profile: AccountProfile? = nil,
accountType: String? = nil,
businessUserId: Int? = nil,
currentRoleId: Int? = nil,
scenicScopes: [BusinessScope] = [],
storeScopes: [BusinessScope] = [],
currentScenicId: Int? = nil,
currentStoreId: Int? = nil
) {
self.profile = profile
self.accountType = accountType
self.businessUserId = businessUserId
self.currentRoleId = currentRoleId
self.scenicScopes = scenicScopes
self.storeScopes = storeScopes
self.currentScenicId = currentScenicId
self.currentStoreId = currentStoreId
}
}
@Observable
/// 使 UserDefaults
final class AccountSnapshotStore {
@ObservationIgnored private let defaults: UserDefaults
@ObservationIgnored private let key: String
@ObservationIgnored private let encoder: JSONEncoder
@ObservationIgnored private let decoder: JSONDecoder
/// UserDefaults
init(
defaults: UserDefaults = .standard,
key: String = "suixinkan.account.snapshot.v1",
encoder: JSONEncoder = JSONEncoder(),
decoder: JSONDecoder = JSONDecoder()
) {
self.defaults = defaults
self.key = key
self.encoder = encoder
self.decoder = decoder
}
///
func save(_ snapshot: AccountSnapshot) {
guard let data = try? encoder.encode(snapshot) else { return }
defaults.set(data, forKey: key)
}
///
func load() -> AccountSnapshot? {
guard let data = defaults.data(forKey: key) else { return nil }
do {
return try decoder.decode(AccountSnapshot.self, from: data)
} catch {
clear()
return nil
}
}
///
func clear() {
defaults.removeObject(forKey: key)
}
}

View File

@ -0,0 +1,46 @@
//
// AppPreferencesStore.swift
// suixinkan
//
// Created by Codex on 2026/6/20.
//
import Foundation
import Observation
@Observable
/// App
final class AppPreferencesStore {
@ObservationIgnored private let defaults: UserDefaults
@ObservationIgnored private let lastLoginUsernameKey = "suixinkan.preferences.last_login_username"
@ObservationIgnored private let privacyAgreementAcceptedKey = "suixinkan.preferences.privacy_agreement_accepted"
/// UserDefaults
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
///
func saveLastLoginUsername(_ username: String) {
let value = username.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { return }
defaults.set(value, forKey: lastLoginUsernameKey)
}
///
func loadLastLoginUsername() -> String? {
let value = defaults.string(forKey: lastLoginUsernameKey)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return value.isEmpty ? nil : value
}
///
func savePrivacyAgreementAccepted(_ accepted: Bool) {
defaults.set(accepted, forKey: privacyAgreementAcceptedKey)
}
///
func loadPrivacyAgreementAccepted() -> Bool {
defaults.bool(forKey: privacyAgreementAcceptedKey)
}
}

View File

@ -0,0 +1,102 @@
//
// SessionTokenStore.swift
// suixinkan
//
// Created by Codex on 2026/6/20.
//
import Foundation
import Observation
import Security
/// token Keychain
enum SessionTokenStoreError: LocalizedError {
case unexpectedStatus(OSStatus)
var errorDescription: String? {
switch self {
case let .unexpectedStatus(status):
"登录凭证存储失败(\(status)"
}
}
}
@Observable
/// token Keychain API
final class SessionTokenStore {
@ObservationIgnored private let service: String
@ObservationIgnored private let account: String
/// token App Bundle Keychain
init(
service: String = Bundle.main.bundleIdentifier ?? "com.yuanzhixiang.suixinkan",
account: String = "session.token"
) {
self.service = service
self.account = account
}
/// token token
func save(_ token: String) throws {
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedToken.isEmpty else {
try clear()
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)
}
}
/// 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)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty else {
return nil
}
return token
}
/// 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
]
}
}