nextcloud-desktop/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension.swift
Jyrki Gadinger 9711bbfdee fix(FileProviderExt): do not report syncing state when trying to delete trash items
Trying to delete items from the trash with the "Allow deletion of items
in Trash" setting unticked would previously result in the sync state
being stuck in the "Syncing" state.

Signed-off-by: Jyrki Gadinger <nilsding@nilsding.org>
2025-08-21 17:37:49 +02:00

585 lines
21 KiB
Swift

/*
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import FileProvider
import NCDesktopClientSocketKit
import NextcloudKit
import NextcloudFileProviderKit
import OSLog
@objc class FileProviderExtension: NSObject, NSFileProviderReplicatedExtension {
let domain: NSFileProviderDomain
let ncKit = NextcloudKit.shared
let appGroupIdentifier = Bundle.main.object(forInfoDictionaryKey: "SocketApiPrefix") as? String
var ncAccount: Account?
var dbManager: FilesDatabaseManager?
var changeObserver: RemoteChangeObserver?
var ignoredFiles: IgnoredFilesMatcher?
lazy var ncKitBackground = NKBackground(nkCommonInstance: ncKit.nkCommonInstance)
lazy var socketClient: LocalSocketClient? = {
guard let containerUrl = pathForAppGroupContainer() else {
Logger.fileProviderExtension.critical("Won't start socket client, no container url")
return nil;
}
let socketPath = containerUrl.appendingPathComponent(
".fileprovidersocket", conformingTo: .archive)
let lineProcessor = FileProviderSocketLineProcessor(delegate: self)
return LocalSocketClient(socketPath: socketPath.path, lineProcessor: lineProcessor)
}()
var syncActions = Set<UUID>()
var errorActions = Set<UUID>()
var actionsLock = NSLock()
// 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.
// We can instead do a fast enumeration where we only scan folders as the user navigates through
// them, thereby avoiding this issue; the trade-off is that we will be unable to detect
// materialised file moves to unexplored folders, therefore deleting the item when we could have
// just moved it instead.
//
// Since it's not desirable to cancel a long recursive enumeration half-way through, we do the
// fast enumeration by default. We prompt the user on the client side to run a proper, full
// enumeration if they want for safety.
lazy var config = FileProviderConfig(domainIdentifier: domain.identifier)
required init(domain: NSFileProviderDomain) {
// The containing application must create a domain using
// `NSFileProviderManager.add(_:, completionHandler:)`. The system will then launch the
// application extension process, call `FileProviderExtension.init(domain:)` to instantiate
// the extension for that domain, and call methods on the instance.
self.domain = domain
super.init()
socketClient?.start()
}
func invalidate() {
// TODO: cleanup any resources
Logger.fileProviderExtension.debug(
"Extension for domain \(self.domain.displayName, privacy: .public) is being torn down"
)
}
func insertSyncAction(_ actionId: UUID) {
actionsLock.lock()
let oldActions = syncActions
syncActions.insert(actionId)
actionsLock.unlock()
updatedSyncStateReporting(oldActions: oldActions)
}
func insertErrorAction(_ actionId: UUID) {
actionsLock.lock()
let oldActions = syncActions
syncActions.remove(actionId)
errorActions.insert(actionId)
actionsLock.unlock()
updatedSyncStateReporting(oldActions: oldActions)
}
func removeSyncAction(_ actionId: UUID) {
actionsLock.lock()
let oldActions = syncActions
syncActions.remove(actionId)
errorActions.remove(actionId)
actionsLock.unlock()
updatedSyncStateReporting(oldActions: oldActions)
}
// MARK: - NSFileProviderReplicatedExtension protocol methods
func item(
for identifier: NSFileProviderItemIdentifier,
request _: NSFileProviderRequest,
completionHandler: @escaping (NSFileProviderItem?, Error?) -> Void
) -> Progress {
guard let ncAccount else {
Logger.fileProviderExtension.error(
"""
Not fetching item for identifier: \(identifier.rawValue, privacy: .public)
as account not set up yet.
"""
)
completionHandler(nil, NSFileProviderError(.notAuthenticated))
return Progress()
}
guard let dbManager else {
Logger.fileProviderExtension.error(
"""
Not fetching item for identifier: \(identifier.rawValue, privacy: .public)
as database is unreachable
"""
)
completionHandler(nil, NSFileProviderError(.cannotSynchronize))
return Progress()
}
let progress = Progress()
Task {
progress.totalUnitCount = 1
if let item = await Item.storedItem(
identifier: identifier,
account: ncAccount,
remoteInterface: ncKit,
dbManager: dbManager
) {
progress.completedUnitCount = 1
completionHandler(item, nil)
} else {
completionHandler(
nil, NSError.fileProviderErrorForNonExistentItem(withIdentifier: identifier)
)
}
}
return progress
}
func fetchContents(
for itemIdentifier: NSFileProviderItemIdentifier,
version requestedVersion: NSFileProviderItemVersion?,
request: NSFileProviderRequest,
completionHandler: @escaping (URL?, NSFileProviderItem?, Error?) -> Void
) -> Progress {
let actionId = UUID()
insertSyncAction(actionId)
Logger.fileProviderExtension.debug(
"Received request to fetch contents of item with identifier: \(itemIdentifier.rawValue, privacy: .public)"
)
guard requestedVersion == nil else {
// TODO: Add proper support for file versioning
Logger.fileProviderExtension.error(
"Can't return contents for a specific version as this is not supported."
)
insertErrorAction(actionId)
completionHandler(
nil,
nil,
NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError)
)
return Progress()
}
guard let ncAccount else {
Logger.fileProviderExtension.error(
"""
Not fetching contents for item: \(itemIdentifier.rawValue, privacy: .public)
as account not set up yet.
"""
)
insertErrorAction(actionId)
completionHandler(nil, nil, NSFileProviderError(.notAuthenticated))
return Progress()
}
guard let dbManager else {
Logger.fileProviderExtension.error(
"""
Not fetching contents for item: \(itemIdentifier.rawValue, privacy: .public)
as database is unreachable
"""
)
completionHandler(nil, nil, NSFileProviderError(.cannotSynchronize))
return Progress()
}
let progress = Progress()
Task {
guard let item = await Item.storedItem(
identifier: itemIdentifier,
account: ncAccount,
remoteInterface: ncKit,
dbManager: dbManager
) else {
Logger.fileProviderExtension.error(
"""
Not fetching contents for item: \(itemIdentifier.rawValue, privacy: .public)
as item not found.
"""
)
completionHandler(
nil,
nil,
NSError.fileProviderErrorForNonExistentItem(withIdentifier: itemIdentifier)
)
insertErrorAction(actionId)
return
}
let (localUrl, updatedItem, error) = await item.fetchContents(
domain: self.domain, progress: progress, dbManager: dbManager
)
removeSyncAction(actionId)
completionHandler(localUrl, updatedItem, error)
}
return progress
}
func createItem(
basedOn itemTemplate: NSFileProviderItem,
fields: NSFileProviderItemFields,
contents url: URL?,
options: NSFileProviderCreateItemOptions = [],
request: NSFileProviderRequest,
completionHandler: @escaping (
NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?
) -> Void
) -> Progress {
let actionId = UUID()
insertSyncAction(actionId)
let tempId = itemTemplate.itemIdentifier.rawValue
Logger.fileProviderExtension.debug(
"""
Received create item request for item with identifier: \(tempId, privacy: .public)
and filename: \(itemTemplate.filename, privacy: .public)
"""
)
guard let ncAccount else {
Logger.fileProviderExtension.error(
"""
Not creating item: \(itemTemplate.itemIdentifier.rawValue, privacy: .public)
as account not set up yet
"""
)
insertErrorAction(actionId)
completionHandler(itemTemplate, [], false, NSFileProviderError(.notAuthenticated))
return Progress()
}
guard let ignoredFiles else {
Logger.fileProviderExtension.error(
"""
Not creating item for identifier:
\(itemTemplate.itemIdentifier.rawValue, privacy: .public)
as ignore list not set up yet.
"""
)
insertErrorAction(actionId)
completionHandler(itemTemplate, [], false, NSFileProviderError(.notAuthenticated))
return Progress()
}
guard let dbManager else {
Logger.fileProviderExtension.error(
"""
Not creating item: \(itemTemplate.itemIdentifier.rawValue, privacy: .public)
as database is unreachable
"""
)
insertErrorAction(actionId)
completionHandler(itemTemplate, [], false, NSFileProviderError(.cannotSynchronize))
return Progress()
}
let progress = Progress()
Task {
let (item, error) = await Item.create(
basedOn: itemTemplate,
fields: fields,
contents: url,
request: request,
domain: self.domain,
account: ncAccount,
remoteInterface: ncKit,
ignoredFiles: ignoredFiles,
progress: progress,
dbManager: dbManager
)
if error != nil {
insertErrorAction(actionId)
signalEnumerator(completionHandler: { _ in })
} else {
removeSyncAction(actionId)
}
completionHandler(
item ?? itemTemplate,
NSFileProviderItemFields(),
false,
error
)
}
return progress
}
func modifyItem(
_ item: NSFileProviderItem,
baseVersion: NSFileProviderItemVersion,
changedFields: NSFileProviderItemFields,
contents newContents: URL?,
options: NSFileProviderModifyItemOptions = [],
request: NSFileProviderRequest,
completionHandler: @escaping (
NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?
) -> Void
) -> Progress {
// An item was modified on disk, process the item's modification
// TODO: Handle finder things like tags, other possible item changed fields
let actionId = UUID()
insertSyncAction(actionId)
let identifier = item.itemIdentifier
let ocId = identifier.rawValue
Logger.fileProviderExtension.debug(
"""
Received modify item request for item with identifier: \(ocId, privacy: .public)
and filename: \(item.filename, privacy: .public)
"""
)
guard let ncAccount else {
Logger.fileProviderExtension.error(
"Not modifying item: \(ocId, privacy: .public) as account not set up yet."
)
insertErrorAction(actionId)
completionHandler(item, [], false, NSFileProviderError(.notAuthenticated))
return Progress()
}
guard let ignoredFiles else {
Logger.fileProviderExtension.error(
"Not modifying item: \(ocId, privacy: .public) as ignore list not set up yet."
)
insertErrorAction(actionId)
completionHandler(item, [], false, NSFileProviderError(.notAuthenticated))
return Progress()
}
guard let dbManager else {
Logger.fileProviderExtension.error(
"""
Not modifying item: \(ocId, privacy: .public)
with filename: \(item.filename, privacy: .public)
as database is unreachable
"""
)
insertErrorAction(actionId)
completionHandler(item, [], false, NSFileProviderError(.cannotSynchronize))
return Progress()
}
let progress = Progress()
Task {
guard let existingItem = await Item.storedItem(
identifier: identifier,
account: ncAccount,
remoteInterface: ncKit,
dbManager: dbManager
) else {
Logger.fileProviderExtension.error(
"Not modifying item: \(ocId, privacy: .public) as item not found."
)
insertErrorAction(actionId)
completionHandler(
item,
[],
false,
NSError.fileProviderErrorForNonExistentItem(withIdentifier: item.itemIdentifier)
)
return
}
let (modifiedItem, error) = await existingItem.modify(
itemTarget: item,
baseVersion: baseVersion,
changedFields: changedFields,
contents: newContents,
options: options,
request: request,
ignoredFiles: ignoredFiles,
domain: domain,
progress: progress,
dbManager: dbManager
)
if error != nil {
insertErrorAction(actionId)
signalEnumerator(completionHandler: { _ in })
} else {
removeSyncAction(actionId)
}
completionHandler(modifiedItem ?? item, [], false, error)
}
return progress
}
func deleteItem(
identifier: NSFileProviderItemIdentifier,
baseVersion _: NSFileProviderItemVersion,
options _: NSFileProviderDeleteItemOptions = [],
request _: NSFileProviderRequest,
completionHandler: @escaping (Error?) -> Void
) -> Progress {
let actionId = UUID()
insertSyncAction(actionId)
Logger.fileProviderExtension.debug(
"Received delete request for item: \(identifier.rawValue, privacy: .public)"
)
guard let ncAccount else {
Logger.fileProviderExtension.error(
"Not deleting item \(identifier.rawValue, privacy: .public), account not set up yet"
)
insertErrorAction(actionId)
completionHandler(NSFileProviderError(.notAuthenticated))
return Progress()
}
guard let ignoredFiles else {
Logger.fileProviderExtension.error(
"Not deleting \(identifier.rawValue, privacy: .public), ignore list not received"
)
insertErrorAction(actionId)
completionHandler(NSFileProviderError(.notAuthenticated))
return Progress()
}
guard let dbManager else {
Logger.fileProviderExtension.error(
"Not deleting item \(identifier.rawValue, privacy: .public), db manager unavailable"
)
insertErrorAction(actionId)
completionHandler(NSFileProviderError(.cannotSynchronize))
return Progress()
}
let progress = Progress(totalUnitCount: 1)
Task {
guard let item = await Item.storedItem(
identifier: identifier,
account: ncAccount,
remoteInterface: ncKit,
dbManager: dbManager
) else {
Logger.fileProviderExtension.error(
"Not deleting item \(identifier.rawValue, privacy: .public), item not found"
)
insertErrorAction(actionId)
completionHandler(
NSError.fileProviderErrorForNonExistentItem(withIdentifier: identifier)
)
return
}
guard config.trashDeletionEnabled || item.parentItemIdentifier != .trashContainer else {
Logger.fileProviderExtension.warning(
"""
System requested deletion of item in trash, but deleting trash items is disabled.
item: \(item.filename, privacy: .public)
"""
)
removeSyncAction(actionId)
completionHandler(NSError.fileProviderErrorForRejectedDeletion(of: item))
return
}
let error = await item.delete(
domain: domain, ignoredFiles: ignoredFiles, dbManager: dbManager
)
if error != nil {
insertErrorAction(actionId)
signalEnumerator(completionHandler: { _ in })
} else {
removeSyncAction(actionId)
}
progress.completedUnitCount = 1
completionHandler(error)
}
return progress
}
func enumerator(
for containerItemIdentifier: NSFileProviderItemIdentifier, request _: NSFileProviderRequest
) throws -> NSFileProviderEnumerator {
guard let ncAccount else {
Logger.fileProviderExtension.error(
"""
Not providing enumerator for container
with identifier \(containerItemIdentifier.rawValue, privacy: .public) yet
as account not set up
"""
)
throw NSFileProviderError(.notAuthenticated)
}
guard let dbManager else {
Logger.fileProviderExtension.error(
"""
Not providing enumerator for container
with identifier \(containerItemIdentifier.rawValue, privacy: .public) yet
as db manager is unavailable
"""
)
throw NSFileProviderError(.cannotSynchronize)
}
return Enumerator(
enumeratedItemIdentifier: containerItemIdentifier,
account: ncAccount,
remoteInterface: ncKit,
dbManager: dbManager,
domain: domain
)
}
func materializedItemsDidChange(completionHandler: @escaping () -> Void) {
guard let ncAccount else {
Logger.fileProviderExtension.error(
"Not purging stale local file metadatas, account not set up")
completionHandler()
return
}
guard let dbManager else {
Logger.fileProviderExtension.error(
"""
Not purging stale local file metadatas.
db manager unabilable for domain: \(self.domain.displayName, privacy: .public)
"""
)
completionHandler()
return
}
guard let fpManager = NSFileProviderManager(for: domain) else {
Logger.fileProviderExtension.error(
"Could not get file provider manager for domain: \(self.domain.displayName, privacy: .public)"
)
completionHandler()
return
}
let materialisedEnumerator = fpManager.enumeratorForMaterializedItems()
let materialisedObserver = MaterialisedEnumerationObserver(
ncKitAccount: ncAccount.ncKitAccount, dbManager: dbManager
) { _, _ in
completionHandler()
}
let startingPage = NSFileProviderPage(NSFileProviderPage.initialPageSortedByName as Data)
materialisedEnumerator.enumerateItems(for: materialisedObserver, startingAt: startingPage)
}
// MARK: - Helper functions
func signalEnumerator(completionHandler: @escaping (_ error: Error?) -> Void) {
guard let fpManager = NSFileProviderManager(for: domain) else {
Logger.fileProviderExtension.error(
"Could not get file provider manager for domain, could not signal enumerator. This might lead to future conflicts."
)
return
}
fpManager.signalEnumerator(for: .workingSet, completionHandler: completionHandler)
}
}