mirror of
https://github.com/nextcloud/desktop.git
synced 2026-01-14 02:01:28 +00:00
540 lines
21 KiB
Swift
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)
|
|
}
|
|
}
|