diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift index 83c1cba398685..9c89cc3fc22c1 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.workingSetSyncAnchor(at: Date()) private let pageItemCount: Int let logger: FileProviderLogger let account: Account @@ -235,16 +235,22 @@ public final class Enumerator: NSObject, NSFileProviderEnumerator, Sendable { if enumeratedItemIdentifier == .workingSet { logger.debug("Enumerating changes in working set.", [.account: account]) - let formatter = ISO8601DateFormatter() + guard let parsed = Self.parseWorkingSetSyncAnchor(anchor) else { + logger.info("Working-set sync anchor is not in the expected version-tagged format. Returning syncAnchorExpired so the framework re-enumerates the working set and refreshes cached NSFileProviderItem snapshots. See nextcloud/desktop#10065.") + observer.finishEnumeratingWithError(NSFileProviderError(.syncAnchorExpired)) + return + } - 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)\".") + let runningVersion = Self.currentExtensionVersion() + + guard parsed.version == runningVersion else { + logger.info("Working-set sync anchor's embedded extension version \"\(parsed.version)\" does not match the running extension version \"\(runningVersion)\". Returning syncAnchorExpired so the framework re-enumerates the working set and refreshes cached NSFileProviderItem snapshots. See nextcloud/desktop#10065.") observer.finishEnumeratingWithError(NSFileProviderError(.syncAnchorExpired)) return } + let date = parsed.date + Task { await checkMaterializedItemsOnServer() let pendingLocalChanges = dbManager.pendingWorkingSetChanges(since: date) @@ -752,4 +758,54 @@ 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: - Working-set sync anchor + + /// + /// Build a working-set sync anchor that encodes the running extension bundle's `CFBundleShortVersionString` alongside the given timestamp. + /// + /// On the next call to ``enumerateChanges(for:from:)`` for the working set, an anchor whose embedded version doesn't match the running extension is rejected with `NSFileProviderError(.syncAnchorExpired)`. + /// That causes the framework to drop its cached sync state and re-enumerate the working set, 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. + /// Anchors persisted by builds older than this change — which carried only the ISO8601 date — fail the parse step in ``parseWorkingSetSyncAnchor(_:)`` and trigger the same re-enumeration on first launch after the upgrade. + /// + /// See nextcloud/desktop#10065. + /// + public static func workingSetSyncAnchor(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 working-set sync anchor produced by ``workingSetSyncAnchor(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 parseWorkingSetSyncAnchor(_ 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 ``workingSetSyncAnchor(at:)`` and ``parseWorkingSetSyncAnchor(_:)`` 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/Tests/Interface/MockChangeObserver.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockChangeObserver.swift index f76629432485c..4946c2063a76f 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.workingSetSyncAnchor(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..8576400e56cf7 100644 --- a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift @@ -622,9 +622,11 @@ 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 working-set 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.workingSetSyncAnchor(at: anchorDate) // Setup remote interface with the items we're testing let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) @@ -744,10 +746,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.workingSetSyncAnchor(at: anchorDate) // Update sync times to be after the anchor (so they would be checked) let now = Date()