Files
gentpan c9d722f20d 账号同步、磁盘工具、IP 归属地数据源与纯净度、弹窗与设置调整
- 账号与同步: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 与译文同步
2026-09-15 03:37:24 +05:00

222 lines
8.4 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: "")
}
}