- 账号与同步:GitHub / Google / Apple 登录后把菜单栏项目与风格等偏好同步到 getopenstats.com,换一台 Mac 登录即恢复(AccountSync 模块、SyncController、SettingsDocument、账号设置页,openstats:// 回调) - 磁盘页成为工具页:已用 / 可清除 / 可用分段容量条、空间分析、文件系统检查、本地快照清理(辅助工具协议 v4)、其他磁盘推出 - 公网 IP:归属地数据源可选 CleanIP.io / ipapi.is / DB-IP / ipinfo.io / 本地 GeoLite2,每台 Mac 自己查、本地缓存 7 天;IPv4 / IPv6 双栈各自查询与切换;弹窗显示 ASN、运营方、原生 / 住宅等徽章与纯净度评分色带;接口区块可重置上传 / 下载统计 - 弹窗:右上角按钮改为该指标自己的图标 + 总设置;主窗口页面标题栏的开关点不动已修复;滚动条改为悬浮式,内容不再偏左;网络弹窗卡片被标题行撑宽的问题已修复 - 设置:⌘ 拖动排序说明;每个菜单栏项目单独选风格并带实时预览;网络与磁盘的进程榜单固定 5 行并平滑排序;CPU 持续高负载通知 - 设计系统:分段条、评分色带、标签徽章、胶囊切换、刷新按钮等组件与徽章配色 token - 更新日志、架构说明、README 与译文同步
75 lines
2.5 KiB
Swift
75 lines
2.5 KiB
Swift
import Foundation
|
||
import Security
|
||
|
||
/// 保存登录令牌的地方:正式环境是钥匙串,测试用内存
|
||
public protocol TokenStore: Sendable {
|
||
func read() -> String?
|
||
func write(_ token: String) throws
|
||
func delete()
|
||
}
|
||
|
||
/// 登录钥匙串里的通用密码项。用文件式钥匙串而不是数据保护钥匙串:后者要求应用有 application-identifier,
|
||
/// 本地 ad-hoc 签名的开发构建没有
|
||
public struct KeychainTokenStore: TokenStore {
|
||
public let service: String
|
||
public let account: String
|
||
|
||
public init(service: String = "com.openstats.sync", account: String = "session") {
|
||
self.service = service
|
||
self.account = account
|
||
}
|
||
|
||
private var query: [String: Any] {
|
||
[kSecClass as String: kSecClassGenericPassword,
|
||
kSecAttrService as String: service,
|
||
kSecAttrAccount as String: account]
|
||
}
|
||
|
||
public func read() -> String? {
|
||
var query = query
|
||
query[kSecReturnData as String] = true
|
||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||
var result: AnyObject?
|
||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
||
let data = result as? Data else { return nil }
|
||
return String(data: data, encoding: .utf8)
|
||
}
|
||
|
||
public func write(_ token: String) throws {
|
||
let data = Data(token.utf8)
|
||
let update = [kSecValueData as String: data]
|
||
var status = SecItemUpdate(query as CFDictionary, update as CFDictionary)
|
||
if status == errSecItemNotFound {
|
||
var item = query
|
||
item[kSecValueData as String] = data
|
||
item[kSecAttrLabel as String] = "OpenStats 同步"
|
||
status = SecItemAdd(item as CFDictionary, nil)
|
||
}
|
||
guard status == errSecSuccess else { throw KeychainError(status: status) }
|
||
}
|
||
|
||
public func delete() {
|
||
SecItemDelete(query as CFDictionary)
|
||
}
|
||
}
|
||
|
||
public struct KeychainError: Error, LocalizedError {
|
||
public let status: OSStatus
|
||
|
||
public var errorDescription: String? {
|
||
(SecCopyErrorMessageString(status, nil) as String?) ?? "Keychain error \(status)"
|
||
}
|
||
}
|
||
|
||
/// 测试与截图用的内存实现
|
||
public final class MemoryTokenStore: TokenStore, @unchecked Sendable {
|
||
private let lock = NSLock()
|
||
private var token: String?
|
||
|
||
public init(token: String? = nil) { self.token = token }
|
||
|
||
public func read() -> String? { lock.withLock { token } }
|
||
public func write(_ token: String) { lock.withLock { self.token = token } }
|
||
public func delete() { lock.withLock { token = nil } }
|
||
}
|