Skip to content

Instantly share code, notes, and snippets.

@adam-zethraeus
Last active September 2, 2025 20:54
Show Gist options
  • Select an option

  • Save adam-zethraeus/a8e4c364ed135266d7cec585341d819d to your computer and use it in GitHub Desktop.

Select an option

Save adam-zethraeus/a8e4c364ed135266d7cec585341d819d to your computer and use it in GitHub Desktop.
OSLog visualizer
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