diff --git a/Core/Models/BackupManifest.swift b/Core/Models/BackupManifest.swift index 3610fe7..fb5d56c 100644 --- a/Core/Models/BackupManifest.swift +++ b/Core/Models/BackupManifest.swift @@ -75,6 +75,16 @@ struct ManifestIndex: Sendable { self.totalCount = overrideTotalCount } + // Rebuild from cached NAS filenames (fast-path for sparse-manifest reconcile). + // Filenames are expected to already be filtered (no dirs, no hidden, no manifest file). + init(nasFilenames filenames: [String]) { + let names = Set(filenames.map { $0.lowercased() }) + self.localIdentifiers = [] + self.lowercasedFilenames = names + self.unclaimedFilenames = names + self.totalCount = names.count + } + // Build from NAS directory listing (bootstrap — no localIdentifiers known) init(nasListing items: [NASItem]) { var names = Set() diff --git a/Kisani.xcodeproj/project.pbxproj b/Kisani.xcodeproj/project.pbxproj index 990066f..b72cbab 100644 --- a/Kisani.xcodeproj/project.pbxproj +++ b/Kisani.xcodeproj/project.pbxproj @@ -122,7 +122,7 @@ 90F628AC04DC05FDCBAE52D9 /* SyncView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncView.swift; sourceTree = ""; }; 966927456571CE45600BEF75 /* BackupManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupManifest.swift; sourceTree = ""; }; 9EF7C3E36ACFD6096DE0677A /* ConnectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewModel.swift; sourceTree = ""; }; - A1DAA26CE0A4DFFDD90370F2 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + A1DAA26CE0A4DFFDD90370F2 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; B3306187119851CE3E989408 /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = ""; }; B70F1C01E131F5F2798200E2 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; BBBDD83322981C31F4DE00FF /* SavedConnections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedConnections.swift; sourceTree = ""; }; @@ -139,7 +139,7 @@ E317DD286568B85367A66AD3 /* NASManifestCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASManifestCache.swift; sourceTree = ""; }; E956B8562EDB9259034CF8FF /* AppMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMenuView.swift; sourceTree = ""; }; E987653F4F7129568656EFDE /* BackupResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupResult.swift; sourceTree = ""; }; - ED5F358675A200A5C0FF2289 /* NASBackup.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = NASBackup.app; sourceTree = BUILT_PRODUCTS_DIR; }; + ED5F358675A200A5C0FF2289 /* NASBackup.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = NASBackup.app; sourceTree = BUILT_PRODUCTS_DIR; }; EF029B56DE72F2D5D7977D95 /* SFTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFTPService.swift; sourceTree = ""; }; EF4EDDA4B02ABF0F17BE86D5 /* BackupQueueItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupQueueItem.swift; sourceTree = ""; }; F052EE8B0A757532E4BCBCCD /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; @@ -463,7 +463,6 @@ LastUpgradeCheck = 1500; TargetAttributes = { 009689A0ADEB4878A288991E = { - DevelopmentTeam = ""; ProvisioningStyle = Automatic; }; 3362521ADE965E6BA3383045 = { @@ -600,6 +599,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; + DEVELOPMENT_TEAM = K8BLMMR883; INFOPLIST_FILE = App/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -779,6 +779,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; + DEVELOPMENT_TEAM = K8BLMMR883; INFOPLIST_FILE = App/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/Services/BackupStatusService.swift b/Services/BackupStatusService.swift index 54cb897..7106ed6 100644 --- a/Services/BackupStatusService.swift +++ b/Services/BackupStatusService.swift @@ -159,13 +159,27 @@ final class BackupStatusService: NSObject, ObservableObject { let manifestCacheIsStale = await NASManifestCache.shared.isStale let localIndexCount = await LocalPhotoIndex.shared.totalCount - if !manifestCacheIsStale, let cached = await NASManifestCache.shared.manifest, localIndexCount > 0 { - let index = await Task.detached(priority: .utility) { - ManifestIndex(manifest: cached) - }.value + fastPath: if !manifestCacheIsStale, let cached = await NASManifestCache.shared.manifest, localIndexCount > 0 { + let dirCount = await NASManifestCache.shared.directoryCount ?? 0 + let index: ManifestIndex + if cached.entries.count <= 1 && dirCount > 1 { + // Manifest is sparse vs. actual NAS directory. Use cached filenames for + // filename-based matching so countSafe reflects real NAS coverage. + // If filenames aren't cached yet, fall through to a full NAS reconcile. + guard let filenames = await NASManifestCache.shared.directoryFilenames else { + log.info("Reconcile (cached): sparse manifest, no cached filenames — falling to full reconcile") + break fastPath + } + index = await Task.detached(priority: .utility) { + ManifestIndex(nasFilenames: filenames) + }.value + } else { + index = await Task.detached(priority: .utility) { + ManifestIndex(manifest: cached) + }.value + } let safe = await LocalPhotoIndex.shared.countSafe(against: index, filter: filter) let phoneTotal = await LocalPhotoIndex.shared.count(filter: filter) - let dirCount = await NASManifestCache.shared.directoryCount ?? 0 let nasTotal = max(index.totalCount, dirCount) var updated = BackupStatusSnapshot() updated.phoneTotal = phoneTotal @@ -295,17 +309,21 @@ final class BackupStatusService: NSObject, ObservableObject { Task { await NASManifestCache.shared.update(manifest) } // Validity check: if the manifest has ≤ 1 entries it may be corrupt or newly - // created. Scan the directory to get the real file count for the NAS Archive - // display — but do NOT add directory files as orphan manifest entries. + // created. Scan the directory to get the real NAS contents. When the directory + // is larger than the manifest, return a filename-based index so countSafe + // matches against the actual NAS files, not just the sparse manifest entries. if index.totalCount <= 1 { let items = (try? await Self.withTimeout(12) { try await transfer.listDirectory(at: conn.remotePath) }) ?? [] - let dirCount = items.filter { + let filteredItems = items.filter { !$0.isDirectory && !$0.name.hasPrefix(".") && $0.name != BackupManifest.remoteFilename - }.count - log.info("buildManifestIndex: validity — manifest=\(index.totalCount) dir=\(dirCount) — using max") + } + let dirCount = filteredItems.count + log.info("buildManifestIndex: validity — manifest=\(index.totalCount) dir=\(dirCount) — filename matching") if dirCount > index.totalCount { - await NASManifestCache.shared.setDirectoryCount(dirCount) - return ManifestIndex(manifest: manifest, overrideTotalCount: dirCount) + await NASManifestCache.shared.setDirectoryFilenames(filteredItems.map { $0.name }) + return await Task.detached(priority: .utility) { + ManifestIndex(nasListing: items) + }.value } } return index diff --git a/Services/NASManifestCache.swift b/Services/NASManifestCache.swift index 26c1b96..5594fe3 100644 --- a/Services/NASManifestCache.swift +++ b/Services/NASManifestCache.swift @@ -26,6 +26,10 @@ actor NASManifestCache { /// Stored independently from manifest.entries.count — the folder may contain /// files uploaded by other tools that are not in Kisani's manifest. var directoryCount: Int? + /// Filtered NAS filenames from the last validity-check or bootstrap scan. + /// Present only when the manifest was found sparse vs. actual NAS contents. + /// Used by the fast-path reconcile to do filename matching without hitting NAS. + var directoryFilenames: [String]? } private init() { @@ -45,6 +49,10 @@ actor NASManifestCache { /// nil until the first full reconcile completes. var directoryCount: Int? { cached?.directoryCount } + /// Cached NAS filenames from the last validity-check scan. + /// nil until the first validity-check reconcile completes with a sparse manifest. + var directoryFilenames: [String]? { cached?.directoryFilenames } + /// True when the cache is absent or older than the stale threshold. var isStale: Bool { guard let c = cached else { return true } @@ -70,6 +78,22 @@ actor NASManifestCache { saveToDisk() } + /// Store filtered NAS filenames from a validity-check or bootstrap scan. + /// Also updates directoryCount atomically from the filename array length. + func setDirectoryFilenames(_ filenames: [String]) { + let count = filenames.count + if var entry = cached { + entry.directoryCount = count + entry.directoryFilenames = filenames + cached = entry + } else { + cached = CachedEntry(manifest: BackupManifest(), savedAt: .distantPast, + directoryCount: count, directoryFilenames: filenames) + } + log.info("ManifestCache: directoryFilenames=\(count)") + saveToDisk() + } + /// Increment the stored directory count after a successful upload batch. func addToDirectoryCount(_ delta: Int) { guard delta > 0 else { return }