Skip to content
Merged
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 @@ -101,13 +101,16 @@ extension Enumerator {
logger.debug("Completed checking materialised items for changes on the server.")
}

// Trashed rows are excluded because trashing moves `serverUrl` to the trashbin without
// setting `deleted`, and the ordinary DAV path 404s there — which the scan reads as a
// deletion of the row trash reconciliation needs.
// Unlike when enumerating items we can't progressively enumerate items as we need to
// wait to see which items are truly deleted and which have just been moved elsewhere.
// Visited folders and downloaded files. Sort in terms of their remote URLs.
// This way we ensure we visit parent folders before their children.
let materialisedItems = dbManager
.materialisedItemMetadatas(account: account.ncKitAccount)
.filter { !$0.deleted }
.filter { !$0.deleted && !$0.isTrashed }
.sorted { $0.remotePath().count < $1.remotePath().count }

var accumulatedCreations = [SendableItemMetadata]()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ import OSLog
private var pendingAccount: Account?
private var setupChain: Task<Void, Never> = Task {}

// Waiters parked in `awaitAccount(…)` until `ncAccount` is published.
private let accountReadyLock = NSLock()
private var accountReadyWaiters = [UUID: CheckedContinuation<Void, Never>]()

/// Whether or not we are going to recursively scan new folders when they are discovered.
/// Apple's recommendation is that we should always scan the file hierarchy fully.
/// This does lead to long load times when a file provider domain is initially configured.
Expand Down Expand Up @@ -146,21 +150,27 @@ import OSLog
public func item(for identifier: NSFileProviderItemIdentifier, request _: NSFileProviderRequest, completionHandler: @Sendable @escaping (NSFileProviderItem?, Error?) -> Void) -> Progress {
logger.debug("Received request for item.", [.item: identifier])

guard let ncAccount else {
logger.debug("Not fetching item because account not set up yet.", [.item: identifier])
completionHandler(nil, NSFileProviderError(.notAuthenticated))
return Progress()
}

guard let dbManager else {
logger.debug("Not fetching item because database is unavailable.", [.item: identifier])
completionHandler(nil, NSFileProviderError(.notAuthenticated))
return Progress()
}

let progress = Progress(totalUnitCount: 1)

Task {
// Same startup race as `fetchContents`, where answering `notAuthenticated` would
// make the framework treat the item as unreachable rather than not-ready-yet.
let ncAccount: Account

do {
ncAccount = try await awaitAccount()
} catch {
logger.error("Not fetching item because account was never set up.", [.item: identifier])
completionHandler(nil, NSFileProviderError(.notAuthenticated))
return
}

guard let dbManager else {
logger.debug("Not fetching item because database is unavailable.", [.item: identifier])
completionHandler(nil, NSFileProviderError(.notAuthenticated))
return
}

if let item = await Item.storedItem(identifier: identifier, account: ncAccount, remoteInterface: ncKit, dbManager: dbManager, log: log), item.metadata.deleted == false {
progress.completedUnitCount = 1
completionHandler(item, nil)
Expand Down Expand Up @@ -189,22 +199,30 @@ import OSLog
return Progress()
}

guard let ncAccount else {
logger.debug("Not fetching contents for item because account not set up yet.", [.item: itemIdentifier])
insertErrorAction(actionId)
completionHandler(nil, nil, NSFileProviderError(.notAuthenticated))
return Progress()
}

guard let dbManager else {
logger.debug("Not fetching contents for item because database is unavailable.", [.item: itemIdentifier])
completionHandler(nil, nil, NSFileProviderError(.cannotSynchronize))
return Progress()
}

let progress = Progress()

Task {
// Wait for the account rather than failing outright: the system starts this process and
// begins requesting content before the main app has handed the account over, and a
// rejected fetch is a download the framework may never ask for again.
let ncAccount: Account

do {
ncAccount = try await awaitAccount()
} catch {
logger.error("Not fetching contents for item because account was never set up.", [.item: itemIdentifier])
insertErrorAction(actionId)
completionHandler(nil, nil, NSFileProviderError(.notAuthenticated))
return
}

guard let dbManager else {
logger.debug("Not fetching contents for item because database is unavailable.", [.item: itemIdentifier])
insertErrorAction(actionId)
completionHandler(nil, nil, NSFileProviderError(.cannotSynchronize))
return
}

guard let item = await Item.storedItem(identifier: itemIdentifier, account: ncAccount, remoteInterface: ncKit, dbManager: dbManager, log: log) else {
logger.error("Not fetching contents for item because item was not found.", [.item: itemIdentifier])
completionHandler(nil, nil, NSError.fileProviderErrorForNonExistentItem(withIdentifier: itemIdentifier))
Expand Down Expand Up @@ -800,11 +818,80 @@ import OSLog
dbManager = databaseManager

ncKit.setup(groupIdentifier: Bundle.main.bundleIdentifier!)
signalAccountReady()
completionHandler?(nil)
signalEnumeratorAfterAccountSetup()
}
}

///
/// The domain account, waiting up to `timeoutNanoseconds` for setup to publish it.
///
/// - Throws: `NSFileProviderError(.notAuthenticated)` when no account arrives within
/// `timeoutNanoseconds`.
///
Comment thread
juliusvaart marked this conversation as resolved.
func awaitAccount(timeoutNanoseconds: UInt64 = 10_000_000_000) async throws -> Account {
if let ncAccount {
return ncAccount
}

let token = UUID()

await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
accountReadyLock.lock()
accountReadyWaiters[token] = continuation
accountReadyLock.unlock()

// The account may have been published between the check above and the enqueue.
if ncAccount != nil {
resumeAccountWaiter(token)
return
}

Task { [weak self] in
try? await Task.sleep(nanoseconds: timeoutNanoseconds)
self?.resumeAccountWaiter(token)
}
}

// Read after the resume, not at the resume site: a timeout that races an account landing a
// moment later should still hand back the account rather than fail the request.
guard let account = ncAccount else {
throw NSFileProviderError(.notAuthenticated)
}

return account
}

///
/// Resume one parked waiter, if it has not been resumed already.
///
private func resumeAccountWaiter(_ token: UUID) {
accountReadyLock.lock()
let continuation = accountReadyWaiters.removeValue(forKey: token)
accountReadyLock.unlock()
continuation?.resume()
}

///
/// Release everything parked in ``awaitAccount(timeoutNanoseconds:)`` once setup has published
/// ``ncAccount``.
///
func signalAccountReady() {
accountReadyLock.lock()
let waiters = accountReadyWaiters
accountReadyWaiters.removeAll()
accountReadyLock.unlock()

if !waiters.isEmpty {
logger.debug("Account is set up; releasing \(waiters.count) waiting request(s).")
}

for continuation in waiters.values {
continuation.resume()
}
}

func updatedSyncStateReporting(oldActions: Set<UUID>) {
actionsLock.lock()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ public extension Item {

directory.downloaded = true
directory.keepDownloaded = parentKeepDownloaded
// A folder we just created is already fully enumerated from the framework's point of
// view, so set `visitedDirectory` to keep future change scans covering it
// (nextcloud/desktop#9688, #10681).
directory.visitedDirectory = true
dbManager.addItemMetadata(directory)

let displayFileActions = await Item.typeHasApplicableContextMenuItems(account: account, remoteInterface: remoteInterface, candidate: directory.contentType)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ public extension Item {
isDownloaded: child.downloaded,
manager: manager
)
} catch let error as NSFileProviderError where error.code == .noSuchItem {
// Expected, because the framework's item store only knows what enumeration
// handed it and the flag written above applies when it first enumerates the item.
logger.debug(
"Framework does not know this descendant yet; its pin applies when the item is first enumerated.",
[.item: child.ocId, .name: child.fileName]
)
} catch {
logger.error(
"Could not signal keep-downloaded change to framework for descendant.",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
// SPDX-License-Identifier: LGPL-3.0-or-later

import FileProvider
import Foundation
@testable import NextcloudFileProviderKit
import Testing

///
/// Coverage for `awaitAccount`, which waits for account setup instead of failing the requests the
/// framework makes before the main app has handed the account over.
///
struct AwaitAccountTests {
private static let account = Account(
user: "testUser", id: "testUserId", serverUrl: "https://mock.nc.com", password: "abcd"
)

private func makeExtension() -> FileProviderExtension {
let domain = NSFileProviderDomain(
identifier: NSFileProviderDomainIdentifier("test-domain-await-account"),
displayName: "Test"
)
return FileProviderExtension(domain: domain)
}

///
/// An account already in place is returned without waiting at all.
///
@Test func returnsImmediatelyWhenAccountIsAlreadySetUp() async throws {
let ext = makeExtension()
ext.ncAccount = Self.account

let account = try await ext.awaitAccount(timeoutNanoseconds: 0)

#expect(account == Self.account)
}

///
/// The waiting case: a request arrives first, the account lands while it is parked, and the
/// request proceeds instead of failing.
///
@Test func waitsForAnAccountThatArrivesLater() async throws {
let ext = makeExtension()

async let awaited = ext.awaitAccount(timeoutNanoseconds: 10_000_000_000)

try await Task.sleep(for: .milliseconds(200))
ext.ncAccount = Self.account
ext.signalAccountReady()

let account = try await awaited

#expect(account == Self.account, "A request parked before setup must proceed once the account lands.")
}

///
/// A domain that never gets an account fails after the timeout rather than hanging forever.
///
@Test func givesUpAfterTheTimeoutWhenNoAccountArrives() async {
let ext = makeExtension()
let timeout = Duration.milliseconds(300)

let started = ContinuousClock().now

await #expect(throws: NSFileProviderError(.notAuthenticated)) {
try await ext.awaitAccount(timeoutNanoseconds: 300_000_000)
}

#expect(ContinuousClock().now - started >= timeout)
}

///
/// Several parked requests are all released by the single setup completion.
///
@Test func releasesEveryParkedWaiter() async throws {
let ext = makeExtension()

async let first = ext.awaitAccount(timeoutNanoseconds: 10_000_000_000)
async let second = ext.awaitAccount(timeoutNanoseconds: 10_000_000_000)
async let third = ext.awaitAccount(timeoutNanoseconds: 10_000_000_000)

try await Task.sleep(for: .milliseconds(200))
ext.ncAccount = Self.account
ext.signalAccountReady()

let results = try await [first, second, third]

#expect(results == [Self.account, Self.account, Self.account])
}
}
Loading
Loading