1 // Copyright 2022-present 650 Industries. All rights reserved. 2 3 import Foundation 4 5 public typealias PersistentFileLogFilter = (String) -> Bool 6 public typealias PersistentFileLogCompletionHandler = (Error?) -> Void 7 8 /** 9 * A thread-safe class for reading and writing line-separated strings to a flat file. 10 * The main use case is for logging specific errors or events, and ensuring that the 11 * logs persist across application crashes and restarts (for example, OSLogReader can 12 * only read system logs for the current process, and cannot access anything logged 13 * before the current process started). 14 * 15 * All write access to the file goes through asynchronous public methods managed by a 16 * serial dispatch queue. 17 * 18 * The dispatch queue is global, to ensure that multiple instances accessing the same 19 * file will have thread-safe access. 20 * 21 * The only operations supported are 22 * - Read the file (synchronous) 23 * - Append one or more entries to the file 24 * - Filter the file (only retain entries that pass the filter check) 25 * - Clear the file (remove all entries) 26 * 27 */ 28 public class PersistentFileLog { 29 private static let EXPO_MODULES_CORE_LOG_QUEUE_LABEL = "dev.expo.modules.core.logging" 30 private static let serialQueue = DispatchQueue(label: EXPO_MODULES_CORE_LOG_QUEUE_LABEL) 31 32 private let category: String 33 private let filePath: String 34 35 public init(category: String) { 36 self.category = category 37 let fileName = "\(PersistentFileLog.EXPO_MODULES_CORE_LOG_QUEUE_LABEL).\(category).txt" 38 // Execution aborts if no application support directory 39 self.filePath = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!.appendingPathComponent(fileName).path 40 } 41 /** 42 Read entries from log file 43 */ readEntriesnull44 public func readEntries() -> [String] { 45 if getFileSize() == 0 { 46 return [] 47 } 48 return (try? self.readFileSync()) ?? [] 49 } 50 51 /** 52 Append entry to the log file 53 Since logging may not require a result handler, the handler parameter is optional 54 If no error handler provided, print error to the console 55 */ appendEntrynull56 public func appendEntry(entry: String, _ completionHandler: PersistentFileLogCompletionHandler? = nil) { 57 PersistentFileLog.serialQueue.async { 58 self.ensureFileExists() 59 self.appendTextToFile(text: entry + "\n") 60 completionHandler?(nil) 61 } 62 } 63 64 /** 65 Filter existing entries and remove ones where filter(entry) == false 66 */ purgeEntriesNotMatchingFilternull67 public func purgeEntriesNotMatchingFilter(filter: @escaping PersistentFileLogFilter, _ completionHandler: @escaping PersistentFileLogCompletionHandler) { 68 PersistentFileLog.serialQueue.async { 69 self.ensureFileExists() 70 do { 71 let contents = try self.readFileSync() 72 let newcontents = contents.filter { entry in filter(entry) } 73 try self.writeFileSync(newcontents) 74 completionHandler(nil) 75 } catch { 76 completionHandler(error) 77 } 78 } 79 } 80 81 /** 82 Clean up (remove) the log file 83 */ clearEntriesnull84 public func clearEntries(_ completionHandler: @escaping PersistentFileLogCompletionHandler) { 85 PersistentFileLog.serialQueue.async { 86 do { 87 try self.deleteFileSync() 88 completionHandler(nil) 89 } catch { 90 completionHandler(error) 91 } 92 } 93 } 94 95 // MARK: - Private methods 96 ensureFileExistsnull97 private func ensureFileExists() { 98 if !FileManager.default.fileExists(atPath: filePath) { 99 FileManager.default.createFile(atPath: filePath, contents: nil) 100 } 101 } 102 getFileSizenull103 private func getFileSize() -> Int { 104 // Gets the file size, or returns 0 if the file does not exist 105 do { 106 let attrs: [FileAttributeKey: Any?] = try FileManager.default.attributesOfItem(atPath: filePath) 107 return attrs[FileAttributeKey.size] as? Int ?? 0 108 } catch { 109 return 0 110 } 111 } 112 appendTextToFilenull113 private func appendTextToFile(text: String) { 114 if let data = text.data(using: .utf8) { 115 if let fileHandle = FileHandle(forWritingAtPath: filePath) { 116 fileHandle.seekToEndOfFile() 117 fileHandle.write(data) 118 fileHandle.closeFile() 119 } 120 } 121 } 122 readFileSyncnull123 private func readFileSync() throws -> [String] { 124 return try stringToList(String(contentsOfFile: filePath, encoding: .utf8)) 125 } 126 writeFileSyncnull127 private func writeFileSync(_ contents: [String]) throws { 128 if contents.isEmpty { 129 try deleteFileSync() 130 return 131 } 132 try contents.joined(separator: "\n").write(toFile: filePath, atomically: true, encoding: .utf8) 133 } 134 deleteFileSyncnull135 private func deleteFileSync() throws { 136 if FileManager.default.fileExists(atPath: filePath) { 137 try FileManager.default.removeItem(atPath: filePath) 138 } 139 } 140 stringToListnull141 private func stringToList(_ contents: String?) -> [String] { 142 // If null contents, or 0 length contents, return empty list 143 guard let contents = contents, !contents.isEmpty else { 144 return [] 145 } 146 return contents 147 .components(separatedBy: "\n") 148 .filter {entryString in 149 !entryString.isEmpty 150 } 151 } 152 } 153