Files
XStats/Packages/XStatsKit/Sources/XStatsUI/State/SyncController.swift
T

145 lines
5.6 KiB
Swift

// Copyright (c) 2026 GiantAccel, LLC
// XStats modifications Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
//
// Upstream portions retain their MIT license.
// XStats modifications are licensed under AGPL-3.0-or-later.
// See LICENSE, LICENSING.md and LICENSES/OpenStats-MIT.txt.
import Foundation
import Localization
import Observation
import WebDAVSync
/// 手动 WebDAV 同步。一次仅执行一个操作,下载完整校验后只暂存,确认前不改设置。
@MainActor
@Observable
public final class SyncController {
public enum Phase: Equatable {
case idle, uploading, downloading
case failed(String)
}
public private(set) var configuration: WebDAVConfiguration?
public private(set) var phase: Phase = .idle
public private(set) var pendingDownload: SettingsBackup?
public private(set) var message: String?
public private(set) var lastSyncedAt: Date?
public var isBusy: Bool { phase == .uploading || phase == .downloading }
@ObservationIgnored private let settings: AppSettings
@ObservationIgnored private let defaults: UserDefaults
@ObservationIgnored private let client: WebDAVClient
@ObservationIgnored private let passwords: any WebDAVPasswordStore
init(settings: AppSettings, defaults: UserDefaults = .standard,
client: WebDAVClient = WebDAVClient(),
passwords: any WebDAVPasswordStore = KeychainWebDAVPasswordStore()) {
self.settings = settings
self.defaults = defaults
self.client = client
self.passwords = passwords
if let saved = defaults.dictionary(forKey: "webdav.configuration"),
let address = saved["address"] as? String, let username = saved["username"] as? String {
configuration = try? WebDAVConfiguration(address: address, username: username)
}
lastSyncedAt = defaults.object(forKey: "webdav.lastSyncedAt") as? Date
}
/// 只在设置页打开或用户操作时读钥匙串;应用启动不登录、不访问网络。
func savedPassword() -> String {
guard let configuration else { return "" }
do {
return try passwords.read(account: configuration.credentialAccount) ?? ""
} catch {
phase = .failed(error.localizedDescription)
return ""
}
}
@discardableResult
func save(address: String, username: String, password: String) -> Bool {
guard !isBusy else { return false }
do {
let next = try WebDAVConfiguration(address: address, username: username)
guard !password.isEmpty else { throw WebDAVError.missingPassword }
// 先保存密码,失败时不提交连接信息,防止界面显示了实际不可用的新配置。
try passwords.write(password, account: next.credentialAccount)
if configuration != next {
lastSyncedAt = nil
defaults.removeObject(forKey: "webdav.lastSyncedAt")
}
defaults.set(["address": next.directoryURL.absoluteString, "username": next.username],
forKey: "webdav.configuration")
configuration = next
pendingDownload = nil
phase = .idle
message = tr("配置已保存,上传或下载时将验证连接")
return true
} catch {
handle(error)
return false
}
}
func upload() async {
guard !isBusy, pendingDownload == nil else { return }
phase = .uploading
message = nil
do {
let (configuration, password) = try credentials()
let data = try SettingsBackup(document: settings.exportDocument()).encoded()
try await client.upload(data, configuration: configuration, password: password)
try Task.checkCancellation()
markSynced()
message = tr("本机设置已上传")
phase = .idle
} catch { handle(error) }
}
func download() async {
guard !isBusy, pendingDownload == nil else { return }
phase = .downloading
message = nil
do {
let (configuration, password) = try credentials()
let data = try await client.download(configuration: configuration, password: password)
let backup = try SettingsBackup.decode(data)
try Task.checkCancellation()
pendingDownload = backup
phase = .idle
} catch { handle(error) }
}
func confirmDownload() {
guard !isBusy, let backup = pendingDownload else { return }
// 校验和下载已全部完成;此处在主线程同步应用,失败响应和取消操作不会进入这里。
settings.apply(backup.document)
pendingDownload = nil
markSynced()
message = tr("已应用远端设置")
phase = .idle
}
func discardDownload() { pendingDownload = nil }
private func credentials() throws -> (WebDAVConfiguration, String) {
guard let configuration else { throw WebDAVError.notConfigured }
guard let password = try passwords.read(account: configuration.credentialAccount),
!password.isEmpty else { throw WebDAVError.missingPassword }
return (configuration, password)
}
private func markSynced() {
let now = Date()
lastSyncedAt = now
defaults.set(now, forKey: "webdav.lastSyncedAt")
}
private func handle(_ error: Error) {
pendingDownload = nil
phase = (error is CancellationError || Task.isCancelled) ? .idle : .failed(error.localizedDescription)
}
}