Swift does not allow async expressions inside ?? closures. Replaced with explicit if-let branch so await is at statement level. Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
224 lines
7.8 KiB
Swift
224 lines
7.8 KiB
Swift
import Foundation
|
|
import Photos
|
|
|
|
@MainActor
|
|
final class BackupStatusService: NSObject, ObservableObject {
|
|
static let shared = BackupStatusService()
|
|
|
|
@Published private(set) var snapshot: BackupStatusSnapshot = .empty
|
|
@Published private(set) var isRefreshing: Bool = false
|
|
@Published private(set) var refreshError: String?
|
|
|
|
private let photoService = PhotoLibraryService()
|
|
private let cacheKey = "backupStatusSnapshot_v2"
|
|
private var refreshTask: Task<Void, Never>?
|
|
private var lastFullRefresh: Date?
|
|
|
|
private override init() {
|
|
super.init()
|
|
loadCachedSnapshot()
|
|
PHPhotoLibrary.shared().register(self)
|
|
}
|
|
|
|
// MARK: — Public
|
|
|
|
/// Trigger a full reconciliation. `force: true` bypasses the 30-second debounce.
|
|
func refresh(force: Bool = false) {
|
|
refreshTask?.cancel()
|
|
refreshTask = Task { [weak self] in
|
|
await self?.performRefresh(force: force)
|
|
}
|
|
}
|
|
|
|
/// Called by BackupEngine after a completed run.
|
|
/// Applies an optimistic update immediately, then writes the manifest and reconciles in background.
|
|
func refreshAfterBackup(entries: [ManifestEntry], connection: NASConnection) {
|
|
let uploaded = entries.count
|
|
snapshot.alreadySafe = min(snapshot.phoneTotal, snapshot.alreadySafe + uploaded)
|
|
snapshot.nasArchiveTotal += uploaded
|
|
saveSnapshot()
|
|
Task {
|
|
await writeManifest(entries: entries, connection: connection)
|
|
refresh(force: true)
|
|
}
|
|
}
|
|
|
|
// MARK: — Core refresh
|
|
|
|
private func performRefresh(force: Bool) async {
|
|
guard !isRefreshing else { return }
|
|
|
|
// Debounce: non-forced refreshes skip the NAS round-trip if we just reconciled
|
|
if !force, let last = lastFullRefresh, Date().timeIntervalSince(last) < 30 {
|
|
updatePhoneCountOnly()
|
|
return
|
|
}
|
|
|
|
lastFullRefresh = Date()
|
|
isRefreshing = true
|
|
refreshError = nil
|
|
|
|
do {
|
|
try await reconcile()
|
|
} catch is CancellationError {
|
|
// Swallow — a newer refresh superseded this one
|
|
} catch {
|
|
refreshError = error.localizedDescription
|
|
// Keep last cached numbers; mark NAS offline
|
|
snapshot.connectionState = .offline
|
|
snapshot.lastCheckedAt = Date()
|
|
saveSnapshot()
|
|
}
|
|
|
|
isRefreshing = false
|
|
}
|
|
|
|
private func reconcile() async throws {
|
|
let store = ConnectionStore.shared
|
|
guard let conn = store.savedConnection else { return }
|
|
|
|
// 1. Count phone assets (fast, no network)
|
|
let filter = store.backupFilter
|
|
let assets = photoService.fetchAssets(filter: filter)
|
|
let phoneTotal = assets.count
|
|
|
|
try Task.checkCancellation()
|
|
|
|
// 2. Connect to NAS
|
|
let transfer: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService()
|
|
|
|
do {
|
|
try await transfer.connect(
|
|
to: conn.host, port: conn.port,
|
|
username: conn.username, password: conn.password
|
|
)
|
|
} catch {
|
|
// NAS offline — update phone count only, enforce invariant on cached safe count
|
|
snapshot.phoneTotal = phoneTotal
|
|
snapshot.alreadySafe = min(snapshot.alreadySafe, phoneTotal)
|
|
snapshot.connectionState = .offline
|
|
snapshot.lastCheckedAt = Date()
|
|
saveSnapshot()
|
|
return
|
|
}
|
|
|
|
defer { transfer.disconnect() }
|
|
try Task.checkCancellation()
|
|
|
|
// 3. Load or bootstrap the manifest
|
|
let manifestPath = "\(conn.remotePath)/\(BackupManifest.remoteFilename)"
|
|
let manifest: BackupManifest
|
|
|
|
do {
|
|
let data = try await transfer.downloadData(at: manifestPath)
|
|
if let decoded = try? JSONDecoder().decode(BackupManifest.self, from: data) {
|
|
manifest = decoded
|
|
} else {
|
|
let items = (try? await transfer.listDirectory(at: conn.remotePath)) ?? []
|
|
manifest = BackupManifest.buildFromNASListing(items)
|
|
}
|
|
} catch {
|
|
// Manifest not found — bootstrap from directory listing
|
|
let items = (try? await transfer.listDirectory(at: conn.remotePath)) ?? []
|
|
manifest = BackupManifest.buildFromNASListing(items)
|
|
}
|
|
|
|
try Task.checkCancellation()
|
|
|
|
// 4. Compare — localIdentifier first, filename fallback for legacy entries
|
|
var safe = 0
|
|
for asset in assets {
|
|
if manifest.contains(localIdentifier: asset.localIdentifier)
|
|
|| manifest.containsByFilename(asset.filename) {
|
|
safe += 1
|
|
}
|
|
}
|
|
|
|
// 5. NAS archive count (excludes hidden manifest file)
|
|
let nasArchive = manifest.entries.filter { !$0.filename.hasPrefix(".") }.count
|
|
|
|
// 6. Commit — enforce invariant
|
|
var updated = BackupStatusSnapshot()
|
|
updated.phoneTotal = phoneTotal
|
|
updated.alreadySafe = min(safe, phoneTotal) // invariant: never > phoneTotal
|
|
updated.nasArchiveTotal = nasArchive
|
|
updated.connectionState = .connected
|
|
updated.lastCheckedAt = Date()
|
|
snapshot = updated
|
|
saveSnapshot()
|
|
}
|
|
|
|
// Fast phone-only update used when debouncing NAS round-trips
|
|
private func updatePhoneCountOnly() {
|
|
let filter = ConnectionStore.shared.backupFilter
|
|
let assets = photoService.fetchAssets(filter: filter)
|
|
let phoneTotal = assets.count
|
|
if phoneTotal != snapshot.phoneTotal {
|
|
snapshot.phoneTotal = phoneTotal
|
|
snapshot.alreadySafe = min(snapshot.alreadySafe, phoneTotal)
|
|
saveSnapshot()
|
|
}
|
|
}
|
|
|
|
// MARK: — Manifest write (non-blocking background operation)
|
|
|
|
func writeManifest(entries: [ManifestEntry], connection: NASConnection) async {
|
|
guard !entries.isEmpty else { return }
|
|
|
|
let transfer: any NASTransferProtocol = connection.nasProtocol == .smb ? SMBService() : SFTPService()
|
|
do {
|
|
try await transfer.connect(
|
|
to: connection.host, port: connection.port,
|
|
username: connection.username, password: connection.password
|
|
)
|
|
defer { transfer.disconnect() }
|
|
|
|
let manifestPath = "\(connection.remotePath)/\(BackupManifest.remoteFilename)"
|
|
|
|
var manifest: BackupManifest
|
|
do {
|
|
let data = try await transfer.downloadData(at: manifestPath)
|
|
manifest = (try? JSONDecoder().decode(BackupManifest.self, from: data)) ?? BackupManifest()
|
|
} catch {
|
|
manifest = BackupManifest()
|
|
}
|
|
|
|
manifest.merge(entries: entries)
|
|
|
|
let encoder = JSONEncoder()
|
|
encoder.dateEncodingStrategy = .iso8601
|
|
let data = try encoder.encode(manifest)
|
|
try await transfer.writeData(data, to: manifestPath)
|
|
} catch {
|
|
// Manifest write failure is non-fatal — reconciliation rebuilds it next time
|
|
}
|
|
}
|
|
|
|
// MARK: — Cache
|
|
|
|
private func loadCachedSnapshot() {
|
|
guard let data = UserDefaults.standard.data(forKey: cacheKey),
|
|
let cached = try? JSONDecoder().decode(BackupStatusSnapshot.self, from: data)
|
|
else { return }
|
|
snapshot = cached
|
|
}
|
|
|
|
private func saveSnapshot() {
|
|
let encoder = JSONEncoder()
|
|
encoder.dateEncodingStrategy = .iso8601
|
|
if let data = try? encoder.encode(snapshot) {
|
|
UserDefaults.standard.set(data, forKey: cacheKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: — PHPhotoLibraryChangeObserver
|
|
|
|
extension BackupStatusService: PHPhotoLibraryChangeObserver {
|
|
nonisolated func photoLibraryDidChange(_ changeInstance: PHChange) {
|
|
Task { @MainActor [weak self] in
|
|
self?.refresh() // debounced — won't hit NAS more than once per 30s
|
|
}
|
|
}
|
|
}
|