145 lines
5.6 KiB
Swift
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)
|
|
}
|
|
}
|