Last active
September 2, 2025 20:54
-
-
Save adam-zethraeus/a8e4c364ed135266d7cec585341d819d to your computer and use it in GitHub Desktop.
OSLog visualizer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import UniformTypeIdentifiers | |
| import SwiftUI | |
| import OSLog | |
| import Foundation | |
| import Observation | |
| extension Logger { | |
| public static func make(file: StaticString = #file, category: StaticString? = nil) -> Logger { | |
| Logger( | |
| subsystem: Bundle.main.bundleIdentifier ?? "some.recorder", | |
| category: category.map(String.init) ?? "\(file)" | |
| ) | |
| } | |
| } | |
| let logger = Logger.make() | |
| public struct LogUtil: View { | |
| @State private var model: LogModel = .shared | |
| public init(){} | |
| public var body: some View { | |
| LogExportScreen(model: model) | |
| } | |
| } | |
| @Observable | |
| final class LogModel { | |
| @MainActor | |
| static let shared: LogModel = .init() | |
| var logs: [LogRepresentation] = [] | |
| struct Filter: Hashable, Sendable { | |
| init( | |
| excludeSystemLogs: Bool = true, | |
| filterType: FilterType = .specificDate, | |
| specificDate: Date = Date(), | |
| dateRangeStart: Date = Date(), | |
| dateRangeFinish: Date = Date(), | |
| hourRangeStart: Date = Date().addingTimeInterval(-3600), | |
| hourRangeFinish: Date = Date(), | |
| selectedPreset: Preset = .minutesFive | |
| ) { | |
| self.excludeSystemLogs = excludeSystemLogs | |
| self.filterType = filterType | |
| self.specificDate = specificDate | |
| self.dateRangeStart = dateRangeStart | |
| self.dateRangeFinish = dateRangeFinish | |
| self.hourRangeStart = hourRangeStart | |
| self.hourRangeFinish = hourRangeFinish | |
| self.selectedPreset = selectedPreset | |
| } | |
| var excludeSystemLogs: Bool = true | |
| var filterType: FilterType | |
| var specificDate: Date | |
| var dateRangeStart: Date | |
| var dateRangeFinish: Date | |
| var hourRangeStart: Date | |
| var hourRangeFinish: Date | |
| var selectedPreset: Preset | |
| } | |
| var filter: Filter = .init() | |
| var exportState: ExportState = .ready | |
| static let allowedTypes: [UTType] = [.log, .json, .plainText, .text, .commaSeparatedText] | |
| init() { | |
| logger.debug("L | ⚪debug") | |
| logger.notice("O | ⚫notice") | |
| logger.info("G | 🔵info") | |
| logger.warning("U | 🟡warning") | |
| logger.error("T | 🔴error") | |
| logger.critical("I | 🟣critical") | |
| logger.fault("L | 🟠fault") | |
| } | |
| } | |
| extension LogModel { | |
| enum ExportState { | |
| case ready | |
| case processing | |
| case completed | |
| case failed | |
| } | |
| var isExporting: Bool { | |
| exportState == .processing | |
| } | |
| enum FilterType: CustomStringConvertible, CaseIterable, Hashable, Identifiable { | |
| case preset | |
| case specificDate | |
| case dateRange | |
| case hourRange | |
| var id: Self { self } | |
| var description: String { | |
| switch self { | |
| case .specificDate: return "Date" | |
| case .dateRange: return "Date range" | |
| case .hourRange: return "Time range" | |
| case .preset: return "Recent" | |
| } | |
| } | |
| } | |
| enum Preset: CustomStringConvertible, CaseIterable { | |
| case minutesFive | |
| case minutesTen | |
| case minutesFifteen | |
| case minutesThirty | |
| case hourOne | |
| case hoursSix | |
| case hoursTwelve | |
| case hoursTwentyFour | |
| var description: String { | |
| switch self { | |
| case .minutesFive: return "5 minutes" | |
| case .minutesTen: return "10 minutes" | |
| case .minutesFifteen: return "15 minutes" | |
| case .minutesThirty: return "30 minutes" | |
| case .hourOne: return "1 hour" | |
| case .hoursSix: return "6 hours" | |
| case .hoursTwelve: return "12 hours" | |
| case .hoursTwentyFour: return "24 hours" | |
| } | |
| } | |
| internal var presetDate: Date { | |
| return Date().addingTimeInterval(-timeInterval) | |
| } | |
| private var timeInterval: TimeInterval { | |
| let minute: TimeInterval = 60 | |
| let hour: TimeInterval = 3600 | |
| switch self { | |
| case .minutesFive: return 5 * minute | |
| case .minutesTen: return 10 * minute | |
| case .minutesFifteen: return 15 * minute | |
| case .minutesThirty: return 30 * minute | |
| case .hourOne: return hour | |
| case .hoursSix: return 6 * hour | |
| case .hoursTwelve: return 12 * hour | |
| case .hoursTwentyFour: return 24 * hour | |
| } | |
| } | |
| } | |
| } | |
| extension LogModel { | |
| internal enum LoggerError: Error, LocalizedError { | |
| case failedToFetch | |
| case failedToWriteFile | |
| case unsupportedFormatType | |
| var errorDescription: String? { | |
| switch self { | |
| case .failedToFetch: | |
| return NSLocalizedString( | |
| "Failed to fetch logs.", | |
| comment: "Failed to fetch logs.") | |
| case .failedToWriteFile: | |
| return NSLocalizedString( | |
| "Failed to write log file.", | |
| comment: "Failed to write log file.") | |
| case .unsupportedFormatType: | |
| return NSLocalizedString( | |
| "Unsupported filed type selected - only .log, .plaintext, .commaSeparatedValue, or .json are allowed.", | |
| comment: "Unsupported filed type selected.") | |
| } | |
| } | |
| } | |
| } | |
| @globalActor | |
| actor BGActor { | |
| static let shared = BGActor() | |
| } | |
| extension LogModel { | |
| func setLogs(to logs: [LogRepresentation]) { | |
| self.logs = logs | |
| self.exportState = logs.isEmpty ? .failed : .completed | |
| } | |
| @MainActor | |
| func fetchLogEntries() { | |
| self.exportState = .processing | |
| Task { @MainActor in | |
| do { | |
| let it = filter | |
| self.logs = try await Task { @BGActor @Sendable [filter = it] in | |
| let logPredicate: NSPredicate? = Self.getLogPredicate(filter: filter, for: filter.excludeSystemLogs ? Bundle.main : nil) | |
| let store = try OSLogStore(scope: .currentProcessIdentifier) | |
| return try store | |
| .getEntries(matching: logPredicate) | |
| .compactMap { $0 as? OSLogEntryLog } | |
| .map { LogRepresentation(entry: $0) } | |
| }.value | |
| } catch { | |
| } | |
| } | |
| } | |
| static func exportLogs(_ logs: [LogRepresentation], as format: UTType, csvDelimiter: Delimiter = .comma) -> String { | |
| guard Self.allowedTypes.contains(format) else { | |
| return "Unsupported export format." | |
| } | |
| switch format { | |
| case .json: | |
| return logEntriesToJSON(logEntries: logs) | |
| case .commaSeparatedText: | |
| return logEntriesToCSV(logEntries: logs, delimiter: csvDelimiter) | |
| case .plainText, .log: | |
| return logEntriesToString(logEntries: logs) | |
| default: | |
| return "" | |
| } | |
| } | |
| @MainActor | |
| func writeLogs(as format: UTType, to url: URL) async throws { | |
| guard Self.allowedTypes.contains(format) else { | |
| logger.error("Unsupported export format: \(format, privacy: .public)") | |
| throw LoggerError.unsupportedFormatType | |
| } | |
| let logs = self.logs | |
| let task = Task { @BGActor [logs] in | |
| let logString = Self.exportLogs(logs, as: format) | |
| try logString.write(to: url, atomically: true, encoding: .utf8) | |
| } | |
| do { | |
| try await task.value | |
| self.exportState = .completed | |
| } catch { | |
| logger.error("Failed file export: \(error, privacy: .public)") | |
| self.exportState = .failed | |
| throw LoggerError.failedToWriteFile | |
| } | |
| } | |
| } | |
| extension LogModel { | |
| private static func getLogPredicate(filter: Filter, for bundle: Bundle?) -> NSPredicate? { | |
| switch filter.filterType { | |
| case .specificDate: | |
| return datePredicate(for: filter.specificDate, and: bundle) | |
| case .dateRange: | |
| return datePredicate(from: filter.dateRangeStart, to: filter.dateRangeFinish, and: bundle) | |
| case .hourRange: | |
| guard let (startDate, endDate) = getHourRangeDates(filter: filter) else { return nil } | |
| return datePredicate(from: startDate, to: endDate, and: bundle) | |
| case .preset: | |
| let startDate = filter.selectedPreset.presetDate | |
| return datePredicate(from: startDate, and: bundle) | |
| } | |
| } | |
| private static func getHourRangeDates(filter: Filter) -> (startDate: Date, endDate: Date)? { | |
| let startHour = Calendar.current.component(.hour, from: filter.hourRangeStart) | |
| let startMinute = Calendar.current.component(.minute, from: filter.hourRangeStart) | |
| let finishHour = Calendar.current.component(.hour, from: filter.hourRangeFinish) | |
| let finishMinute = Calendar.current.component(.minute, from: filter.hourRangeFinish) | |
| guard let startDate = filter.specificDate.createDateTime(hour: startHour, minute: startMinute), | |
| let endDate = filter.specificDate.createDateTime(hour: finishHour, minute: finishMinute) else { | |
| return nil | |
| } | |
| return (startDate, endDate) | |
| } | |
| private static func datePredicate(for date: Date, and bundle: Bundle?) -> NSPredicate { | |
| guard let nextDay = Calendar.current.date(byAdding: .day, value: 1, to: date) else { | |
| return NSPredicate(value: false) | |
| } | |
| if let b = bundle?.bundleIdentifier { | |
| return NSPredicate( | |
| format: "date >= %@ AND date < %@ AND subsystem = %@", | |
| date as NSDate, | |
| nextDay as NSDate, | |
| b as NSString | |
| ) | |
| } else { | |
| return NSPredicate( | |
| format: "date >= %@ AND date < %@", | |
| date as NSDate, | |
| nextDay as NSDate | |
| ) | |
| } | |
| } | |
| private static func datePredicate(from startDate: Date, to endDate: Date? = nil, and bundle: Bundle?) -> NSPredicate { | |
| if let endDate = endDate { | |
| if let b = bundle?.bundleIdentifier { | |
| return NSPredicate( | |
| format: "date >= %@ AND date <= %@ AND subsystem = %@", | |
| startDate as NSDate, | |
| endDate as NSDate, | |
| b as NSString | |
| ) | |
| } else { | |
| return NSPredicate( | |
| format: "date >= %@ AND date <= %@", | |
| startDate as NSDate, | |
| endDate as NSDate | |
| ) | |
| } | |
| } else { | |
| if let b = bundle?.bundleIdentifier { | |
| return NSPredicate( | |
| format: "date >= %@ AND subsystem = %@", | |
| startDate as NSDate, | |
| b as NSString | |
| ) | |
| } else { | |
| return NSPredicate( | |
| format: "date >= %@", | |
| startDate as NSDate | |
| ) | |
| } | |
| } | |
| } | |
| private static func logEntriesToString(logEntries logs: [LogRepresentation]) -> String { | |
| let logStrings = logs.map { entry in | |
| let message = entry.composedMessage.replacingOccurrences(of: "|", with: "\\|") | |
| return "\(entry.date) | \(entry.level) | \(entry.subsystem) | \(entry.category) | \(message)" | |
| } | |
| return logStrings.joined(separator: "\n") | |
| } | |
| private static func logEntriesToJSON(logEntries: [LogRepresentation]) -> String { | |
| let jsonEncoder = JSONEncoder() | |
| jsonEncoder.outputFormatting = .prettyPrinted | |
| if let jsonData = try? jsonEncoder.encode(logEntries), | |
| let jsonString = String(data: jsonData, encoding: .utf8) { | |
| return jsonString | |
| } | |
| return "" | |
| } | |
| private static func logEntriesToCSV(logEntries: [LogRepresentation], delimiter: Delimiter) -> String { | |
| let headers = ["Date", "Level", "Subsystem", "Category", "Message"] | |
| let csvHeaders = headers.joined(separator: delimiter.rawValue) | |
| let csvStrings = logEntries.map { entry in | |
| return [entry.dateString, entry.level.description, entry.subsystem, entry.category, entry.composedMessage] | |
| .joined(separator: delimiter.rawValue) | |
| } | |
| return ([csvHeaders] + csvStrings).joined(separator: "\n") | |
| } | |
| } | |
| struct LogRepresentation: Hashable, Identifiable, Codable { | |
| let id: String | |
| let date: Date | |
| var dateString: String { | |
| let dateFormatter = ISO8601DateFormatter() | |
| return dateFormatter.string(from: date) | |
| } | |
| let level: Level | |
| let subsystem: String | |
| let category: String | |
| let message: String | |
| let color: String | |
| let description: String | |
| let composedMessage: String | |
| init(entry: OSLogEntryLog) { | |
| self.id = "\(ObjectIdentifier(entry))" | |
| self.date = entry.date | |
| self.level = entry.level.level | |
| self.subsystem = entry.subsystem | |
| self.category = entry.category | |
| self.message = entry.composedMessage | |
| self.color = entry.level.color.toHex() | |
| self.description = entry.description | |
| self.composedMessage = entry.composedMessage | |
| } | |
| } | |
| private protocol LoggerCategoryRepresentable: RawRepresentable, Hashable, Equatable where RawValue == String {} | |
| private actor LoggerCategoryManager { | |
| private var existingRawValues = Set<String>() | |
| internal func addCategoryIfNew(_ rawValue: String) -> Bool { | |
| let lowercasedValue = rawValue.lowercased() | |
| if existingRawValues.contains(lowercasedValue) { | |
| return true | |
| } else { | |
| existingRawValues.insert(lowercasedValue) | |
| return false | |
| } | |
| } | |
| } | |
| struct LoggerCategory: LoggerCategoryRepresentable, Sendable { | |
| let rawValue: String | |
| private static let manager = LoggerCategoryManager() | |
| init(rawValue: String) { | |
| self.rawValue = rawValue | |
| Task { | |
| _ = await LoggerCategory.manager.addCategoryIfNew(rawValue) | |
| } | |
| } | |
| init(_ value: String) { | |
| self.init(rawValue: value) | |
| } | |
| } | |
| extension Logger { | |
| init(category: LoggerCategory) { | |
| let subsystem = Bundle.main.bundleIdentifier! | |
| self.init(subsystem: subsystem, category: category.rawValue) | |
| } | |
| } | |
| enum Level: String, Sendable, Codable, CustomStringConvertible { | |
| case undefined | |
| case debug | |
| case info | |
| case notice | |
| case error | |
| case fault | |
| case unknown | |
| var description: String { | |
| switch self { | |
| case .undefined: | |
| return "Undefined" | |
| case .debug: | |
| return "Debug" | |
| case .info: | |
| return "Info" | |
| case .notice: | |
| return "Notice" | |
| case .error: | |
| return "Error" | |
| case .fault: | |
| return "Fault" | |
| case .unknown: | |
| return "Unknown" | |
| } | |
| } | |
| var color: Color { | |
| switch self { | |
| case .undefined: | |
| return .gray | |
| case .debug: | |
| return .green | |
| case .info: | |
| return .blue | |
| case .notice: | |
| return Color(PlatformColor.predatedCyan) | |
| case .error: | |
| return .orange | |
| case .fault: | |
| return .red | |
| case .unknown: | |
| return .clear | |
| } | |
| } | |
| var sfSymbol: String { | |
| switch self { | |
| case .undefined: | |
| return "exclamationmark" | |
| case .debug: | |
| return "stethoscope" | |
| case .info: | |
| return "info" | |
| case .notice: | |
| return "bell.fill" | |
| case .error: | |
| return "exclamationmark.2" | |
| case .fault: | |
| return "exclamationmark.3" | |
| case .unknown: | |
| return "questionmark" | |
| } | |
| } | |
| } | |
| extension OSLogEntryLog.Level { | |
| var level: Level { | |
| switch self { | |
| case .undefined: | |
| .undefined | |
| case .debug: | |
| .debug | |
| case .info: | |
| .info | |
| case .notice: | |
| .notice | |
| case .error: | |
| .error | |
| case .fault: | |
| .fault | |
| @unknown default: | |
| .unknown | |
| } | |
| } | |
| var color: Color { | |
| switch self { | |
| case .undefined: | |
| return .gray | |
| case .debug: | |
| return .green | |
| case .info: | |
| return .blue | |
| case .notice: | |
| return Color(PlatformColor.predatedCyan) | |
| case .error: | |
| return .orange | |
| case .fault: | |
| return .red | |
| @unknown default: | |
| return .clear | |
| } | |
| } | |
| var sfSymbol: String { | |
| switch self { | |
| case .undefined: | |
| return "exclamationmark" | |
| case .debug: | |
| return "stethoscope" | |
| case .info: | |
| return "info" | |
| case .notice: | |
| return "bell.fill" | |
| case .error: | |
| return "exclamationmark.2" | |
| case .fault: | |
| return "exclamationmark.3" | |
| @unknown default: | |
| return "questionmark" | |
| } | |
| } | |
| } | |
| extension Date { | |
| internal func createDateTime(hour: Int, minute: Int) -> Date? { | |
| var components = Calendar.current.dateComponents([.year, .month, .day], from: self) | |
| components.hour = hour | |
| components.minute = minute | |
| return Calendar.current.date(from: components) | |
| } | |
| } | |
| extension PlatformColor { | |
| internal static let predatedCyan: PlatformColor = .init(red: 50/255, green: 173/255, blue: 230/255, alpha: 1) | |
| } | |
| enum Delimiter: String { | |
| case comma = "," | |
| case semicolon = ";" | |
| case tab = "\t" | |
| case pipe = "|" | |
| internal func escape(_ value: String) -> String { | |
| switch self { | |
| case .comma: | |
| return value.replacingOccurrences(of: self.rawValue, with: "\\\(self.rawValue)") | |
| case .semicolon: | |
| return value.replacingOccurrences(of: self.rawValue, with: "\\\(self.rawValue)") | |
| case .tab: | |
| return value.replacingOccurrences(of: self.rawValue, with: "\\\(self.rawValue)") | |
| case .pipe: | |
| return value.replacingOccurrences(of: self.rawValue, with: "\\\(self.rawValue)") | |
| } | |
| } | |
| } | |
| struct ActionButton: View { | |
| private let title: String | |
| private let systemImage: String | |
| private let action: () -> Void | |
| init( | |
| _ title: String, | |
| systemImage: String, | |
| action: @escaping () -> Void | |
| ) { | |
| self.title = title | |
| self.systemImage = systemImage | |
| self.action = action | |
| } | |
| var body: some View { | |
| Button(action: action) { | |
| HStack { | |
| Label(title, systemImage: systemImage) | |
| } | |
| } | |
| .accessibilityLabel(title) | |
| } | |
| } | |
| struct FilterSheet: View { | |
| @Binding var selectedCategories: Set<String> | |
| @Binding var selectedLevels: Set<Level> | |
| let categories: [String] | |
| let levels: [Level] | |
| var body: some View { | |
| NavigationStack { | |
| Form { | |
| HStack { | |
| Button(selectedCategories.count == categories.count ? "None" : "All") { | |
| if selectedCategories.count == categories.count { | |
| selectedCategories.removeAll() | |
| } else { | |
| selectedCategories = Set(categories) | |
| } | |
| } | |
| Spacer() | |
| Divider() | |
| Spacer() | |
| MultiSelectPicker( | |
| title: "Categories", | |
| options: categories, | |
| selectedOptions: $selectedCategories | |
| ) | |
| } | |
| HStack { | |
| Button(selectedLevels.count == levels.count ? "None" : "All") { | |
| if selectedLevels.count == levels.count { | |
| selectedLevels.removeAll() | |
| } else { | |
| selectedLevels = Set(levels) | |
| } | |
| } | |
| Spacer() | |
| Divider() | |
| Spacer() | |
| MultiSelectPicker( | |
| title: "Levels", | |
| options: levels, | |
| selectedOptions: $selectedLevels | |
| ) | |
| } | |
| } | |
| .navigationTitle("Filters") | |
| } | |
| } | |
| } | |
| struct VerticalLabeledContentStyle: LabeledContentStyle { | |
| func makeBody(configuration: Configuration) -> some View { | |
| VStack(alignment: .leading) { | |
| configuration.label | |
| .fontWeight(.bold) | |
| configuration.content | |
| } | |
| .padding(.bottom, 4) | |
| } | |
| } | |
| extension LabeledContentStyle where Self == VerticalLabeledContentStyle { | |
| static var vertical: VerticalLabeledContentStyle { Self() } | |
| } | |
| struct LogCell: View { | |
| private let log: LogRepresentation | |
| init(for log: LogRepresentation) { | |
| self.log = log | |
| } | |
| var body: some View { | |
| VStack(alignment: .leading) { | |
| headerSection | |
| Divider().background(log.level.color) | |
| logDetailsSection | |
| } | |
| .font(.footnote) | |
| .listRowBackground(log.level.color.opacity(0.1)) | |
| .frame(maxWidth: .infinity, alignment: .leading) | |
| } | |
| private var headerSection: some View { | |
| HStack { | |
| ZStack { | |
| RoundedRectangle(cornerRadius: 6) | |
| .fill(log.level.color) | |
| .frame(width: 24, height: 24) | |
| Image(systemName: log.level.sfSymbol) | |
| .frame(width: 22, height: 22) | |
| .foregroundStyle(.white) | |
| } | |
| Text(log.date.formatted(date: .long, time: .standard)) | |
| .font(.system(.footnote, design: .monospaced)) | |
| } | |
| } | |
| private var logDetailsSection: some View { | |
| Group { | |
| LabeledContent("Subsystem", value: log.subsystem) | |
| LabeledContent("Category", value: log.category) | |
| LabeledContent("Level", value: log.level.description) | |
| LabeledContent("Message", value: log.composedMessage) | |
| } | |
| .labeledContentStyle(.vertical) | |
| } | |
| } | |
| struct LogExportScreen: View { | |
| init(model: LogModel) { | |
| self.model = model | |
| } | |
| @Bindable private var model: LogModel | |
| @State private var showFileExporter: Bool = false | |
| @State private var logFileDocument: MultiTypeFileDocument? | |
| @State private var logFileDocumentType: UTType = .log | |
| @State private var selectedExport: UTType = .log | |
| @State private var showAlert: Bool = false | |
| @State private var alertMessage: String = "" | |
| @State private var showToast: Bool = false | |
| enum Destination { | |
| case logList | |
| } | |
| @State var destination: Destination? | |
| var body: some View { | |
| NavigationStack { | |
| Form { | |
| actionButtonsSection | |
| filterTypeSection | |
| filterOptions | |
| HStack { | |
| Button { | |
| destination = .logList | |
| } label: { | |
| Label("View logs", systemImage: "doc.plaintext") | |
| } | |
| .opacity(model.isExporting ? 0.6 : 1) | |
| .disabled(model.isExporting) | |
| Spacer() | |
| if model.isExporting { | |
| ProgressView() | |
| } | |
| } | |
| } | |
| .formStyle(.grouped) | |
| .navigationTitle("Export Logs") | |
| .animation(.easeInOut, value: model.isExporting) | |
| .toolbar { toolbarComponents } | |
| .task { model.fetchLogEntries() } | |
| .onChange(of: model.filter) { | |
| Task { model.fetchLogEntries() } | |
| } | |
| .fileExporter( | |
| isPresented: $showFileExporter, | |
| document: logFileDocument, | |
| contentType: selectedExport | |
| ) { result in | |
| handleFileExportResult(result) | |
| } | |
| .alert( | |
| "Error", | |
| isPresented: $showAlert | |
| ) { | |
| Button("OK", role: .cancel) {} | |
| } message: { | |
| Text(alertMessage) | |
| } | |
| } | |
| .navigationDestination(item: $destination) { it in | |
| switch it { | |
| case .logList: | |
| LogListScreen(logs: model.logs) | |
| } | |
| } | |
| } | |
| } | |
| extension LogExportScreen { | |
| private func copyToClipboard() { | |
| let logs = model.logs | |
| Task { [logs] in | |
| let logs = LogModel.exportLogs(logs, as: .plainText) | |
| let contentToPaste = logs.isEmpty ? "Nothing to paste" : logs | |
| setToPasteBoard(contentToPaste) | |
| showToast = true | |
| } | |
| } | |
| private func exportLogFile(type: UTType) { | |
| guard logFileDocument == nil else { | |
| showFileExporter.toggle() | |
| return | |
| } | |
| Task { | |
| do { | |
| let url = createLogFileURL(for: type) | |
| try await model.writeLogs(as: type, to: url) | |
| await MainActor.run { | |
| self.logFileDocument = MultiTypeFileDocument(file: url, fileType: type) | |
| self.showFileExporter = true | |
| } | |
| } catch { | |
| alertMessage = "Failed to write logs to file: \(error.localizedDescription)" | |
| showAlert = true | |
| } | |
| } | |
| } | |
| private func shareLogFile(type: UTType) -> URL { | |
| let url = createLogFileURL(for: type) | |
| Task { | |
| do { | |
| try await model.writeLogs(as: type, to: url) | |
| } catch { | |
| } | |
| } | |
| return url | |
| } | |
| private func createLogFileURL(for type: UTType) -> URL { | |
| let fileName = "\(ProcessInfo.processInfo.processName)-\(Date().timeIntervalSince1970)" | |
| let fileExtension = type.fileExtension | |
| let fullFile = "\(fileName).\(fileExtension)" | |
| return URL.temporaryDirectory.appendingPathComponent(fullFile) | |
| } | |
| private func handleFileExportResult(_ result: Result<URL, Error>) { | |
| switch result { | |
| case .success: | |
| break | |
| case let .failure(error): | |
| alertMessage = "Failed to export the file: \(error.localizedDescription)" | |
| showAlert = true | |
| } | |
| logFileDocument = nil | |
| } | |
| private func setToPasteBoard(_ string: String) { | |
| #if os(macOS) | |
| let pasteboard = NSPasteboard.general | |
| pasteboard.declareTypes([.string], owner: nil) | |
| pasteboard.setString(string, forType: .string) | |
| #else | |
| UIPasteboard.general.string = string | |
| #endif | |
| } | |
| } | |
| extension LogExportScreen { | |
| @ToolbarContentBuilder | |
| private var toolbarComponents: some ToolbarContent { | |
| ToolbarItemGroup(placement: .navigation) { | |
| overlayProgress | |
| copiedConfirmation | |
| } | |
| } | |
| private var overlayProgress: some View { | |
| ProgressView() | |
| .padding(4) | |
| .background(Color(.gray).opacity(0.2)) | |
| .clipShape(.rect(cornerRadius: 4)) | |
| .opacity(model.isExporting ? 1 : 0) | |
| .transition(.opacity) | |
| } | |
| private var copiedConfirmation: some View { | |
| Text("Copied to clipboard") | |
| .font(.caption) | |
| .padding(4) | |
| .background(Color(.gray).opacity(0.2)) | |
| .clipShape(.rect(cornerRadius: 4)) | |
| .opacity(showToast ? 1 : 0) | |
| .transition(.opacity) | |
| } | |
| private var filterTypeSection: some View { | |
| Section { | |
| Picker( | |
| "Filter by", selection: $model.filter.filterType) { | |
| ForEach(LogModel.FilterType.allCases) { type in | |
| Text(type.description).tag(type) | |
| } | |
| } | |
| .pickerStyle(.segmented) | |
| filterBySegments | |
| } header: { | |
| Text("Filter") | |
| } footer: { | |
| Text(filterFooterText) | |
| } | |
| .animation(.easeInOut, value: model.filter.filterType) | |
| } | |
| private var filterOptions: some View { | |
| Section { | |
| Toggle("Exclude system logs", isOn: $model.filter.excludeSystemLogs) | |
| Picker("Export filetype", selection: $selectedExport) { | |
| ForEach(LogModel.allowedTypes, id: \.self) { type in | |
| Text(type.description).tag(type) | |
| } | |
| } | |
| .pickerStyle(.segmented) | |
| } header: { | |
| Text("Options") | |
| } footer: { | |
| Text( | |
| "If you activate **Exclude system logs** then only entries linked to this app's identifier will be extracted." | |
| ) | |
| } | |
| .animation(.easeInOut, value: model.filter.excludeSystemLogs) | |
| } | |
| private var actionButtonsSection: some View { | |
| Section { | |
| ActionButton("Copy to clipboard", systemImage: "doc.on.doc") { | |
| copyToClipboard() | |
| } | |
| ActionButton("Export log file", systemImage: "square.and.arrow.down") { | |
| exportLogFile(type: selectedExport) | |
| } | |
| ShareLink(item: shareLogFile(type: selectedExport)) { | |
| Label("Share log file", systemImage: "square.and.arrow.up") | |
| } | |
| } | |
| } | |
| @ViewBuilder | |
| private var filterBySegments: some View { | |
| switch model.filter.filterType { | |
| case .specificDate: specificDateSegment | |
| case .dateRange: dateRangeSegment | |
| case .hourRange: hourRangeSegment | |
| case .preset: presetSegment | |
| } | |
| } | |
| private var specificDateSegment: some View { | |
| DatePicker( | |
| "Specific date", | |
| selection: $model.filter.specificDate, | |
| in: ...Date.now, | |
| displayedComponents: .date | |
| ) | |
| } | |
| private var dateRangeSegment: some View { | |
| Group { | |
| DatePicker( | |
| "Start date", | |
| selection: $model.filter.dateRangeStart, | |
| in: ...model.filter.dateRangeFinish, | |
| displayedComponents: .date | |
| ) | |
| DatePicker( | |
| "Finish date", | |
| selection: $model.filter.dateRangeFinish, | |
| in: model.filter.dateRangeStart..., | |
| displayedComponents: .date | |
| ) | |
| } | |
| } | |
| private var hourRangeSegment: some View { | |
| Group { | |
| DatePicker( | |
| "Specific date", | |
| selection: $model.filter.specificDate, | |
| in: ...Date.now, | |
| displayedComponents: .date | |
| ) | |
| DatePicker( | |
| "Start time", | |
| selection: $model.filter.hourRangeStart, | |
| in: ...model.filter.hourRangeFinish, | |
| displayedComponents: .hourAndMinute | |
| ) | |
| DatePicker( | |
| "Finish time", | |
| selection: $model.filter.hourRangeFinish, | |
| in: model.filter.hourRangeStart..., | |
| displayedComponents: .hourAndMinute | |
| ) | |
| } | |
| } | |
| private var presetSegment: some View { | |
| Picker("Recent", selection: $model.filter.selectedPreset) { | |
| ForEach(LogModel.Preset.allCases, id: \.self) { preset in | |
| Text("Last \(preset.description)").id(preset) | |
| } | |
| } | |
| } | |
| private var filterFooterText: String { | |
| switch model.filter.filterType { | |
| case .specificDate: | |
| return | |
| "Select a specific date to filter logs from that day only. All times are considered within the selected date." | |
| case .dateRange: | |
| return | |
| "Choose a start and end date to filter logs within a specific date range. Logs from both dates will be included." | |
| case .hourRange: | |
| return | |
| "Set a specific date and a range of hours to narrow down logs to a precise time window within the chosen day." | |
| case .preset: | |
| return | |
| "Select a preset option to quickly apply common date and time filters without manual adjustments." | |
| } | |
| } | |
| } | |
| final class MultiTypeFileDocument: FileDocument { | |
| static let readableContentTypes: [UTType] = [.log, .json, .plainText, .commaSeparatedText] | |
| let file: URL | |
| let fileType: UTType | |
| init(file: URL, fileType: UTType) { | |
| self.file = file | |
| self.fileType = fileType | |
| } | |
| required init(configuration _: ReadConfiguration) throws { | |
| throw NSError( | |
| domain: "com.example.logexporter", | |
| code: -1, | |
| userInfo: [NSLocalizedDescriptionKey: "This document type cannot be read."] | |
| ) | |
| } | |
| func fileWrapper(configuration _: WriteConfiguration) throws -> FileWrapper { | |
| return try FileWrapper(url: file) | |
| } | |
| } | |
| struct LogListScreen: View { | |
| @State private var searchText: String = "" | |
| @State private var logs: [LogRepresentation] | |
| @State private var selectedCategories: Set<String> = [] | |
| @State private var selectedLevels: Set<Level> = [] | |
| @State private var isFilterSheetPresented: Bool = false | |
| private var categories: [String] { | |
| Array(Set(logs.map { $0.category })).sorted() | |
| } | |
| private var levels: [Level] { | |
| Array(Set(logs.map { $0.level })).sorted { $0.rawValue < $1.rawValue } | |
| } | |
| private var filteredLogs: [LogRepresentation] { | |
| logs.filter { log in | |
| (searchText.isEmpty || log.composedMessage.localizedCaseInsensitiveContains(searchText)) | |
| && (selectedCategories.isEmpty || selectedCategories.contains(log.category)) | |
| && (selectedLevels.isEmpty || selectedLevels.contains(log.level)) | |
| } | |
| } | |
| init(logs: [LogRepresentation]) { | |
| self.logs = logs | |
| } | |
| var body: some View { | |
| VStack { | |
| List(filteredLogs) { log in | |
| LogCell(for: log) | |
| } | |
| .navigationTitle("View Logs") | |
| .searchable(text: $searchText) | |
| .toolbar { | |
| ToolbarItem(placement: .navigation) { | |
| Button { | |
| isFilterSheetPresented.toggle() | |
| } label: { | |
| Image(systemName: "slider.horizontal.3") | |
| } | |
| .opacity(logs.isEmpty ? 0 : 1) | |
| } | |
| } | |
| .overlay { | |
| if filteredLogs.isEmpty { | |
| VStack { | |
| Text("No log entries found") | |
| .font(.title2) | |
| .fontWeight(.bold) | |
| } | |
| } | |
| } | |
| } | |
| .sheet(isPresented: $isFilterSheetPresented) { | |
| FilterSheet( | |
| selectedCategories: $selectedCategories, | |
| selectedLevels: $selectedLevels, | |
| categories: categories, | |
| levels: levels | |
| ) | |
| .presentationDetents([.height(200)]) | |
| } | |
| } | |
| } | |
| struct MultiSelectPicker<T: Hashable & CustomStringConvertible>: View { | |
| let title: String | |
| let options: [T] | |
| @Binding var selectedOptions: Set<T> | |
| var body: some View { | |
| Menu { | |
| ForEach(options, id: \.self) { option in | |
| Button(action: { | |
| toggleSelection(for: option) | |
| }) { | |
| Label( | |
| option.description, | |
| systemImage: selectedOptions.contains(option) ? "checkmark.square" : "square" | |
| ) | |
| } | |
| } | |
| } label: { | |
| HStack { | |
| Text(title) | |
| Spacer() | |
| Text(selectedOptions.isEmpty ? "None" : "\(selectedOptions.count) selected") | |
| .foregroundColor(.gray) | |
| } | |
| } | |
| } | |
| private func toggleSelection(for option: T) { | |
| if selectedOptions.contains(option) { | |
| selectedOptions.remove(option) | |
| } else { | |
| selectedOptions.insert(option) | |
| } | |
| } | |
| } | |
| extension OSLogEntryLog: @retroactive Identifiable {} | |
| extension UTType { | |
| var fileExtension: String { | |
| switch self { | |
| case .log: return "log" | |
| case .json: return "json" | |
| case .plainText: return "md" | |
| case .text: return "txt" | |
| case .commaSeparatedText: return "csv" | |
| default: return preferredFilenameExtension ?? "" | |
| } | |
| } | |
| var description: String { | |
| switch self { | |
| case .log: return "Log file" | |
| case .json: return "JSON" | |
| case .plainText: return "Markdown" | |
| case .text: return "Text file" | |
| case .commaSeparatedText: return "CSV" | |
| default: return preferredFilenameExtension ?? "" | |
| } | |
| } | |
| } | |
| import Foundation | |
| #if canImport(UIKit) | |
| import UIKit | |
| #else | |
| import AppKit | |
| #endif | |
| #if canImport(UIKit) | |
| public typealias PlatformColor = UIColor | |
| #else | |
| public typealias PlatformColor = NSColor | |
| #endif | |
| extension PlatformColor { | |
| var relativeLuminance: CGFloat { | |
| let components = self.toRGBAComponents() | |
| // Convert from sRGB to linear RGB | |
| let r = components.r < 0.04045 ? components.r / 12.92 : pow((components.r + 0.055) / 1.055, 2.4) | |
| let g = components.g < 0.04045 ? components.g / 12.92 : pow((components.g + 0.055) / 1.055, 2.4) | |
| let b = components.b < 0.04045 ? components.b / 12.92 : pow((components.b + 0.055) / 1.055, 2.4) | |
| // Calculate relative luminance (Y) | |
| let y = r * 0.2126 + g * 0.7152 + b * 0.0722 | |
| return min(max(y, 0), 1) | |
| } | |
| func contrastRatio(to otherColor: PlatformColor) -> CGFloat { | |
| let luminance1 = self.relativeLuminance | |
| let luminance2 = otherColor.relativeLuminance | |
| return (max(luminance1, luminance2) + 0.05) / (min(luminance1, luminance2) + 0.05) | |
| } | |
| } | |
| extension PlatformColor { | |
| struct RGBAComponents { | |
| var r: CGFloat = 0 | |
| var g: CGFloat = 0 | |
| var b: CGFloat = 0 | |
| var a: CGFloat = 0 | |
| } | |
| struct HSBComponents { | |
| var h: CGFloat = 0 | |
| var s: CGFloat = 0 | |
| var b: CGFloat = 0 | |
| var a: CGFloat = 0 | |
| } | |
| func toRGBAComponents() -> RGBAComponents { | |
| var components = RGBAComponents() | |
| #if canImport(UIKit) | |
| let result = self.getRed( | |
| &components.r, | |
| green: &components.g, | |
| blue: &components.b, | |
| alpha: &components.a | |
| ) | |
| assert(result, "Failed to get RGBA components from UIColor") | |
| #else | |
| if let rgbColor = self.usingColorSpace(.sRGB) { | |
| rgbColor.getRed( | |
| &components.r, | |
| green: &components.g, | |
| blue: &components.b, | |
| alpha: &components.a | |
| ) | |
| } else { | |
| assertionFailure("Failed to convert color space") | |
| } | |
| #endif | |
| return components | |
| } | |
| func toHSBComponents() -> HSBComponents { | |
| var components = HSBComponents() | |
| #if canImport(UIKit) | |
| let result = self.getHue( | |
| &components.h, | |
| saturation: &components.s, | |
| brightness: &components.b, | |
| alpha: &components.a | |
| ) | |
| assert(result, "Failed to get HSB components from UIColor") | |
| #else | |
| if let rgbColor = self.usingColorSpace(.sRGB) { | |
| rgbColor.getHue( | |
| &components.h, | |
| saturation: &components.s, | |
| brightness: &components.b, | |
| alpha: &components.a | |
| ) | |
| } else { | |
| assertionFailure("Failed to convert color space") | |
| } | |
| #endif | |
| return components | |
| } | |
| static func dynamicColor(_ block: @escaping () -> PlatformColor) -> PlatformColor { | |
| #if canImport(UIKit) | |
| #if os(watchOS) | |
| return block() | |
| #else | |
| return PlatformColor { _ in block() } | |
| #endif | |
| #else | |
| return PlatformColor(name: nil) { _ in block() } | |
| #endif | |
| } | |
| } | |
| extension PlatformColor { | |
| /// Creates a color from # prefix, alpha values, and 3 char shorthand hex values | |
| public convenience init?(hex: String) { | |
| let scanner = Scanner(string: hex) | |
| scanner.charactersToBeSkipped = nil | |
| _ = scanner.scanString("#") | |
| switch scanner.charactersLeft() { | |
| case 6, 8: | |
| guard let red = scanner.scanHexByte(), | |
| let green = scanner.scanHexByte(), | |
| let blue = scanner.scanHexByte() else { | |
| return nil | |
| } | |
| var alpha: UInt8 = 255 | |
| if scanner.charactersLeft() == 2 { | |
| guard let parsedAlpha = scanner.scanHexByte() else { | |
| return nil | |
| } | |
| alpha = parsedAlpha | |
| } | |
| self.init( | |
| red: CGFloat(red) / 255, | |
| green: CGFloat(green) / 255, | |
| blue: CGFloat(blue) / 255, | |
| alpha: CGFloat(alpha) / 255 | |
| ) | |
| case 3: | |
| guard let red = scanner.scanHexNibble(), | |
| let green = scanner.scanHexNibble(), | |
| let blue = scanner.scanHexNibble() else { | |
| return nil | |
| } | |
| self.init( | |
| red: CGFloat(red) / 15, | |
| green: CGFloat(green) / 15, | |
| blue: CGFloat(blue) / 15, | |
| alpha: 1 | |
| ) | |
| default: | |
| return nil | |
| } | |
| } | |
| public func toHex() -> String { | |
| var components = self.toRGBAComponents() | |
| // Clamp components to [0.0, 1.0] | |
| components.r = max(0, min(1, components.r)) | |
| components.g = max(0, min(1, components.g)) | |
| components.b = max(0, min(1, components.b)) | |
| components.a = max(0, min(1, components.a)) | |
| if components.a == 1 { | |
| // RGB | |
| return String( | |
| format: "#%02lX%02lX%02lX", | |
| Int(round(components.r * 255)), | |
| Int(round(components.g * 255)), | |
| Int(round(components.b * 255)) | |
| ) | |
| } else { | |
| // RGBA | |
| return String( | |
| format: "#%02lX%02lX%02lX%02lX", | |
| Int(round(components.r * 255)), | |
| Int(round(components.g * 255)), | |
| Int(round(components.b * 255)), | |
| Int(round(components.a * 255)) | |
| ) | |
| } | |
| } | |
| } | |
| private extension Scanner { | |
| func scanHexNibble() -> UInt8? { | |
| guard let character = scanCharacter(), character.isHexDigit else { | |
| return nil | |
| } | |
| return UInt8(String(character), radix: 16) | |
| } | |
| func scanHexByte() -> UInt8? { | |
| guard let highNibble = scanHexNibble(), let lowNibble = scanHexNibble() else { | |
| return nil | |
| } | |
| return (highNibble << 4) | lowNibble | |
| } | |
| func charactersLeft() -> Int { | |
| return string.count - currentIndex.utf16Offset(in: string) | |
| } | |
| } | |
| extension PlatformColor { | |
| public func lightening(by ratio: CGFloat) -> PlatformColor { | |
| return .dynamicColor { | |
| let components = self.toHSBComponents() | |
| let newBrightness = components.b != 0 | |
| ? components.b + (components.b * ratio) | |
| : ratio | |
| return PlatformColor( | |
| hue: components.h, | |
| saturation: components.s, | |
| brightness: min(newBrightness, 1), | |
| alpha: components.a | |
| ) | |
| } | |
| } | |
| public func darkening(by ratio: CGFloat) -> PlatformColor { | |
| return .dynamicColor { | |
| let components = self.toHSBComponents() | |
| let newBrightness = components.b != 1 | |
| ? components.b - (components.b * ratio) | |
| : 1 - ratio | |
| return PlatformColor( | |
| hue: components.h, | |
| saturation: components.s, | |
| brightness: max(newBrightness, 0), | |
| alpha: components.a | |
| ) | |
| } | |
| } | |
| } | |
| #if canImport(SwiftUI) | |
| import SwiftUI | |
| @available(macOS 11.0, iOS 14.0, tvOS 14.0, macCatalyst 14.0, watchOS 7.0, *) | |
| extension Color { | |
| var relativeLuminance: CGFloat { | |
| PlatformColor(self).relativeLuminance | |
| } | |
| init?(hex: String) { | |
| guard let color = PlatformColor(hex: hex) else { | |
| return nil | |
| } | |
| self.init(color) | |
| } | |
| public func toHex() -> String { | |
| return PlatformColor(self).toHex() | |
| } | |
| public func contrastRatio(to otherColor: Color) -> CGFloat { | |
| return PlatformColor(self).contrastRatio(to: PlatformColor(otherColor)) | |
| } | |
| public func lightening(by ratio: CGFloat) -> Color { | |
| return Color( | |
| PlatformColor(self).lightening(by: ratio) | |
| ) | |
| } | |
| public func darkening(by ratio: CGFloat) -> Color { | |
| return Color( | |
| PlatformColor(self).darkening(by: ratio) | |
| ) | |
| } | |
| } | |
| #endif | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment