Files
nextcloud-desktop/shell_integration/MacOSX/NextcloudIntegration/FileProviderExt/FileProviderExtension.swift
2025-12-18 10:29:40 +01:00

540 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 keychain: Keychain
let log: any FileProviderLogging
let logger: FileProviderLogger
///
/// NextcloudKit instance used by this file provider extension object.
///
let ncKit: NextcloudKit
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 = FileManager.default.applicationGroupContainer() else {
logger.fault("Won't start socket client, no container URL available!")
return nil;
}
let socketPath = containerUrl.appendingPathComponent("fps", conformingTo: .archive)
let lineProcessor = FileProviderSocketLineProcessor(delegate: self, log: log)
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
// materialized 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 = FileProviderDomainDefaults(identifier: domain.identifier, log: log)
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
// Set up logging.
self.log = FileProviderLog(fileProviderDomainIdentifier: domain.identifier)
self.logger = FileProviderLogger(category: "FileProviderExtension", log: log)
logger.debug("Initializing with domain identifier: \(domain.identifier.rawValue)")
// Set up NextcloudKit.
self.ncKit = NextcloudKit.shared
#if DEBUG
NKLogFileManager.configure(logLevel: .verbose)
#else
NKLogFileManager.configure(logLevel: .normal)
#endif
logger.info("Current NextcloudKit log file URL: \(NKLogFileManager.shared.currentLogFileURL().absoluteString)")
self.keychain = Keychain(log: log)
super.init()
socketClient?.start()
}
func invalidate() {
logger.debug("File provider extension process is being invalidated.")
}
func insertSyncAction(_ actionId: UUID) {
logger.debug("Inserting synchronization action.", [.item: actionId])
actionsLock.lock()
let oldActions = syncActions
syncActions.insert(actionId)
actionsLock.unlock()
updatedSyncStateReporting(oldActions: oldActions)
}
func insertErrorAction(_ actionId: UUID) {
logger.debug("Inserting error action.", [.item: actionId])
actionsLock.lock()
let oldActions = syncActions
syncActions.remove(actionId)
errorActions.insert(actionId)
actionsLock.unlock()
updatedSyncStateReporting(oldActions: oldActions)
}
func removeSyncAction(_ actionId: UUID) {
logger.debug("Removing synchronization action.", [.item: actionId])
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 {
logger.debug("Received request for item.", [.item: identifier, .request: request])
guard let ncAccount else {
logger.error("Not fetching item because account not set up yet.", [.item: identifier])
completionHandler(nil, NSFileProviderError(.notAuthenticated))
return Progress()
}
guard let dbManager else {
logger.error("Not fetching item because database is unavailable.", [.item: identifier])
completionHandler(nil, NSFileProviderError(.notAuthenticated))
return Progress()
}
let progress = Progress()
Task {
progress.totalUnitCount = 1
if let item = await Item.storedItem(
identifier: identifier,
account: ncAccount,
remoteInterface: ncKit,
dbManager: dbManager,
log: log
) {
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.debug("Received request to fetch contents of item.", [.item: itemIdentifier, .request: request])
guard requestedVersion == nil else {
// TODO: Add proper support for file versioning
logger.error("Can't return contents for a specific version as this is not supported.", [.item: itemIdentifier])
insertErrorAction(actionId)
completionHandler(
nil,
nil,
NSError(domain: NSCocoaErrorDomain, code: NSFeatureUnsupportedError)
)
return Progress()
}
guard let ncAccount else {
logger.error("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.error("Not fetching contents for item because database is unavailable.", [.item: itemIdentifier])
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,
log: log
) else {
logger.error("Not fetching contents for item because item was not found.", [.item: itemIdentifier])
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)
logger.debug("Received request to create item.", [.item: itemTemplate, .name: itemTemplate.filename, .request: request])
guard let ncAccount else {
logger.error(
"""
Not creating item: \(itemTemplate.itemIdentifier.rawValue)
as account not set up yet
"""
)
insertErrorAction(actionId)
completionHandler(itemTemplate, [], false, NSFileProviderError(.notAuthenticated))
return Progress()
}
guard let ignoredFiles else {
logger.error("Not creating item for identifier: \(itemTemplate.itemIdentifier.rawValue) as ignore list not set up yet.")
insertErrorAction(actionId)
completionHandler(itemTemplate, [], false, NSFileProviderError(.notAuthenticated))
return Progress()
}
guard let dbManager else {
logger.error("Not creating item because database is unavailable.", [.item: itemTemplate.itemIdentifier])
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,
log: log
)
if error == nil {
removeSyncAction(actionId)
} else {
// Do not consider the exclusion of a lock file a synchronization error resulting in a misleading status report because exclusion is expected.
// Though, the exclusion error code is only available starting with macOS 13, hence this logic reads a bit more cumbersome.
if #available(macOS 13.0, *) {
if isLockFileName(itemTemplate.filename), let fileProviderError = error as? NSFileProviderError, fileProviderError.code == .excludedFromSync {
removeSyncAction(actionId)
} else {
insertErrorAction(actionId)
signalEnumerator(completionHandler: { _ in })
}
} else {
insertErrorAction(actionId)
signalEnumerator(completionHandler: { _ in })
}
}
logger.debug("Calling item creation completion handler.", [.item: item?.itemIdentifier, .name: item?.filename, .error: error])
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
logger.debug("Received request to modify item.", [.item: item, .request: request])
guard let ncAccount else {
logger.error("Not modifying item because account not set up yet.", [.item: identifier])
insertErrorAction(actionId)
completionHandler(item, [], false, NSFileProviderError(.notAuthenticated))
return Progress()
}
guard let ignoredFiles else {
logger.error("Not modifying item because ignore list not set up yet.", [.item: identifier])
insertErrorAction(actionId)
completionHandler(item, [], false, NSFileProviderError(.notAuthenticated))
return Progress()
}
guard let dbManager else {
logger.error("Not modifying item because the database is unavailable.")
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,
log: log
) else {
logger.error("Not modifying item because it was not found.", [.item: identifier])
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)
}
logger.debug("Calling item modification completion handler.", [.item: item.itemIdentifier, .name: item.filename, .error: error])
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.debug("Received request to delete item.", [.item: identifier, .request: request])
guard let ncAccount else {
logger.error("Not deleting item \(identifier.rawValue), account not set up yet")
insertErrorAction(actionId)
completionHandler(NSFileProviderError(.notAuthenticated))
return Progress()
}
guard let ignoredFiles else {
logger.error("Not deleting \(identifier.rawValue), ignore list not received")
insertErrorAction(actionId)
completionHandler(NSFileProviderError(.notAuthenticated))
return Progress()
}
guard let dbManager else {
logger.error("Not deleting item \(identifier.rawValue), 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,
log: log
) else {
logger.error("Not deleting item because it was not found.", [.item: identifier])
insertErrorAction(actionId)
completionHandler(NSError.fileProviderErrorForNonExistentItem(withIdentifier: identifier))
return
}
logger.debug("Found item for identifier.", [.item: identifier, .name: item.filename])
guard config.trashDeletionEnabled || item.parentItemIdentifier != .trashContainer else {
logger.info("System requested deletion of item in trash, but deleting trash items is disabled. item: \(item.filename)")
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
logger.debug("Calling item deletion completion handler.", [.item: identifier, .name: item.filename, .error: error])
completionHandler(error)
}
return progress
}
func enumerator(for containerItemIdentifier: NSFileProviderItemIdentifier, request: NSFileProviderRequest) throws -> NSFileProviderEnumerator {
logger.debug("System requested enumerator.", [.item: containerItemIdentifier])
guard let ncAccount else {
logger.error("Not providing enumerator for container with identifier \(containerItemIdentifier.rawValue) yet as account not set up")
throw NSFileProviderError(.notAuthenticated)
}
guard let dbManager else {
logger.error("Not providing enumerator for container with identifier \(containerItemIdentifier.rawValue) yet as db manager is unavailable")
throw NSFileProviderError(.cannotSynchronize)
}
return Enumerator(
enumeratedItemIdentifier: containerItemIdentifier,
account: ncAccount,
remoteInterface: ncKit,
dbManager: dbManager,
domain: domain,
log: log
)
}
func materializedItemsDidChange(completionHandler: @escaping () -> Void) {
guard let ncAccount else {
logger.error("Not purging stale local file metadatas, account not set up")
completionHandler()
return
}
guard let dbManager else {
logger.error("Not purging stale local file metadatas. db manager unabilable for domain: \(self.domain.displayName)")
completionHandler()
return
}
guard let fpManager = NSFileProviderManager(for: domain) else {
logger.error("Could not get file provider manager for domain: \(self.domain.displayName)")
completionHandler()
return
}
let materialisedEnumerator = fpManager.enumeratorForMaterializedItems()
let materialisedObserver = MaterializedEnumerationObserver(account: ncAccount, dbManager: dbManager, log: log) { _, _ 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.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)
}
}