- 账号与同步: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 与译文同步
398 lines
14 KiB
Swift
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()
|
|
}
|
|
}
|
|
}
|