Files
OpenStats/Packages/OpenStatsKit/Sources/OpenStatsUI/State/SyncController.swift
T
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

398 lines
14 KiB
Swift

import AccountSync
import AppKit
import AuthenticationServices
import Foundation
import Localization
import Observation
/// 账号与设置同步:用系统的登录窗口走 GitHub / Google / Apple 授权,令牌存钥匙串;
/// 设置一变就在 2 秒后整份推送,启动、唤醒与每 15 分钟拉一次。云端以最后写入为准,
/// 只有第一次登录且两边都有内容时让用户选一次
@MainActor
@Observable
public final class SyncController {
public enum Phase: Equatable {
case idle
case signingIn
case syncing
case failed(String)
}
/// 首次登录时本机与云端设置不同,等用户选择
public struct Conflict: Equatable {
public let remoteUpdatedAt: Date
public let remoteDevice: String?
}
public private(set) var phase: Phase = .idle
public private(set) var user: SyncUser? {
didSet { defaults.set(user.flatMap { try? JSONEncoder().encode($0) }, forKey: Keys.user) }
}
public private(set) var lastSyncedAt: Date? {
didSet { defaults.set(lastSyncedAt, forKey: Keys.lastSyncedAt) }
}
public private(set) var conflict: Conflict?
/// 服务器上配置好的登录方式;取不到时全部显示
public private(set) var providers: [SyncProvider] = SyncProvider.allCases
public var isSignedIn: Bool { token != nil && user != nil }
/// 登录窗口挂在哪个窗口上,由 AppController 提供主窗口
@ObservationIgnored var presentationAnchor: @MainActor () -> NSWindow? = { nil }
@ObservationIgnored private let settings: AppSettings
@ObservationIgnored private let defaults: UserDefaults
@ObservationIgnored private let client: SyncClient
@ObservationIgnored private let tokens: TokenStore
@ObservationIgnored private var token: String?
@ObservationIgnored private var lastSyncedVersion: Int {
didSet { defaults.set(lastSyncedVersion, forKey: Keys.lastSyncedVersion) }
}
/// 上次推送或套用过的文档:本机再次读到同样的内容就不用推,也用来识别套用云端设置引起的回声
@ObservationIgnored private var lastKnownDocument: SettingsDocument? {
didSet { defaults.set(lastKnownDocument.flatMap { try? JSONEncoder().encode($0) }, forKey: Keys.lastKnownDocument) }
}
@ObservationIgnored private var pendingRemote: RemoteSettings<SettingsDocument>?
@ObservationIgnored private var pushTask: Task<Void, Never>?
@ObservationIgnored private var pullTask: Task<Void, Never>?
@ObservationIgnored private var authSession: ASWebAuthenticationSession?
@ObservationIgnored private var anchorProvider: AnchorProvider?
@ObservationIgnored private var timer: Timer?
@ObservationIgnored private var observing = false
static let pushDelay: Duration = .seconds(2)
static let pullInterval: TimeInterval = 15 * 60
init(settings: AppSettings, defaults: UserDefaults = .standard, client: SyncClient = SyncClient(), tokens: TokenStore = KeychainTokenStore()) {
self.settings = settings
self.defaults = defaults
self.client = client
self.tokens = tokens
user = defaults.data(forKey: Keys.user).flatMap { try? JSONDecoder().decode(SyncUser.self, from: $0) }
lastSyncedAt = defaults.object(forKey: Keys.lastSyncedAt) as? Date
lastSyncedVersion = defaults.integer(forKey: Keys.lastSyncedVersion)
lastKnownDocument = defaults.data(forKey: Keys.lastKnownDocument).flatMap { try? JSONDecoder().decode(SettingsDocument.self, from: $0) }
}
/// 启动时调用:已登录就开始观察设置并补一次同步
public func start() {
token = tokens.read()
guard let token else {
if user != nil { clearLocalSession() }
return
}
if user == nil {
Task { [weak self] in
guard let self, let fetched = try? await self.client.me(token: token) else { return }
self.user = fetched
self.start()
}
return
}
beginObserving()
if lastKnownDocument != settings.exportDocument() {
schedulePush()
} else {
pullSoon()
}
}
/// 账号页打开时:未登录就刷新可用的登录方式,已登录就拉一次
func pageOpened() {
if isSignedIn {
pullSoon()
} else {
Task { [weak self] in
guard let self, let fetched = try? await self.client.providers(), !fetched.isEmpty else { return }
self.providers = fetched
}
}
}
// MARK: 登录
public func signIn(with provider: SyncProvider) {
guard phase != .signingIn else { return }
let verifier = PKCE.verifier()
let url = client.loginURL(provider: provider, challenge: PKCE.challenge(for: verifier))
let anchor = AnchorProvider(anchor: presentationAnchor)
let session = ASWebAuthenticationSession(url: url, callbackURLScheme: SyncClient.callbackScheme) { [weak self] callback, error in
Task { @MainActor in self?.finishSignIn(callback: callback, error: error, verifier: verifier) }
}
session.presentationContextProvider = anchor
// 保留浏览器里已登录的第三方账号,用户不必再输一次密码
session.prefersEphemeralWebBrowserSession = false
anchorProvider = anchor
authSession = session
phase = .signingIn
Log.sync.notice("开始登录:\(provider.rawValue, privacy: .public)")
if !session.start() {
phase = .failed(tr("无法打开登录窗口"))
authSession = nil
}
}
public func cancelSignIn() {
authSession?.cancel()
authSession = nil
if phase == .signingIn { phase = .idle }
}
private func finishSignIn(callback: URL?, error: Error?, verifier: String) {
authSession = nil
anchorProvider = nil
if let error {
let cancelled = (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin
phase = cancelled ? .idle : .failed(error.localizedDescription)
return
}
guard let callback else {
phase = .failed(SyncError.invalidCallback.localizedDescription)
return
}
switch SyncClient.parseCallback(callback) {
case .failure(.cancelled):
phase = .idle
case .failure(let error):
phase = .failed(error.localizedDescription)
case .success(let code):
Task { await exchange(code: code, verifier: verifier) }
}
}
private func exchange(code: String, verifier: String) async {
do {
let session = try await client.exchange(code: code, verifier: verifier, device: Self.deviceName)
try tokens.write(session.token)
token = session.token
user = session.user
lastSyncedVersion = 0
lastKnownDocument = nil
conflict = nil
phase = .idle
Log.sync.notice("登录成功:\(session.user.providers.joined(separator: ","), privacy: .public)")
beginObserving()
await reconcileAfterSignIn()
} catch {
Log.sync.error("登录失败:\(error.localizedDescription, privacy: .public)")
phase = .failed(error.localizedDescription)
}
}
/// 第一次登录:云端没有就上传;本机还是出厂设置就直接用云端;两边都有且不同就问用户
private func reconcileAfterSignIn() async {
guard let token else { return }
phase = .syncing
do {
let local = settings.exportDocument()
guard let remote = try await client.fetchSettings(token: token, as: SettingsDocument.self) else {
phase = .idle
await push()
return
}
if remote.document == local || local == AppSettings.defaultDocument() {
applyRemote(remote)
} else {
pendingRemote = remote
conflict = Conflict(remoteUpdatedAt: remote.updatedAt, remoteDevice: remote.device)
}
phase = .idle
} catch {
handle(error)
}
}
public func resolveConflict(useRemote: Bool) {
guard conflict != nil else { return }
conflict = nil
if useRemote, let remote = pendingRemote {
applyRemote(remote)
} else {
Task { await push() }
}
pendingRemote = nil
}
// MARK: 退出
public func signOut() {
if let token {
Task { [client] in try? await client.logout(token: token) }
}
clearLocalSession()
Log.sync.notice("已退出登录")
}
public func deleteAccount() {
guard let token else { return }
phase = .syncing
Task {
do {
try await client.deleteAccount(token: token)
clearLocalSession()
Log.sync.notice("已删除云端账号")
} catch {
handle(error)
}
}
}
private func clearLocalSession() {
pushTask?.cancel()
pullTask?.cancel()
pushTask = nil
pullTask = nil
timer?.invalidate()
timer = nil
tokens.delete()
token = nil
user = nil
conflict = nil
pendingRemote = nil
lastSyncedAt = nil
lastSyncedVersion = 0
lastKnownDocument = nil
observing = false
phase = .idle
}
// MARK: 同步
/// 用户点“立即同步”:有本机改动就推,否则拉
public func syncNow() {
guard isSignedIn, conflict == nil else { return }
if lastKnownDocument != settings.exportDocument() {
pushTask?.cancel()
pushTask = nil
Task { await push() }
} else {
pullTask?.cancel()
pullTask = nil
Task { await pull() }
}
}
/// 唤醒或打开账号页时稍后拉一次,合并短时间内的多次触发
func pullSoon() {
guard isSignedIn, pullTask == nil else { return }
pullTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(1))
guard let self, !Task.isCancelled else { return }
self.pullTask = nil
await self.pull()
}
}
private func beginObserving() {
guard !observing else { return }
observing = true
observeSettings()
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: Self.pullInterval, repeats: true) { [weak self] _ in
MainActor.assumeIsolated { self?.pullSoon() }
}
timer?.tolerance = 60
}
private func observeSettings() {
guard observing else { return }
withObservationTracking {
_ = settings.exportDocument()
} onChange: { [weak self] in
Task { @MainActor in
guard let self, self.observing else { return }
self.schedulePush()
self.observeSettings()
}
}
}
private func schedulePush() {
pushTask?.cancel()
pushTask = Task { [weak self] in
try? await Task.sleep(for: Self.pushDelay)
guard let self, !Task.isCancelled else { return }
self.pushTask = nil
await self.push()
}
}
private func push() async {
guard let token, conflict == nil else { return }
let document = settings.exportDocument()
guard document != lastKnownDocument else { return }
phase = .syncing
do {
let receipt = try await client.putSettings(token: token, document: document, device: Self.deviceName)
lastKnownDocument = document
lastSyncedVersion = receipt.version
lastSyncedAt = receipt.updatedAt
phase = .idle
Log.sync.info("已上传设置,版本 \(receipt.version)")
} catch {
handle(error)
}
}
private func pull() async {
guard let token, conflict == nil, pushTask == nil else { return }
do {
guard let remote = try await client.fetchSettings(token: token, as: SettingsDocument.self) else { return }
if remote.version != lastSyncedVersion {
applyRemote(remote)
Log.sync.info("已套用云端设置,版本 \(remote.version)")
}
if case .failed = phase { phase = .idle }
} catch {
handle(error)
}
}
private func applyRemote(_ remote: RemoteSettings<SettingsDocument>) {
// 先记下再套用:套用触发的观察回调会发现内容一致,不会再推回去
lastKnownDocument = remote.document
lastSyncedVersion = remote.version
lastSyncedAt = remote.updatedAt
settings.apply(remote.document)
// 文档里不认识的值被跳过后本机内容可能与云端不完全一致,以本机实际内容为准避免反复推送
lastKnownDocument = settings.exportDocument()
}
private func handle(_ error: Error) {
if let syncError = error as? SyncError, syncError == .unauthorized {
Log.sync.error("令牌已失效")
clearLocalSession()
phase = .failed(syncError.localizedDescription)
return
}
Log.sync.error("同步失败:\(error.localizedDescription, privacy: .public)")
phase = .failed(error.localizedDescription)
}
static var deviceName: String {
Host.current().localizedName ?? "Mac"
}
private enum Keys {
static let user = "syncUser"
static let lastSyncedAt = "syncLastSyncedAt"
static let lastSyncedVersion = "syncLastSyncedVersion"
static let lastKnownDocument = "syncLastKnownDocument"
}
}
/// 登录窗口需要一个挂靠的窗口:优先主窗口,其次当前主窗口
private final class AnchorProvider: NSObject, ASWebAuthenticationPresentationContextProviding {
private let anchor: @MainActor () -> NSWindow?
init(anchor: @escaping @MainActor () -> NSWindow?) {
self.anchor = anchor
}
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
MainActor.assumeIsolated {
anchor() ?? NSApp.keyWindow ?? NSApp.windows.first(where: \.isVisible) ?? NSWindow()
}
}
}