- AutoBackupCoordinator: add 55s debounce to onActive() so repeated .task and scenePhase triggers (navigation appear, rapid foreground cycles) are no-ops; start a 60s periodic recheck timer on first active call; stop timer and reset debounce on willResignActiveNotification so each new foreground session always gets an immediate fresh status check - BackupView: remove the redundant lanMonitor.nasReachable onChange that was calling statusService.refresh() directly — coordinator already handles this via its Combine $nasReachable subscriber, avoiding a double reconcile that could bypass the autoBackupOnOpen gate with the wrong lastTriggerWasLAN value - BackupStatusService: when NAS connect fails, restore nasArchiveTotal from NASManifestCache before writing the offline snapshot — prevents the count from showing 0 when the NAS is temporarily unreachable but the cache is warm - BackupStatusService: manifest validity check — if the decoded manifest has ≤ 1 entries (corrupt or newly created), scan the NAS directory and use the real file count as the display floor for nasArchiveTotal without adding orphan entries to the manifest (that was the 7421 regression) - BackupManifest: add ManifestIndex.init(manifest:overrideTotalCount:) for the validity check path — keeps localIdentifier matching intact while correcting the displayed archive count - SMBService: add os.log around connect/auth/listDirectory with actual error reason instead of always surfacing authenticationFailed; distinguish network errors (timeout, host unreachable) from auth errors in thrown BackupError Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
149 lines
5.9 KiB
Swift
149 lines
5.9 KiB
Swift
import Foundation
|
|
import Photos
|
|
|
|
// MARK: — ManifestIndex
|
|
// Lightweight comparison structure — two Sets for O(1) lookups.
|
|
// Built from BackupManifest and then the manifest is released.
|
|
// Sendable: all stored properties are value types — safe to pass across task boundaries.
|
|
struct ManifestIndex: Sendable {
|
|
let localIdentifiers: Set<String> // PHAsset.localIdentifier (primary key)
|
|
let lowercasedFilenames: Set<String> // all filenames — used for pure bootstrap mode
|
|
let unclaimedFilenames: Set<String> // filenames of entries with no localIdentifier
|
|
// (orphans / pre-ID backups) — filename fallback
|
|
let totalCount: Int // non-hidden file count for NAS Archive stat
|
|
|
|
/// True only when every entry lacks a localIdentifier (pure bootstrap from NAS listing).
|
|
var isFilenameOnly: Bool { localIdentifiers.isEmpty }
|
|
|
|
func matches(localIdentifier id: String) -> Bool {
|
|
!id.isEmpty && localIdentifiers.contains(id)
|
|
}
|
|
|
|
/// Full filename match — only used in pure bootstrap mode.
|
|
func matches(filename: String) -> Bool {
|
|
lowercasedFilenames.contains(filename.lowercased())
|
|
}
|
|
|
|
/// Filename match against orphan entries only.
|
|
/// Use as fallback when localIdentifier matching fails: if a NAS file was uploaded
|
|
/// in a session where the localIdentifier wasn't saved (interrupted / pre-ID backup),
|
|
/// matching by filename prevents re-uploading it.
|
|
func matchesUnclaimed(filename: String) -> Bool {
|
|
unclaimedFilenames.contains(filename.lowercased())
|
|
}
|
|
|
|
// Build from decoded manifest — manifest released by caller after this returns
|
|
init(manifest: BackupManifest) {
|
|
var ids = Set<String>(minimumCapacity: manifest.entries.count)
|
|
var names = Set<String>(minimumCapacity: manifest.entries.count)
|
|
var unclaimed = Set<String>()
|
|
var count = 0
|
|
for entry in manifest.entries {
|
|
if !entry.filename.hasPrefix(".") { count += 1 }
|
|
let lower = entry.filename.lowercased()
|
|
names.insert(lower)
|
|
if !entry.localIdentifier.isEmpty {
|
|
ids.insert(entry.localIdentifier)
|
|
} else {
|
|
unclaimed.insert(lower)
|
|
}
|
|
}
|
|
self.localIdentifiers = ids
|
|
self.lowercasedFilenames = names
|
|
self.unclaimedFilenames = unclaimed
|
|
self.totalCount = count
|
|
}
|
|
|
|
// Same as init(manifest:) but with a corrected totalCount for display.
|
|
// Used when the manifest has too few entries to trust but localIdentifiers are still valid.
|
|
init(manifest: BackupManifest, overrideTotalCount: Int) {
|
|
var ids = Set<String>(minimumCapacity: manifest.entries.count)
|
|
var names = Set<String>(minimumCapacity: manifest.entries.count)
|
|
var unclaimed = Set<String>()
|
|
for entry in manifest.entries {
|
|
let lower = entry.filename.lowercased()
|
|
names.insert(lower)
|
|
if !entry.localIdentifier.isEmpty {
|
|
ids.insert(entry.localIdentifier)
|
|
} else {
|
|
unclaimed.insert(lower)
|
|
}
|
|
}
|
|
self.localIdentifiers = ids
|
|
self.lowercasedFilenames = names
|
|
self.unclaimedFilenames = unclaimed
|
|
self.totalCount = overrideTotalCount
|
|
}
|
|
|
|
// Build from NAS directory listing (bootstrap — no localIdentifiers known)
|
|
init(nasListing items: [NASItem]) {
|
|
var names = Set<String>()
|
|
var count = 0
|
|
for item in items where !item.isDirectory && item.name != BackupManifest.remoteFilename {
|
|
if !item.name.hasPrefix(".") { count += 1 }
|
|
names.insert(item.name.lowercased())
|
|
}
|
|
self.localIdentifiers = []
|
|
self.lowercasedFilenames = names
|
|
self.unclaimedFilenames = names // all filename-only in bootstrap mode
|
|
self.totalCount = count
|
|
}
|
|
}
|
|
|
|
// MARK: — ManifestEntry
|
|
|
|
struct ManifestEntry: Codable, Sendable {
|
|
let localIdentifier: String // PHAsset.localIdentifier — primary key
|
|
let filename: String
|
|
let creationDate: Date?
|
|
let fileSize: Int64
|
|
let remotePath: String
|
|
let uploadedAt: Date
|
|
}
|
|
|
|
struct BackupManifest: Codable, Sendable {
|
|
static let remoteFilename = ".kisani.json"
|
|
|
|
var version: Int = 1
|
|
var lastUpdated: Date = Date()
|
|
var entries: [ManifestEntry] = []
|
|
|
|
// Primary match: stable PHAsset localIdentifier
|
|
func contains(localIdentifier id: String) -> Bool {
|
|
guard !id.isEmpty else { return false }
|
|
return entries.contains { $0.localIdentifier == id }
|
|
}
|
|
|
|
// Fallback: case-insensitive filename (for assets backed up before manifest existed)
|
|
func containsByFilename(_ name: String) -> Bool {
|
|
let lower = name.lowercased()
|
|
return entries.contains { $0.filename.lowercased() == lower }
|
|
}
|
|
|
|
// Merge new entries, replacing any existing record for the same localIdentifier
|
|
mutating func merge(entries newEntries: [ManifestEntry]) {
|
|
let newIds = Set(newEntries.map { $0.localIdentifier }.filter { !$0.isEmpty })
|
|
entries.removeAll { !$0.localIdentifier.isEmpty && newIds.contains($0.localIdentifier) }
|
|
entries.append(contentsOf: newEntries)
|
|
lastUpdated = Date()
|
|
}
|
|
|
|
// Bootstrap from a raw NAS directory listing (no localIdentifiers known)
|
|
static func buildFromNASListing(_ items: [NASItem]) -> BackupManifest {
|
|
var manifest = BackupManifest()
|
|
manifest.entries = items
|
|
.filter { !$0.isDirectory && $0.name != remoteFilename }
|
|
.map { item in
|
|
ManifestEntry(
|
|
localIdentifier: "",
|
|
filename: item.name,
|
|
creationDate: item.modifiedDate,
|
|
fileSize: item.size,
|
|
remotePath: item.path,
|
|
uploadedAt: item.modifiedDate ?? Date()
|
|
)
|
|
}
|
|
return manifest
|
|
}
|
|
}
|