Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 `"<version>|<ISO8601-date>"` 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 ?? ""
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

@preconcurrency import FileProvider
import Foundation
import NextcloudFileProviderKit

public class MockChangeObserver: NSObject, NSFileProviderChangeObserver {
public var changedItems: [any NSFileProviderItemProtocol] = []
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
Loading