Skip to content
Open
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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/nextcloud/NextcloudCapabilitiesKit.git", from: "2.5.0"),
.package(url: "https://github.com/nextcloud/NextcloudKit", from: "7.3.5"),
.package(url: "https://github.com/nextcloud/NextcloudKit", from: "7.5.0"),
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.55.0"),
.package(url: "https://github.com/realm/realm-swift.git", from: "20.0.4")
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public final class FilesDatabaseManager: Sendable {
)
}

private static let schemaVersion = SchemaVersion.addedChangeDeliverySessions
private static let schemaVersion = SchemaVersion.addedFileProviderContentVersion
let logger: FileProviderLogger
let account: Account

Expand Down Expand Up @@ -316,6 +316,10 @@ public final class FilesDatabaseManager: Sendable {

for var updatedMetadata in updatedMetadatas {
if let existingMetadata = existingByOcId[updatedMetadata.ocId] {
if updatedMetadata.etag == existingMetadata.etag {
updatedMetadata.fileProviderContentVersion = existingMetadata.fileProviderContentVersion
}

if existingMetadata.status == Status.normal.rawValue, !existingMetadata.isInSameDatabaseStoreableRemoteState(updatedMetadata) {
let pathChanged = !updatedMetadata.hasSameLocation(as: existingMetadata)

Expand Down Expand Up @@ -444,6 +448,10 @@ public final class FilesDatabaseManager: Sendable {
}

if let existing = itemMetadata(ocId: readTargetMetadata.ocId) {
if readTargetMetadata.etag == existing.etag {
readTargetMetadata.fileProviderContentVersion = existing.fileProviderContentVersion
}

if existing.status == Status.normal.rawValue,
!existing.isInSameDatabaseStoreableRemoteState(readTargetMetadata)
{
Expand Down Expand Up @@ -602,7 +610,7 @@ public final class FilesDatabaseManager: Sendable {
///
/// Add or replace `metadata` while carrying over local-only fields the
/// server payload cannot know about: ``keepDownloaded``, ``downloaded``,
/// ``visitedDirectory``, and ``lockToken``.
/// ``visitedDirectory``, ``lockToken``, and the content version File Provider has already seen.
///
/// Mirrors the preservation set applied by
/// ``processItemMetadatasToUpdate`` for non-paginated reads. Use this from
Expand Down Expand Up @@ -641,6 +649,9 @@ public final class FilesDatabaseManager: Sendable {
}

toWrite.lockToken = existing.lockToken
if toWrite.etag == existing.etag {
toWrite.fileProviderContentVersion = existing.fileProviderContentVersion
}
} else {
// The ocId lookup missed. Before falling back to defaults from the
// server payload, look for a single non-deleted, non-local-lock row
Expand Down Expand Up @@ -673,6 +684,9 @@ public final class FilesDatabaseManager: Sendable {
}

toWrite.lockToken = existing.lockToken
if toWrite.etag == existing.etag {
toWrite.fileProviderContentVersion = existing.fileProviderContentVersion
}
} else {
// No prior row at this ocId or logical address: this is a
// genuinely new item. Inherit the parent's "Always keep
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ enum SchemaVersion: UInt64 {
case addedExcludedFromSyncItems = 205
case addedPendingChunkUploadCleanup = 206
case addedChangeDeliverySessions = 207
case addedFileProviderContentVersion = 208
}
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,9 @@ extension Enumerator {
let isNew = existing == nil
let newItems: [SendableItemMetadata] = isNew ? [metadata] : []
metadata.lockToken = existing?.lockToken
if metadata.etag == existing?.etag {
metadata.fileProviderContentVersion = existing?.fileProviderContentVersion
}
let updatedItems: [SendableItemMetadata] = isNew ? [] : [metadata]
metadata.downloaded = existing?.downloaded == true

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,21 @@ extension Item {
targetMetadata.lockOwnerType = lock.ownerType.rawValue
targetMetadata.lockTime = lock.time
targetMetadata.lockTimeOut = lock.timeOut
if let etag = lock.etag {
// LOCK changes server metadata, not file bytes. Keep the content version
// File Provider already knows while adopting the lock response's etag.
if targetMetadata.fileProviderContentVersion == nil {
targetMetadata.fileProviderContentVersion = targetMetadata.etag
}
targetMetadata.etag = etag
}
targetMetadata.lockToken = lock.token
// Ensure token-dependent capabilities are published even if the etag is unchanged.
targetMetadata.syncTime = Date()
}

if let domain {
FileProviderChangeNotificationInterface(domain: domain, log: log).notifyChange()
}
} else {
logger.error("Failed to find target item for acquired lock.", [.lock: lock])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,6 @@ public extension Item {
}

var headers = [String: String]()

if let token = metadata.lockToken {
headers["If"] = "<\(remotePath)> (<opaquelocktoken:\(token)>)"
}
Expand Down Expand Up @@ -165,10 +164,11 @@ public extension Item {
shouldSendIfMatch = true
}

// We can only guard the write if we know the version to match against. When we
// do, a subsequent 412 is a real content conflict (below); when we don't, the
// upload stays unconditional and 412 keeps its previous stale-lock meaning.
let sentIfMatch = shouldSendIfMatch && baseEtag != nil
// A lock token is already an exclusive write precondition. Its acquisition also changes
// the server etag, while File Provider's base version intentionally remains the version the
// document was opened from. Do not combine that pre-lock etag with the current lock token.
// Without a lock token, keep using the etag as the optimistic-concurrency guard.
let sentIfMatch = shouldSendIfMatch && baseEtag != nil && metadata.lockToken == nil
if sentIfMatch, let baseEtag {
// Our stored etag is normalized (unquoted); Sabre/DAV compares If-Match
// against the quoted resource ETag, so re-add the quotes.
Expand Down Expand Up @@ -322,6 +322,7 @@ public extension Item {
// "changed by another application" right after they save it.
newMetadata.date = newContentModificationDate ?? date ?? metadata.date
newMetadata.etag = etag ?? metadata.etag
newMetadata.fileProviderContentVersion = newMetadata.etag
newMetadata.ocId = ocId
newMetadata.size = size ?? 0
newMetadata.session = ""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ public final class Item: NSObject, NSFileProviderItem, Sendable {
private let displayFileActions: Bool
private let remoteSupportsTrash: Bool

private var lockStateAllowsModifications: Bool {
metadata.lock == false || (
metadata.lockOwnerType == NKLockType.token.rawValue &&
metadata.ownerId == metadata.lockOwner &&
metadata.lockToken != nil
)
}

public var itemIdentifier: NSFileProviderItemIdentifier {
NSFileProviderItemIdentifier(metadata.ocId)
}
Expand All @@ -37,7 +45,7 @@ public final class Item: NSObject, NSFileProviderItem, Sendable {
capabilities.insert(.allowsReading)
}

if metadata.lock == false || (metadata.lock == true && metadata.lockOwnerType == NKLockType.token.rawValue && metadata.ownerId == metadata.lockOwner && metadata.lockToken != nil) {
if lockStateAllowsModifications {
if permissions.contains("D") { // Deletable
capabilities.insert(.allowsDeleting)
}
Expand Down Expand Up @@ -87,14 +95,21 @@ public final class Item: NSObject, NSFileProviderItem, Sendable {
// `metadataVersion` we previously handed the framework, the framework would treat
// its cached snapshot as still valid, and the new derivations would never reach the
// system — leaving e.g. the `displayOpenInBrowser` / `displayCopyInternalLink`
// userInfo keys missing on items enumerated by older builds. `contentVersion` stays
// bare-etag because the file content itself didn't change across the upgrade and
// bumping it would force the framework to re-download every materialised file.
// userInfo keys missing on items enumerated by older builds. `contentVersion` normally
// follows the etag, but lock-only etag transitions preserve the prior value because the
// file bytes did not change. Existing rows without that separately stored value fall back
// to the etag.
//
// See nextcloud/desktop#10065.
let extensionVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? ""
var metadataVersionString = "\(metadata.etag)|\(extensionVersion)"

// Reacquiring an existing lock can restore the local token without changing the etag.
// Include the resulting capability state so File Provider re-reads the item as writable.
if metadata.lock {
metadataVersionString += "|\(lockStateAllowsModifications)"
}

// A directory's `displayEvictDescendants` ("Remove downloaded items")
// depends on whether it holds an evictable descendant file — state that
// lives outside the folder's own etag. Fold that boolean into
Expand All @@ -111,7 +126,8 @@ public final class Item: NSObject, NSFileProviderItem, Sendable {
metadataVersionString += "|\(dbManager.hasEvictableDescendantFile(directoryMetadata: metadata))"
}

return NSFileProviderItemVersion(contentVersion: metadata.etag.data(using: .utf8)!, metadataVersion: metadataVersionString.data(using: .utf8)!)
let contentVersion = metadata.fileProviderContentVersion ?? metadata.etag
return NSFileProviderItemVersion(contentVersion: Data(contentVersion.utf8), metadataVersion: Data(metadataVersionString.utf8))
}

public var filename: String {
Expand Down Expand Up @@ -218,7 +234,11 @@ public final class Item: NSObject, NSFileProviderItem, Sendable {
]
}

if metadata.lock, metadata.lockOwnerType != NKLockType.user.rawValue || metadata.lockOwner != account.username, metadata.lockTimeOut ?? Date() > Date() {
if metadata.lock,
!lockStateAllowsModifications,
metadata.lockOwnerType != NKLockType.user.rawValue || metadata.lockOwner != account.username,
metadata.lockTimeOut ?? Date() > Date()
{
return [
.userReadable
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public protocol ItemMetadata: Equatable {
var downloadURL: String { get set }
var e2eEncrypted: Bool { get set }
var etag: String { get set }
var fileProviderContentVersion: String? { get set }
var favorite: Bool { get set }
var fileId: String { get set }
var fileName: String { get set } // What the file's real file name is
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class RealmItemMetadata: Object, ItemMetadata {
@Persisted var downloadURL = ""
@Persisted var e2eEncrypted: Bool = false
@Persisted var etag = ""
@Persisted var fileProviderContentVersion: String?
@Persisted var favorite: Bool = false
@Persisted var fileId = ""
@Persisted var fileName = "" // What the file's real file name is
Expand Down Expand Up @@ -140,6 +141,7 @@ class RealmItemMetadata: Object, ItemMetadata {
downloadURL = value.downloadURL
e2eEncrypted = value.e2eEncrypted
etag = value.etag
fileProviderContentVersion = value.fileProviderContentVersion
favorite = value.favorite
fileId = value.fileId
fileNameView = value.fileNameView
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public struct SendableItemMetadata: ItemMetadata, Codable, Sendable {
public var downloadURL: String
public var e2eEncrypted: Bool
public var etag: String
public var fileProviderContentVersion: String?
public var favorite: Bool
public var fileId: String
public var fileName: String
Expand Down Expand Up @@ -95,6 +96,7 @@ public struct SendableItemMetadata: ItemMetadata, Codable, Sendable {
downloadURL: String = "",
e2eEncrypted: Bool,
etag: String,
fileProviderContentVersion: String? = nil,
favorite: Bool = false,
fileId: String,
fileName: String,
Expand Down Expand Up @@ -162,6 +164,7 @@ public struct SendableItemMetadata: ItemMetadata, Codable, Sendable {
self.downloadURL = downloadURL
self.e2eEncrypted = e2eEncrypted
self.etag = etag
self.fileProviderContentVersion = fileProviderContentVersion
self.favorite = favorite
self.fileId = fileId
self.fileName = fileName
Expand Down Expand Up @@ -231,6 +234,7 @@ public struct SendableItemMetadata: ItemMetadata, Codable, Sendable {
downloadURL = value.downloadURL
e2eEncrypted = value.e2eEncrypted
etag = value.etag
fileProviderContentVersion = value.fileProviderContentVersion
favorite = value.favorite
fileId = value.fileId
fileName = value.fileName
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,12 @@ public class MockRemoteInterface: RemoteInterface, @unchecked Sendable {
/// which etag. Captured before any injected `uploadError` short-circuit.
public var lastUploadIfMatchHeader: String?

/// Records the WebDAV `If` header the most recent upload call carried (nil if none).
public var lastUploadIfHeader: String?

/// Lock information returned by lock and unlock requests.
public var lockUnlockResult: NKLock?

/// Handler to track enumerate calls
public var enumerateCallHandler: ((String, EnumerateDepth, Bool, [String], Data?, Account, NKRequestOptions, @escaping (URLSessionTask) -> Void) -> Void)?

Expand Down Expand Up @@ -794,6 +800,7 @@ public class MockRemoteInterface: RemoteInterface, @unchecked Sendable {
remoteError: NKError
) {
lastUploadIfMatchHeader = options.customHeader?["If-Match"]
lastUploadIfHeader = options.customHeader?["If"]

if let uploadError {
return (account.ncKitAccount, nil, nil, nil, 0, nil, uploadError)
Expand Down Expand Up @@ -1300,8 +1307,11 @@ public class MockRemoteInterface: RemoteInterface, @unchecked Sendable {
}

item.locked = shouldLock
if shouldLock, let etag = lockUnlockResult?.etag {
item.versionIdentifier = etag
}

return nil
return lockUnlockResult
}

public func listingTrashAsync(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2205,6 +2205,26 @@ final class FilesDatabaseManagerTests: NextcloudFileProviderKitTestCase {
XCTAssertTrue(storedRotated.downloaded)
}

func testAddItemMetadataPreservingLocalStateKeepsContentVersionForSameEtag() {
let account = Account(user: "test", id: "t", serverUrl: "https://example.com", password: "")

var original = SendableItemMetadata(ocId: "item", fileName: "locked.txt", account: account)
original.etag = "etag-after-lock"
original.fileProviderContentVersion = "etag-before-lock"
Self.dbManager.addItemMetadata(original)

var refreshed = SendableItemMetadata(ocId: "item", fileName: "locked.txt", account: account)
refreshed.etag = "etag-after-lock"

let merged = Self.dbManager.addItemMetadataPreservingLocalState(refreshed)

XCTAssertEqual(merged.fileProviderContentVersion, "etag-before-lock")
XCTAssertEqual(
Self.dbManager.itemMetadata(ocId: "item")?.fileProviderContentVersion,
"etag-before-lock"
)
}

func testAddItemMetadataPreservingLocalStateFallbackDoesNotMergeWhenAlreadyDuplicated() throws {
let account = Account(user: "test", id: "t", serverUrl: "https://example.com", password: "")
let fileName = "duped.txt"
Expand Down
Loading
Loading