// // LocalFileProvider.swift // FileProvider // // Created by Amir Abbas Mousavian. // Copyright © 2016 Mousavian. Distributed under MIT license. // import Foundation /** This provider class allows interacting with local files placed in user disk. It also allows an easy way to use `NSFileCoordintaing` to coordinate read and write when neccessary. it uses `FileManager` foundation class with some additions like searching and reading a portion of file. */ open class LocalFileProvider: NSObject, FileProvider, FileProviderMonitor, FileProviderSymbolicLink { open class var type: String { return "Local" } open fileprivate(set) var baseURL: URL? open var dispatch_queue: DispatchQueue open var operation_queue: OperationQueue open weak var delegate: FileProviderDelegate? open var credential: URLCredential? /// Underlying `FileManager` object for listing and metadata fetching. open private(set) var fileManager = FileManager() /// Underlying `FileManager` object for operationa like copying, moving, etc. open private(set) var opFileManager = FileManager() fileprivate var fileProviderManagerDelegate: LocalFileProviderManagerDelegate? = nil #if os(macOS) || os(iOS) || os(tvOS) open var undoManager: UndoManager? = nil /** Forces file operations to use `NSFileCoordinating`, should be set `true` if: - Files are on ubiquity (iCloud) container. - Multiple processes are accessing same file, recommended when accessing a shared/public user document in macOS and when using app extensions in iOS/tvOS (shared container). By default it's `true` when using iCloud or shared container (App Group) initializers, otherwise it's `false` to accelerate operations. */ open var isCoorinating: Bool #endif /** Initializes provider for the specified common directory in the requested domains. default values are `directory: .documentDirectory, domainMask: .userDomainMask`. - Parameters: - for: The search path directory. The supported values are described in `FileManager.SearchPathDirectory`. - in: Base locations for directory to search. The value for this parameter is one or more of the constants described in `FileManager.SearchPathDomainMask`. */ public convenience init (for directory: FileManager.SearchPathDirectory = .documentDirectory, in domainMask: FileManager.SearchPathDomainMask = .userDomainMask) { self.init(baseURL: FileManager.default.urls(for: directory, in: domainMask).first!) } #if os(macOS) || os(iOS) || os(tvOS) /** Failable initializer for the specified shared container directory, allows data and files to be shared among app and extensions regarding sandbox requirements. Container ID is same with app group specified in project `Capabilities` tab under `App Group` item. If you don't have enough privilage to access container or the app group imply does't exist, initialing will fail. default values are `directory: .documentDirectory`. - Parameters: - sharedContainerId: Same with `App Group` identifier defined in project settings. - directory: The search path directory. The supported values are described in `FileManager.SearchPathDirectory`. */ public convenience init? (sharedContainerId: String, directory: FileManager.SearchPathDirectory = .documentDirectory) { guard let baseURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: sharedContainerId) else { return nil } var finalBaseURL = baseURL.absoluteURL switch directory { case .documentDirectory: finalBaseURL = baseURL.appendingPathComponent("Documents") case .libraryDirectory: finalBaseURL = baseURL.appendingPathComponent("Library") case .cachesDirectory: finalBaseURL = baseURL.appendingPathComponent("Library/Caches") case .applicationSupportDirectory: finalBaseURL = baseURL.appendingPathComponent("Library/Application support") default: break } self.init(baseURL: finalBaseURL) self.isCoorinating = true try? fileManager.createDirectory(at: finalBaseURL, withIntermediateDirectories: true) } #endif /// Initializes provider for the specified local URL. /// /// - Parameter baseURL: Local URL location for base directory. public init (baseURL: URL) { guard baseURL.isFileURL else { fatalError("Cannot initialize a Local provider from remote URL.") } self.baseURL = URL(fileURLWithPath: baseURL.path, isDirectory: true) self.credential = nil self.isCoorinating = false let queueLabel = "FileProvider.\(Swift.type(of: self).type)" dispatch_queue = DispatchQueue(label: queueLabel, attributes: .concurrent) operation_queue = OperationQueue() operation_queue.name = "\(queueLabel).Operation" super.init() fileProviderManagerDelegate = LocalFileProviderManagerDelegate(provider: self) opFileManager.delegate = fileProviderManagerDelegate } public required convenience init?(coder aDecoder: NSCoder) { guard let baseURL = aDecoder.decodeObject(of: NSURL.self, forKey: "baseURL") as URL? else { if #available(macOS 10.11, iOS 9.0, tvOS 9.0, *) { aDecoder.failWithError(CocoaError(.coderValueNotFound, userInfo: [NSLocalizedDescriptionKey: "Base URL is not set."])) } return nil } self.init(baseURL: baseURL) self.isCoorinating = aDecoder.decodeBool(forKey: "isCoorinating") } deinit { let monitors = self.monitors self.monitors = [] for monitor in monitors { monitor.stop() } } open func encode(with aCoder: NSCoder) { aCoder.encode(self.baseURL, forKey: "baseURL") aCoder.encode(self.isCoorinating, forKey: "isCoorinating") } public static var supportsSecureCoding: Bool { return true } public func copy(with zone: NSZone? = nil) -> Any { let copy = LocalFileProvider(baseURL: self.baseURL!) copy.undoManager = self.undoManager #if os(macOS) || os(iOS) || os(tvOS) copy.isCoorinating = self.isCoorinating #endif copy.delegate = self.delegate copy.fileOperationDelegate = self.fileOperationDelegate return copy } open func contentsOfDirectory(path: String, completionHandler: @escaping (_ contents: [FileObject], _ error: Error?) -> Void) { dispatch_queue.async { do { let contents = try self.fileManager.contentsOfDirectory(at: self.url(of: path), includingPropertiesForKeys: nil, options: .skipsSubdirectoryDescendants) let filesAttributes = contents.compactMap({ (fileURL) -> LocalFileObject? in let path = self.relativePathOf(url: fileURL) return LocalFileObject(fileWithPath: path, relativeTo: self.baseURL) }) completionHandler(filesAttributes, nil) } catch { completionHandler([], error) } } } open func attributesOfItem(path: String, completionHandler: @escaping (_ attributes: FileObject?, _ error: Error?) -> Void) { dispatch_queue.async { completionHandler(LocalFileObject(fileWithPath: path, relativeTo: self.baseURL), nil) } } public func storageProperties(completionHandler: @escaping (_ volumeInfo: VolumeObject?) -> Void) { dispatch_queue.async { var keys: Set = [.volumeTotalCapacityKey, .volumeAvailableCapacityKey, .volumeURLKey, .volumeNameKey, .volumeIsReadOnlyKey, .volumeCreationDateKey] if #available(iOS 10.0, macOS 10.12, tvOS 10.0, *) { keys.insert(.isEncryptedKey) } let values: URLResourceValues? = self.baseURL.flatMap { try? $0.resourceValues(forKeys: keys) } completionHandler(values.flatMap({ VolumeObject(allValues: $0.allValues) })) } } @discardableResult open func searchFiles(path: String, recursive: Bool, query: NSPredicate, foundItemHandler: ((FileObject) -> Void)?, completionHandler: @escaping (_ files: [FileObject], _ error: Error?) -> Void) -> Progress? { let progress = Progress(totalUnitCount: -1) progress.setUserInfoObject(self.url(of: path), forKey: .fileURLKey) dispatch_queue.async { progress.setUserInfoObject(Date(), forKey: .startingTimeKey) let iterator = self.fileManager.enumerator(at: self.url(of: path), includingPropertiesForKeys: nil, options: recursive ? [] : [.skipsSubdirectoryDescendants, .skipsPackageDescendants]) { (url, e) -> Bool in completionHandler([], e) return true } var result = [LocalFileObject]() while let fileURL = iterator?.nextObject() as? URL { if progress.isCancelled { break } let path = self.relativePathOf(url: fileURL) if let fileObject = LocalFileObject(fileWithPath: path, relativeTo: self.baseURL), query.evaluate(with: fileObject.mapPredicate()) { result.append(fileObject) progress.completedUnitCount = Int64(result.count) foundItemHandler?(fileObject) } } completionHandler(result, nil) } return progress } open func isReachable(completionHandler: @escaping (_ success: Bool, _ error: Error?) -> Void) { dispatch_queue.async { do { let isReachable = try self.baseURL!.checkResourceIsReachable() completionHandler(isReachable, nil) } catch { completionHandler(false, error) } } } open func relativePathOf(url: URL) -> String { // check if url derieved from current base url let relativePath = url.relativePath if !relativePath.isEmpty, url.baseURL == self.baseURL { return (relativePath.removingPercentEncoding ?? relativePath).replacingOccurrences(of: "/", with: "", options: .anchored) } guard let baseURL = self.baseURL?.standardizedFileURL else { return url.absoluteString } let standardPath = url.absoluteString.replacingOccurrences(of: "file:///private/var/", with: "file:///var/", options: .anchored) let standardBase = baseURL.absoluteString.replacingOccurrences(of: "file:///private/var/", with: "file:///var/", options: .anchored) let standardRelativePath = standardPath.replacingOccurrences(of: standardBase, with: "/").replacingOccurrences(of: "/", with: "", options: .anchored) return standardRelativePath.removingPercentEncoding ?? standardRelativePath } open weak var fileOperationDelegate : FileOperationDelegate? @discardableResult open func create(folder folderName: String, at atPath: String, completionHandler: SimpleCompletionHandler) -> Progress? { let operation = FileOperationType.create(path: atPath.appendingPathComponent(folderName) + "/") return self.doOperation(operation, completionHandler: completionHandler) } @discardableResult open func moveItem(path: String, to toPath: String, overwrite: Bool = false, completionHandler: SimpleCompletionHandler) -> Progress? { let operation = FileOperationType.move(source: path, destination: toPath) return self.doOperation(operation, overwrite: overwrite, completionHandler: completionHandler) } @discardableResult open func copyItem(path: String, to toPath: String, overwrite: Bool = false, completionHandler: SimpleCompletionHandler) -> Progress? { let operation = FileOperationType.copy(source: path, destination: toPath) return self.doOperation(operation, overwrite: overwrite, completionHandler: completionHandler) } @discardableResult open func removeItem(path: String, completionHandler: SimpleCompletionHandler) -> Progress? { let operation = FileOperationType.remove(path: path) return self.doOperation(operation, completionHandler: completionHandler) } @discardableResult open func copyItem(localFile: URL, to toPath: String, overwrite: Bool, completionHandler: SimpleCompletionHandler) -> Progress? { let operation = FileOperationType.copy(source: localFile.absoluteString, destination: toPath) return self.doOperation(operation, overwrite: overwrite, forUploading: true, completionHandler: completionHandler) } @discardableResult open func copyItem(path: String, toLocalURL: URL, completionHandler: SimpleCompletionHandler) -> Progress? { let operation = FileOperationType.copy(source: path, destination: toLocalURL.absoluteString) return self.doOperation(operation, completionHandler: completionHandler) } @discardableResult fileprivate func doOperation(_ operation: FileOperationType, data: Data? = nil, overwrite: Bool = true, atomically: Bool = false, forUploading: Bool = false, completionHandler: SimpleCompletionHandler) -> Progress? { let progress = Progress(totalUnitCount: -1) progress.setUserInfoObject(operation, forKey: .fileProvderOperationTypeKey) progress.kind = .file progress.isCancellable = false progress.setUserInfoObject(Progress.FileOperationKind.receiving, forKey: .fileOperationKindKey) func urlofpath(path: String) -> URL { if path.hasPrefix("file://") { let removedSchemePath = path.replacingOccurrences(of: "file://", with: "", options: .anchored) let pDecodedPath = removedSchemePath.removingPercentEncoding ?? removedSchemePath return URL(fileURLWithPath: pDecodedPath) } else { return self.url(of: path) } } let sourcePath = operation.source let destPath = operation.destination let source: URL = urlofpath(path: sourcePath) progress.setUserInfoObject(source, forKey: .fileURLKey) let dest = destPath.map(urlofpath(path:)) if !overwrite, let dest = dest, /* fileExists */ ((try? dest.checkResourceIsReachable()) ?? false) || ((try? dest.checkPromisedItemIsReachable()) ?? false) { let e = CocoaError(.fileWriteFileExists, path: destPath!) dispatch_queue.async { completionHandler?(e) } self.delegateNotify(operation, error: e) return nil } #if os(macOS) || os(iOS) || os(tvOS) var successfulSecurityScopedResourceAccess = false #endif let operationHandler: (URL, URL?) -> Void = { source, dest in do { progress.setUserInfoObject(Date(), forKey: .startingTimeKey) switch operation { case .create: if sourcePath.hasSuffix("/") { progress.totalUnitCount = 1 try self.opFileManager.createDirectory(at: source, withIntermediateDirectories: true, attributes: [:]) } else { progress.totalUnitCount = Int64(data?.count ?? -1) try data?.write(to: source, options: .atomic) } case .modify: progress.totalUnitCount = Int64(data?.count ?? -1) try data?.write(to: source, options: atomically ? [.atomic] : []) case .copy: guard let dest = dest else { return } progress.setUserInfoObject(Progress.FileOperationKind.copying, forKey: .fileOperationKindKey) progress.totalUnitCount = abs(source.fileSize) try self.opFileManager.copyItem(at: source, to: dest) case .move: progress.setUserInfoObject(Progress.FileOperationKind.copying, forKey: .fileOperationKindKey) guard let dest = dest else { return } progress.totalUnitCount = abs(source.fileSize) try self.opFileManager.moveItem(at: source, to: dest) case.remove: progress.totalUnitCount = abs(source.fileSize) try self.opFileManager.removeItem(at: source) default: return } #if os(macOS) || os(iOS) || os(tvOS) if successfulSecurityScopedResourceAccess { source.stopAccessingSecurityScopedResource() } #endif #if os(macOS) || os(iOS) || os(tvOS) self._registerUndo(operation) #endif progress.completedUnitCount = progress.totalUnitCount self.dispatch_queue.async { completionHandler?(nil) } self.delegateNotify(operation) } catch { #if os(macOS) || os(iOS) || os(tvOS) if successfulSecurityScopedResourceAccess { source.stopAccessingSecurityScopedResource() } #endif progress.cancel() self.dispatch_queue.async { completionHandler?(error) } self.delegateNotify(operation, error: error) } } #if os(macOS) || os(iOS) || os(tvOS) if isCoorinating { successfulSecurityScopedResourceAccess = source.startAccessingSecurityScopedResource() var intents = [NSFileAccessIntent]() switch operation { case .create, .modify: intents.append(NSFileAccessIntent.writingIntent(with: source, options: .forReplacing)) case .copy: guard let dest = dest else { return nil } intents.append(NSFileAccessIntent.readingIntent(with: source, options: forUploading ? .forUploading : .withoutChanges)) intents.append(NSFileAccessIntent.writingIntent(with: dest, options: .forReplacing)) case .move: guard let dest = dest else { return nil } intents.append(NSFileAccessIntent.writingIntent(with: source, options: .forMoving)) intents.append(NSFileAccessIntent.writingIntent(with: dest, options: .forReplacing)) case .remove: intents.append(NSFileAccessIntent.writingIntent(with: source, options: .forDeleting)) default: return nil } self.coordinated(intents: intents, moving: true, operationHandler: operationHandler, errorHandler: { error in self.dispatch_queue.async { completionHandler?(error) } self.delegateNotify(operation, error: error) }) } else { operation_queue.addOperation { operationHandler(source, dest) } } #else operation_queue.addOperation { operationHandler(source, dest) } #endif return progress } @discardableResult open func contents(path: String, completionHandler: @escaping ((_ contents: Data?, _ error: Error?) -> Void)) -> Progress? { let operation = FileOperationType.fetch(path: path) let url = self.url(of: path) let progress = Progress(totalUnitCount: url.fileSize) progress.setUserInfoObject(operation, forKey: .fileProvderOperationTypeKey) progress.kind = .file progress.isCancellable = false progress.setUserInfoObject(Progress.FileOperationKind.receiving, forKey: .fileOperationKindKey) progress.setUserInfoObject(url, forKey: .fileURLKey) let operationHandler: (URL) -> Void = { url in progress.setUserInfoObject(Date(), forKey: .startingTimeKey) do { let data = try Data(contentsOf: url) progress.completedUnitCount = progress.totalUnitCount self.dispatch_queue.async { completionHandler(data, nil) } self.delegateNotify(operation) } catch { progress.cancel() self.dispatch_queue.async { completionHandler(nil, error) } self.delegateNotify(operation, error: error) } } #if os(macOS) || os(iOS) || os(tvOS) if isCoorinating { let intent = NSFileAccessIntent.readingIntent(with: url, options: .withoutChanges) coordinated(intents: [intent], operationHandler: operationHandler, errorHandler: { error in self.dispatch_queue.async { completionHandler(nil, error) } self.delegateNotify(operation, error: error) }) } else { dispatch_queue.async { operationHandler(url) } } #else dispatch_queue.async { operationHandler(url) } #endif return progress } @discardableResult open func contents(path: String, offset: Int64, length: Int, completionHandler: @escaping (_ contents: Data?, _ error: Error?) -> Void) -> Progress? { if length == 0 || offset < 0 { dispatch_queue.async { completionHandler(Data(), nil) } return nil } if offset == 0 && length < 0 { return self.contents(path: path, completionHandler: completionHandler) } let operation = FileOperationType.fetch(path: path) let url = self.url(of: path) let progress = Progress(totalUnitCount: -1) progress.setUserInfoObject(operation, forKey: .fileProvderOperationTypeKey) progress.kind = .file progress.isCancellable = false progress.setUserInfoObject(url, forKey: .fileURLKey) progress.setUserInfoObject(Progress.FileOperationKind.receiving, forKey: .fileOperationKindKey) let operationHandler: (URL) -> Void = { url in do { guard let handle = FileHandle(forReadingAtPath: url.path) else { throw CocoaError(.fileNoSuchFile, path: path) } defer { handle.closeFile() } let size = LocalFileObject(fileWithURL: url)?.size ?? -1 progress.totalUnitCount = size guard size > offset else { progress.cancel() throw CocoaError(.fileReadTooLarge, path: path) } progress.setUserInfoObject(Date(), forKey: .startingTimeKey) handle.seek(toFileOffset: UInt64(offset)) guard Int64(handle.offsetInFile) == offset else { progress.cancel() throw CocoaError(.fileReadTooLarge, path: path) } let data = handle.readData(ofLength: length) progress.completedUnitCount = progress.totalUnitCount self.dispatch_queue.async { completionHandler(data, nil) self.delegateNotify(operation) } } catch { self.dispatch_queue.async { completionHandler(nil, error) self.delegateNotify(operation, error: error) } } } #if os(macOS) || os(iOS) || os(tvOS) if isCoorinating { let intent = NSFileAccessIntent.readingIntent(with: url, options: .withoutChanges) coordinated(intents: [intent], operationHandler: operationHandler, errorHandler: { error in completionHandler(nil, error) self.delegateNotify(operation, error: error) }) } else { dispatch_queue.async { operationHandler(url) } } #else dispatch_queue.async { operationHandler(url) } #endif return progress } @discardableResult open func writeContents(path: String, contents data: Data?, atomically: Bool, overwrite: Bool, completionHandler: SimpleCompletionHandler) -> Progress? { let fileExists = ((try? self.url(of: path).checkResourceIsReachable()) ?? false) || ((try? self.url(of: path).checkPromisedItemIsReachable()) ?? false) if !overwrite && fileExists { let e = CocoaError(.fileWriteFileExists, path: path) dispatch_queue.async { completionHandler?(e) } self.delegateNotify(.modify(path: path), error: e) return nil } let operation: FileOperationType = fileExists ? .modify(path: path) : .create(path: path) return self.doOperation(operation, data: data ?? Data(), atomically: atomically, completionHandler: completionHandler) } fileprivate var monitors = [LocalFileMonitor]() open func registerNotifcation(path: String, eventHandler: @escaping (() -> Void)) { self.unregisterNotifcation(path: path) let url = self.url(of: path) if let monitor = LocalFileMonitor(url: url, handler: eventHandler) { monitor.start() monitors.append(monitor) } } open func unregisterNotifcation(path: String) { var removedMonitor: LocalFileMonitor? for (i, monitor) in monitors.enumerated() { if self.relativePathOf(url: monitor.url) == path { removedMonitor = monitors.remove(at: i) break } } removedMonitor?.stop() } open func isRegisteredForNotification(path: String) -> Bool { return monitors.map( { self.relativePathOf(url: $0.url) } ).contains(path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))) } open func create(symbolicLink path: String, withDestinationPath destPath: String, completionHandler: SimpleCompletionHandler) { operation_queue.addOperation { let operation = FileOperationType.link(link: path, target: destPath) do { let url = self.url(of: path) let destURL = self.url(of: destPath) let homePath = NSHomeDirectory() if destURL.path.hasPrefix(homePath) { let canonicalHomePath = "/" + homePath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let destRelativePath = destURL.path.replacingOccurrences(of: canonicalHomePath, with: "~", options: .anchored) try self.opFileManager.createSymbolicLink(atPath: url.path, withDestinationPath: destRelativePath) } else { try self.opFileManager.createSymbolicLink(at: url, withDestinationURL: destURL) } completionHandler?(nil) self.delegateNotify(operation) } catch { completionHandler?(error) self.delegateNotify(operation, error: error) } } } open func destination(ofSymbolicLink path: String, completionHandler: @escaping (_ file: FileObject?, _ error: Error?) -> Void) { dispatch_queue.async { do { let destPath = try self.opFileManager.destinationOfSymbolicLink(atPath: self.url(of: path).path) let absoluteDestPath = (destPath as NSString).expandingTildeInPath let file = LocalFileObject(fileWithPath: absoluteDestPath, relativeTo: self.baseURL) completionHandler(file, nil) } catch { completionHandler(nil, error) } } } } #if os(macOS) || os(iOS) || os(tvOS) extension LocalFileProvider: FileProvideUndoable { } internal extension LocalFileProvider { func coordinated(intents: [NSFileAccessIntent], operationHandler: @escaping (_ url: URL) -> Void, errorHandler: ((_ error: Error) -> Void)? = nil) { let coordinator = NSFileCoordinator(filePresenter: nil) coordinator.coordinate(with: intents, queue: operation_queue) { (error) in if let error = error { errorHandler?(error) return } operationHandler(intents.first!.url) } } func coordinated(intents: [NSFileAccessIntent], moving: Bool = false, operationHandler: @escaping (_ sourceUrl: URL, _ destURL: URL?) -> Void, errorHandler: ((_ error: Error) -> Void)? = nil) { let coordinator = NSFileCoordinator(filePresenter: nil) coordinator.coordinate(with: intents, queue: operation_queue) { (error) in if let error = error { errorHandler?(error) return } guard let newSource: URL = intents.first?.url else { return } let newDest: URL? = intents.dropFirst().first?.url if moving, let newDest = newDest { coordinator.item(at: newSource, willMoveTo: newDest) } operationHandler(newSource, newDest) if moving, let newDest = newDest { coordinator.item(at: newSource, didMoveTo: newDest) } } } } #endif