From 48a4d3f52de4d30d85ec67cb51b6aafc79f5b9d4 Mon Sep 17 00:00:00 2001 From: Iva Horn Date: Wed, 20 May 2026 15:57:50 +0200 Subject: [PATCH] fix(file-provider): Re-enumerate items after app updates Fixes #10065. Signed-off-by: Iva Horn --- .../Enumeration/Enumerator.swift | 83 ++++++++++++++++--- .../Extension/FileProviderExtension.swift | 15 ++++ .../Log/FileProviderLog.swift | 1 + .../Tests/Interface/MockChangeObserver.swift | 7 +- .../EnumeratorTests.swift | 13 +-- 5 files changed, 98 insertions(+), 21 deletions(-) diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift index 83c1cba398685..4f416c23f68e5 100644 --- a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift @@ -18,7 +18,7 @@ public final class Enumerator: NSObject, NSFileProviderEnumerator, Sendable { let domain: NSFileProviderDomain? let dbManager: FilesDatabaseManager - private let currentAnchor = NSFileProviderSyncAnchor(ISO8601DateFormatter().string(from: Date()).data(using: .utf8)!) + private let currentAnchor = Enumerator.syncAnchor(at: Date()) private let pageItemCount: Int let logger: FileProviderLogger let account: Account @@ -224,6 +224,30 @@ public final class Enumerator: NSObject, NSFileProviderEnumerator, Sendable { public func enumerateChanges(for observer: NSFileProviderChangeObserver, from anchor: NSFileProviderSyncAnchor) { logger.debug("Enumerating changes (anchor: \(String(data: anchor.rawValue, encoding: .utf8) ?? "")).", [.url: serverUrl]) + // Version-tagged anchor check — applies to every container, not just the working set. + // The framework persists per-container anchors and replays them on subsequent + // `enumerateChanges` calls. An anchor that doesn't parse as the version-tagged format + // (older builds wrote just the ISO8601 date) or whose embedded version doesn't match the + // running extension is treated as expired so the framework drops cached + // `NSFileProviderItem` snapshots in that container and re-enumerates them. The fresh + // `Item` objects then carry up-to-date `userInfo`, `contentPolicy`, etc. + // + // See nextcloud/desktop#10065. + + guard let parsed = Self.parseSyncAnchor(anchor) else { + logger.info("Sync anchor is not in the expected version-tagged format. Returning syncAnchorExpired so the framework re-enumerates this container and refreshes cached NSFileProviderItem snapshots. See nextcloud/desktop#10065.", [.item: enumeratedItemIdentifier]) + observer.finishEnumeratingWithError(NSFileProviderError(.syncAnchorExpired)) + return + } + + let runningVersion = Self.currentExtensionVersion() + + guard parsed.version == runningVersion else { + logger.info("Sync anchor's embedded extension version \"\(parsed.version)\" does not match the running extension version \"\(runningVersion)\". Returning syncAnchorExpired so the framework re-enumerates this container and refreshes cached NSFileProviderItem snapshots. See nextcloud/desktop#10065.", [.item: enumeratedItemIdentifier]) + observer.finishEnumeratingWithError(NSFileProviderError(.syncAnchorExpired)) + return + } + /* If this is an enumerator for the working set, then: @@ -235,15 +259,7 @@ public final class Enumerator: NSObject, NSFileProviderEnumerator, Sendable { if enumeratedItemIdentifier == .workingSet { logger.debug("Enumerating changes in working set.", [.account: account]) - let formatter = ISO8601DateFormatter() - - guard let anchorDateString = String(data: anchor.rawValue, encoding: .utf8), - let date = formatter.date(from: anchorDateString) - else { - logger.error("Could not parse sync anchor \"\(anchor.rawValue)\".") - observer.finishEnumeratingWithError(NSFileProviderError(.syncAnchorExpired)) - return - } + let date = parsed.date Task { await checkMaterializedItemsOnServer() @@ -752,4 +768,51 @@ public final class Enumerator: NSObject, NSFileProviderEnumerator, Sendable { // Provide it to the caller method so it can ingest it into the database and fix future errs return metadata } + + // MARK: - Version-tagged sync anchor + + /// + /// Build a sync anchor that encodes the running extension bundle's `CFBundleShortVersionString` alongside the given timestamp. + /// + /// The same format is used for every container the extension enumerates (working set, root container, sub-directories, trash) so the framework's per-container persisted anchors all carry the version. On the next call to ``enumerateChanges(for:from:)`` the embedded version is compared against the running extension — any mismatch (including anchors persisted by builds older than this change, which carried only the ISO8601 date) is rejected with `NSFileProviderError(.syncAnchorExpired)`. That causes the framework to drop its cached sync state for that container and re-enumerate it via ``enumerateItems(for:startingAt:)``, so the fresh ``Item`` objects we hand back carry up-to-date `userInfo`, `contentPolicy`, and any other `NSFileProviderItem` properties whose derivation changed between app versions. + /// + /// See nextcloud/desktop#10065. + /// + public static func syncAnchor(at date: Date) -> NSFileProviderSyncAnchor { + let raw = "\(currentExtensionVersion())|\(ISO8601DateFormatter().string(from: date))" + // Force-unwrap is safe: an ASCII version string and an ISO8601 date both encode cleanly to UTF-8. + return NSFileProviderSyncAnchor(raw.data(using: .utf8)!) + } + + /// + /// Parse a sync anchor produced by ``syncAnchor(at:)``. + /// + /// Returns `nil` for anchors that are not in the expected `"|"` format — including anchors persisted by builds older than #10065 that carried only the ISO8601 date. The caller treats `nil` as an expired anchor. + /// + private static func parseSyncAnchor(_ anchor: NSFileProviderSyncAnchor) -> (version: String, date: Date)? { + guard let raw = String(data: anchor.rawValue, encoding: .utf8) else { + return nil + } + + let parts = raw.split(separator: "|", maxSplits: 1, omittingEmptySubsequences: false) + + guard parts.count == 2 else { + return nil + } + + guard let date = ISO8601DateFormatter().date(from: String(parts[1])) else { + return nil + } + + return (String(parts[0]), date) + } + + /// + /// The running extension bundle's `CFBundleShortVersionString`, or the empty string when none is available — e.g. unit-test hosts without a versioned `Info.plist`. + /// + /// The empty-string fallback compares equal across calls inside the same process, so test anchors round-trip cleanly through ``syncAnchor(at:)`` and ``parseSyncAnchor(_:)`` without triggering the version-mismatch branch. + /// + private static func currentExtensionVersion() -> String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "" + } } diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extension/FileProviderExtension.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extension/FileProviderExtension.swift index d9366f71f2685..1fb71f5c8a2e1 100644 --- a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extension/FileProviderExtension.swift +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Extension/FileProviderExtension.swift @@ -527,6 +527,21 @@ import OSLog logger.debug("Signalling enumerators.") notifyChange() + + // Also nudge the root container so its enumerator's `enumerateChanges(for:from:)` is + // invoked shortly after the extension starts. The working-set signal above only covers + // materialised items (visited directories + downloaded files); placeholder children of + // the root — the common case for a fresh sync — would otherwise stay in the framework's + // cache with their previous `NSFileProviderItem` snapshots until the user navigates and + // forces enumeration. Combined with the version-tagged sync anchor introduced in + // `Enumerator.swift`, this guarantees the framework re-enumerates the root container + // and picks up updated `userInfo` / `contentPolicy` / etc. for every direct child on + // the first launch after an extension version bump. See nextcloud/desktop#10065. + manager.signalEnumerator(for: .rootContainer) { error in + if error != nil { + self.logger.error("Failed to signal root container enumerator after account setup.", [.error: error?.localizedDescription]) + } + } } /// diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Log/FileProviderLog.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Log/FileProviderLog.swift index f00f2cdcc4580..62323511cc661 100644 --- a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Log/FileProviderLog.swift +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Log/FileProviderLog.swift @@ -151,6 +151,7 @@ public actor FileProviderLog: FileProviderLogging { } self.logsDirectory = logsDirectory + debugLoggingObservation = UserDefaults.standard.observe(\.debugLoggingEnabled, options: [.new]) { [weak self] _, _ in Task { [weak self] in await self?.reloadDebugLoggingEnabled() diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockChangeObserver.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockChangeObserver.swift index f76629432485c..86ce6580f3bb9 100644 --- a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockChangeObserver.swift +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockChangeObserver.swift @@ -3,6 +3,7 @@ @preconcurrency import FileProvider import Foundation +import NextcloudFileProviderKit public class MockChangeObserver: NSObject, NSFileProviderChangeObserver { public var changedItems: [any NSFileProviderItemProtocol] = [] @@ -33,11 +34,7 @@ public class MockChangeObserver: NSObject, NSFileProviderChangeObserver { } public func enumerateChanges(from anchor: NSFileProviderSyncAnchor = - .init( - ISO8601DateFormatter() - .string(from: Date(timeIntervalSince1970: 1)) - .data(using: .utf8)! - )) async throws + Enumerator.syncAnchor(at: Date(timeIntervalSince1970: 1))) async throws { enumerator.enumerateChanges?(for: self, from: anchor) while !isComplete { diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift index 67de7873ab85f..ac5bb337be5fb 100644 --- a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift @@ -622,9 +622,10 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { let tenMinutesAgo = Date().addingTimeInterval(-600) let now = Date() - // Create a sync anchor from our date. - let formatter = ISO8601DateFormatter() - let anchor = try NSFileProviderSyncAnchor(XCTUnwrap(formatter.string(from: anchorDate).data(using: .utf8))) + // Build a version-tagged sync anchor at the chosen date. The same helper is used by the + // production code, so the anchor round-trips cleanly through `enumerateChanges` without + // tripping the syncAnchorExpired branch added for nextcloud/desktop#10065. + let anchor = Enumerator.syncAnchor(at: anchorDate) // Setup remote interface with the items we're testing let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) @@ -744,10 +745,10 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { childFolderMetadata.etag = remoteChildFolder.versionIdentifier Self.dbManager.addItemMetadata(childFolderMetadata) - // Create a sync anchor from before now + // Create a sync anchor from before now using the production helper so it carries the + // version prefix expected by the syncAnchorExpired check (nextcloud/desktop#10065). let anchorDate = Date().addingTimeInterval(-300) // 5 minutes ago - let formatter = ISO8601DateFormatter() - let anchor = try NSFileProviderSyncAnchor(XCTUnwrap(formatter.string(from: anchorDate).data(using: .utf8))) + let anchor = Enumerator.syncAnchor(at: anchorDate) // Update sync times to be after the anchor (so they would be checked) let now = Date()