import Foundation import AppKit protocol SecurityBookmarkErrorDelegate: AnyObject { func securityBookmark(error: String) } struct SecurityBookmark { enum OverwriteBehaviour { case skip case write case writeIfChanged case fail } let url: URL let isStale: Bool private let encoder = JSONEncoder() private let decoder = JSONDecoder() private let fm = FileManager.default weak var delegate: SecurityBookmarkErrorDelegate? init(url: URL, isStale: Bool) { self.url = url self.isStale = isStale self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys] } // MARK: Write func openFinderWindow(relativePath: String) { with(relativePath: relativePath) { path in NSWorkspace.shared.activateFileViewerSelecting([path]) } } func fullPath(to relativePath: String) -> URL { return url.appending(path: relativePath.withLeadingSlashRemoved, directoryHint: .notDirectory) } /** Write the data of an encodable value to a relative path in the content folder, or delete the file if nil is passed. */ func encode(_ value: T?, to relativePath: String) -> Bool where T: Encodable { guard let value else { return deleteFile(at: relativePath) } return encode(value, to: relativePath) } /** Write the data of an encodable value to a relative path in the content folder */ func encode(_ value: T, to relativePath: String) -> Bool where T: Encodable { let data: Data do { data = try encoder.encode(value) } catch { delegate?.securityBookmark(error: "Failed to encode \(value): \(error)") return false } return write(data, to: relativePath) } /** Write text to a file at the relative path. */ func write(_ content: String, to relativePath: String, createParentFolder: Bool = true, ifFileExists overwrite: OverwriteBehaviour = .writeIfChanged) -> Bool { guard let data = content.data(using: .utf8) else { delegate?.securityBookmark(error: "Failed to encode content to write to \(relativePath)") return false } return write(data, to: relativePath, createParentFolder: createParentFolder, ifFileExists: overwrite) } func write(_ data: Data, to relativePath: String, createParentFolder: Bool = true, ifFileExists overwrite: OverwriteBehaviour = .writeIfChanged) -> Bool { perform { url in let file = fullPath(to: relativePath) if exists(file) { switch overwrite { case .fail: delegate?.securityBookmark(error: "Failed to write \(relativePath): File exists") return false case .skip: return true case .write: break case .writeIfChanged: if let existingData = try? Data(contentsOf: file), existingData == data { return true } } } do { try createParentIfNeeded(of: file) try data.write(to: file) } catch { delegate?.securityBookmark(error: "Failed to write \(relativePath): \(error)") return false } return true } } func create(folder: String) -> Bool { with(relativePath: folder, perform: create) } // MARK: Read func size(of relativePath: String) -> Int? { with(relativePath: relativePath) { $0.size } } func hasFile(at relativePath: String) -> Bool { with(relativePath: relativePath, perform: exists) } func readString(at relativePath: String) -> String? { guard let data = readData(at: relativePath) else { return nil } guard let result = String(data: data, encoding: .utf8) else { delegate?.securityBookmark(error: "Failed to read \(relativePath): invalid UTF-8") return nil } return result } func readData(at relativePath: String) -> Data? { with(relativePath: relativePath) { file in guard exists(file) else { return nil } do { return try Data(contentsOf: file) } catch { delegate?.securityBookmark(error: "Failed to read \(relativePath) \(error)") return nil } } } func decode(at relativePath: String) -> T? where T: Decodable { guard let data = readData(at: relativePath) else { return nil } do { return try decoder.decode(T.self, from: data) } catch { delegate?.securityBookmark(error: "Failed to decode \(relativePath): \(error)") return nil } } // MARK: Modify func move(_ relativeSource: String, to relativeDestination: String, failIfMissing: Bool = true, createParentFolder: Bool = true, ifFileExists overwrite: OverwriteBehaviour = .fail) -> Bool { with(relativePath: relativeSource) { source in if !exists(source) { if !failIfMissing { return true } delegate?.securityBookmark(error: "Failed to move \(relativeSource): File does not exist") return false } let destination = url.appending(path: relativeDestination.withLeadingSlashRemoved) if exists(destination) { switch overwrite { case .fail: delegate?.securityBookmark(error: "Failed to move to \(relativeDestination): File already exists") return false case .skip: return true case .write: break case .writeIfChanged: if let existingData = try? Data(contentsOf: destination), let newData = try? Data(contentsOf: source), existingData == newData { return true } } } do { if createParentFolder { try createParentIfNeeded(of: destination) } try fm.moveItem(at: source, to: destination) return true } catch { delegate?.securityBookmark(error: "Failed to move \(source.path()) to \(destination.path()): \(error)") return false } } } func copy(externalFile: URL, to relativePath: String, createParentFolder: Bool = true, ifFileExists overwrite: OverwriteBehaviour = .writeIfChanged) -> Bool { with(relativePath: relativePath) { destination in do { if destination.exists { switch overwrite { case .fail: delegate?.securityBookmark(error: "Failed to copy to \(relativePath): File already exists") return false case .skip: return true case .write: break case .writeIfChanged: if let existingData = try? Data(contentsOf: destination), let newData = try? Data(contentsOf: externalFile), existingData == newData { return true } } try fm.removeItem(at: destination) } try createParentIfNeeded(of: destination) try fm.copyItem(at: externalFile, to: destination) return true } catch { delegate?.securityBookmark(error: "Failed to copy \(externalFile.path()) to \(relativePath): \(error)") return false } } } func deleteFile(at relativePath: String) -> Bool { with(relativePath: relativePath) { file in guard exists(file) else { return true } do { try fm.removeItem(at: file) return true } catch { delegate?.securityBookmark(error: "Failed to delete \(relativePath): \(error)") return false } } } // MARK: Writing files /** Delete files in a subPath of the content folder which are not in the given set of files - Note: This function requires a security scope for the content path */ func deleteFiles(in relativePath: String, notIn fileSet: Set) -> [String]? { with(relativePath: relativePath) { folder in if !exists(folder) { return [] } guard let files = files(in: folder) else { return [] } return files.compactMap { file in guard !fileSet.contains(file.lastPathComponent) else { return nil } guard remove(file) else { return nil } return file.lastPathComponent } } } // MARK: Transfer func transfer(file sourcePath: String, to relativePath: String, of scope: SecurityBookmark, createParentFolder: Bool = true, ifFileExists: OverwriteBehaviour = .writeIfChanged) -> Bool { with(relativePath: sourcePath) { source in scope.copy( externalFile: source, to: relativePath, createParentFolder: createParentFolder, ifFileExists: ifFileExists) } } // MARK: Batch operations func fileNames(inRelativeFolder relativePath: String) -> [String]? { files(inRelativeFolder: relativePath)?.map { $0.lastPathComponent } } func files(inRelativeFolder relativePath: String) -> [URL]? { with(relativePath: relativePath, perform: files) } /** - Note: This function requires a security scope for the content path */ func decodeJsonFiles(in relativeFolder: String) -> [String : T]? where T: Decodable { with(relativePath: relativeFolder) { folder in guard let files = files(in: folder) else { return nil } return files.filter { $0.pathExtension.lowercased() == "json" } .reduce(into: [:]) { items, url in let id = url.deletingPathExtension().lastPathComponent let data: Data do { data = try Data(contentsOf: url) } catch { delegate?.securityBookmark(error: "Failed to read \(url.path()): \(error)") return } do { items[id] = try decoder.decode(T.self, from: data) } catch { delegate?.securityBookmark(error: "Failed to decode \(url.path()): \(error)") return } } } } // MARK: Generic operations func with(relativePath: String, perform operation: (URL) -> Bool) -> Bool { perform { operation($0.appending(path: relativePath.withLeadingSlashRemoved)) } } func with(relativePath: String, perform operation: (URL) -> T?) -> T? { perform { operation($0.appending(path: relativePath.withLeadingSlashRemoved)) } } func with(relativePath: String, perform operation: (URL) async -> T?) async -> T? { let path = url.appending(path: relativePath.withLeadingSlashRemoved) guard url.startAccessingSecurityScopedResource() else { delegate?.securityBookmark(error: "Failed to start security scope") return nil } defer { url.stopAccessingSecurityScopedResource() } return await operation(path) } /** Run an operation in the security scope of a url. */ func perform(_ operation: (URL) -> Bool) -> Bool { guard url.startAccessingSecurityScopedResource() else { delegate?.securityBookmark(error: "Failed to start security scope") return false } defer { url.stopAccessingSecurityScopedResource() } return operation(url) } /** Run an operation in the content folder */ func perform(_ operation: (URL) -> T?) -> T? { guard url.startAccessingSecurityScopedResource() else { delegate?.securityBookmark(error: "Failed to start security scope") return nil } defer { url.stopAccessingSecurityScopedResource() } return operation(url) } // MARK: Unscoped helpers private func create(folder: URL) -> Bool { do { try createIfNeeded(folder) return true } catch { delegate?.securityBookmark(error: "Failed to create folder \(folder.path())") return false } } private func exists(_ url: URL) -> Bool { fm.fileExists(atPath: url.path()) } private func remove(_ file: URL) -> Bool { guard exists(url) else { return true } do { try fm.removeItem(at: file) } catch { delegate?.securityBookmark(error: "Failed to delete \(file.path()): \(error)") return false } return true } private func createParentIfNeeded(of file: URL) throws { try createIfNeeded(file.deletingLastPathComponent()) } private func createIfNeeded(_ folder: URL) throws { if !exists(folder) { try fm.createDirectory(at: folder, withIntermediateDirectories: true) } } private func files(in folder: URL) throws -> [URL] { try FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil) .filter { !$0.hasDirectoryPath } } private func files(in folder: URL) -> [URL]? { do { return try files(in: folder).filter { !$0.lastPathComponent.hasPrefix(".") } } catch { delegate?.securityBookmark(error: "Failed to read list of files in \(folder.path())") return nil } } }