- 账号与同步: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 与译文同步
222 lines
8.4 KiB
Swift
222 lines
8.4 KiB
Swift
import CryptoKit
|
||
import Foundation
|
||
import Localization
|
||
|
||
/// 支持的登录方式;服务器 /auth/providers 告诉应用哪些已经配置好
|
||
public enum SyncProvider: String, CaseIterable, Codable, Identifiable, Sendable {
|
||
case github, google, apple
|
||
|
||
public var id: String { rawValue }
|
||
|
||
public var title: String {
|
||
switch self {
|
||
case .github: "GitHub"
|
||
case .google: "Google"
|
||
case .apple: "Apple"
|
||
}
|
||
}
|
||
}
|
||
|
||
public struct SyncUser: Codable, Equatable, Sendable {
|
||
public var id: String
|
||
public var email: String?
|
||
public var name: String?
|
||
public var avatar: URL?
|
||
public var providers: [String]
|
||
|
||
public init(id: String, email: String? = nil, name: String? = nil, avatar: URL? = nil, providers: [String] = []) {
|
||
self.id = id
|
||
self.email = email
|
||
self.name = name
|
||
self.avatar = avatar
|
||
self.providers = providers
|
||
}
|
||
|
||
/// 界面上显示的名字:姓名,其次邮箱
|
||
public var displayName: String { name?.isEmpty == false ? name! : (email ?? id) }
|
||
}
|
||
|
||
/// 云端保存的一份设置:整份覆盖,版本号递增
|
||
public struct RemoteSettings<Document: Codable & Sendable>: Codable, Sendable {
|
||
public var version: Int
|
||
public var updatedAt: Date
|
||
public var device: String?
|
||
public var document: Document
|
||
}
|
||
|
||
public struct SettingsReceipt: Codable, Equatable, Sendable {
|
||
public var version: Int
|
||
public var updatedAt: Date
|
||
}
|
||
|
||
public struct SyncSession: Codable, Equatable, Sendable {
|
||
public var token: String
|
||
public var user: SyncUser
|
||
}
|
||
|
||
public enum SyncError: Error, Equatable, LocalizedError {
|
||
/// 令牌已作废(退出登录、删除账号或服务器清理)
|
||
case unauthorized
|
||
case server(Int, String)
|
||
case transport(String)
|
||
case invalidCallback
|
||
case cancelled
|
||
|
||
public var errorDescription: String? {
|
||
switch self {
|
||
case .unauthorized: tr("登录已失效,请重新登录")
|
||
case .server(let status, let message): message.isEmpty ? tr("服务器返回 \(status)") : message
|
||
case .transport(let message): message
|
||
case .invalidCallback: tr("登录回调不正确")
|
||
case .cancelled: tr("已取消登录")
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 同步服务的 HTTP 客户端:只做请求与解码,不保存状态
|
||
public struct SyncClient: Sendable {
|
||
public static let defaultBaseURL = URL(string: "https://getopenstats.com/api/v1")!
|
||
/// 应用在 Info.plist 里注册的 URL scheme,登录完成后浏览器跳回这里
|
||
public static let callbackScheme = "openstats"
|
||
|
||
public let baseURL: URL
|
||
private let session: URLSession
|
||
|
||
public init(baseURL: URL = SyncClient.defaultBaseURL, session: URLSession = .shared) {
|
||
self.baseURL = baseURL
|
||
self.session = session
|
||
}
|
||
|
||
// MARK: 登录
|
||
|
||
/// 浏览器打开的登录入口;challenge 是 PKCE verifier 的 S256 摘要
|
||
public func loginURL(provider: SyncProvider, challenge: String) -> URL {
|
||
var components = URLComponents(url: baseURL.appending(path: "auth/\(provider.rawValue)/start"), resolvingAgainstBaseURL: false)!
|
||
components.queryItems = [URLQueryItem(name: "challenge", value: challenge)]
|
||
return components.url!
|
||
}
|
||
|
||
/// 解析 openstats://auth/callback?code=… 或 ?error=…
|
||
public static func parseCallback(_ url: URL) -> Result<String, SyncError> {
|
||
guard url.scheme == callbackScheme,
|
||
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems else {
|
||
return .failure(.invalidCallback)
|
||
}
|
||
if let code = items.first(where: { $0.name == "code" })?.value, !code.isEmpty {
|
||
return .success(code)
|
||
}
|
||
let error = items.first(where: { $0.name == "error" })?.value ?? ""
|
||
return .failure(error == "cancelled" ? .cancelled : .server(0, error))
|
||
}
|
||
|
||
public func providers() async throws -> [SyncProvider] {
|
||
struct Response: Decodable { var providers: [String] }
|
||
let response: Response = try await send(request("GET", "auth/providers"))
|
||
return response.providers.compactMap(SyncProvider.init(rawValue:))
|
||
}
|
||
|
||
public func exchange(code: String, verifier: String, device: String) async throws -> SyncSession {
|
||
struct Body: Encodable { var code: String; var verifier: String; var device: String }
|
||
return try await send(request("POST", "auth/exchange", body: Body(code: code, verifier: verifier, device: device)))
|
||
}
|
||
|
||
public func me(token: String) async throws -> SyncUser {
|
||
try await send(request("GET", "me", token: token))
|
||
}
|
||
|
||
public func logout(token: String) async throws {
|
||
_ = try await sendRaw(request("POST", "auth/logout", token: token))
|
||
}
|
||
|
||
public func deleteAccount(token: String) async throws {
|
||
_ = try await sendRaw(request("DELETE", "account", token: token))
|
||
}
|
||
|
||
// MARK: 设置
|
||
|
||
/// 没有保存过时返回 nil
|
||
public func fetchSettings<Document: Codable & Sendable>(token: String, as type: Document.Type) async throws -> RemoteSettings<Document>? {
|
||
let (data, status) = try await sendRaw(request("GET", "settings", token: token), allowing: [404])
|
||
guard status != 404 else { return nil }
|
||
return try Self.decoder.decode(RemoteSettings<Document>.self, from: data)
|
||
}
|
||
|
||
public func putSettings<Document: Codable & Sendable>(token: String, document: Document, device: String) async throws -> SettingsReceipt {
|
||
try await send(request("PUT", "settings", token: token, body: PutSettingsBody(document: document, device: device)))
|
||
}
|
||
|
||
private struct PutSettingsBody<Document: Encodable>: Encodable {
|
||
var document: Document
|
||
var device: String
|
||
}
|
||
|
||
// MARK: 请求
|
||
|
||
private static let decoder: JSONDecoder = {
|
||
let decoder = JSONDecoder()
|
||
decoder.dateDecodingStrategy = .iso8601
|
||
return decoder
|
||
}()
|
||
|
||
private func request(_ method: String, _ path: String, token: String? = nil, body: (some Encodable)? = nil as Data?) -> URLRequest {
|
||
var request = URLRequest(url: baseURL.appending(path: path), cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 20)
|
||
request.httpMethod = method
|
||
request.setValue("OpenStats", forHTTPHeaderField: "User-Agent")
|
||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||
if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
|
||
if let body {
|
||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||
request.httpBody = try? JSONEncoder().encode(body)
|
||
}
|
||
return request
|
||
}
|
||
|
||
private func send<Response: Decodable>(_ request: URLRequest) async throws -> Response {
|
||
let (data, _) = try await sendRaw(request)
|
||
do {
|
||
return try Self.decoder.decode(Response.self, from: data)
|
||
} catch {
|
||
throw SyncError.server(200, tr("服务器返回的数据格式不正确"))
|
||
}
|
||
}
|
||
|
||
/// 返回响应体与状态码;2xx 与 allowing 之外的状态码转成 SyncError
|
||
private func sendRaw(_ request: URLRequest, allowing: Set<Int> = []) async throws -> (Data, Int) {
|
||
let data: Data
|
||
let response: URLResponse
|
||
do {
|
||
(data, response) = try await session.data(for: request)
|
||
} catch {
|
||
throw SyncError.transport(error.localizedDescription)
|
||
}
|
||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||
if (200..<300).contains(status) || allowing.contains(status) { return (data, status) }
|
||
if status == 401 { throw SyncError.unauthorized }
|
||
struct Failure: Decodable { var error: String }
|
||
let message = (try? JSONDecoder().decode(Failure.self, from: data))?.error ?? ""
|
||
throw SyncError.server(status, message)
|
||
}
|
||
}
|
||
|
||
/// PKCE(RFC 7636):verifier 留在应用里,只把 S256 摘要交给服务器
|
||
public enum PKCE {
|
||
public static func verifier() -> String {
|
||
var bytes = [UInt8](repeating: 0, count: 32)
|
||
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||
return Data(bytes).base64URLEncoded
|
||
}
|
||
|
||
public static func challenge(for verifier: String) -> String {
|
||
Data(SHA256.hash(data: Data(verifier.utf8))).base64URLEncoded
|
||
}
|
||
}
|
||
|
||
extension Data {
|
||
var base64URLEncoded: String {
|
||
base64EncodedString()
|
||
.replacingOccurrences(of: "+", with: "-")
|
||
.replacingOccurrences(of: "/", with: "_")
|
||
.replacingOccurrences(of: "=", with: "")
|
||
}
|
||
}
|