ReadKeep/readeck/UI/Utils/Logger.swift
Ilyas Hallak 997d740597 fix: Replace local() with url() for WKWebView font loading
Fix custom fonts not displaying in WKWebView by replacing CSS `src: local()`
with `src: url()` using bundle file paths. WKWebView cannot resolve fonts via
local() even when registered in Info.plist UIAppFonts.

Changes:
- Add fontFileNames and cssFontFamily properties to FontFamily enum
- Implement generateFontFaceCSS() helper in WebView.swift and NativeWebView.swift
- Generate @font-face CSS with file:// URLs for all 12 font files
- Fonts are now dynamically loaded from FontFamily enum (single source of truth)

Affected fonts:
- Literata (Regular, Bold)
- Merriweather (Regular, Bold)
- Source Serif 4 (Regular, Bold)
- Lato (Regular, Bold)
- Montserrat (Regular, Bold)
- Source Sans 3 (Regular, Bold)
2025-12-19 21:31:01 +01:00

305 lines
11 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// Logger.swift
// readeck
//
// Created by Ilyas Hallak on 16.08.25.
//
import Foundation
import os
// MARK: - Log Configuration
enum LogLevel: Int, CaseIterable, Codable {
case debug = 0
case info = 1
case notice = 2
case warning = 3
case error = 4
case critical = 5
var emoji: String {
switch self {
case .debug: return "🔍"
case .info: return ""
case .notice: return "📢"
case .warning: return "⚠️"
case .error: return ""
case .critical: return "💥"
}
}
}
enum LogCategory: String, CaseIterable, Codable {
case network = "Network"
case ui = "UI"
case data = "Data"
case auth = "Authentication"
case performance = "Performance"
case general = "General"
case manual = "Manual"
case viewModel = "ViewModel"
case sync = "Sync"
}
class LogConfiguration: ObservableObject {
static let shared = LogConfiguration()
@Published private var categoryLevels: [LogCategory: LogLevel] = [:]
@Published var globalMinLevel: LogLevel = .debug
@Published var showPerformanceLogs = true
@Published var showTimestamps = true
@Published var includeSourceLocation = true
@Published var isLoggingEnabled = false
private init() {
// First time setup: Enable logging in DEBUG builds with sensible defaults
#if DEBUG
if UserDefaults.standard.object(forKey: "LogConfigurationInitialized") == nil {
isLoggingEnabled = true
showPerformanceLogs = true
showTimestamps = true
includeSourceLocation = true
globalMinLevel = .debug
UserDefaults.standard.set(true, forKey: "LogConfigurationInitialized")
saveConfiguration()
}
#endif
loadConfiguration()
}
func setLevel(_ level: LogLevel, for category: LogCategory) {
categoryLevels[category] = level
saveConfiguration()
}
func getLevel(for category: LogCategory) -> LogLevel {
return categoryLevels[category] ?? globalMinLevel
}
func shouldLog(_ level: LogLevel, for category: LogCategory) -> Bool {
guard isLoggingEnabled else { return false }
let categoryLevel = getLevel(for: category)
return level.rawValue >= categoryLevel.rawValue
}
private func loadConfiguration() {
// Load from UserDefaults
if let data = UserDefaults.standard.data(forKey: "LogConfiguration"),
let config = try? JSONDecoder().decode([String: Int].self, from: data) {
for (categoryString, levelInt) in config {
if let category = LogCategory(rawValue: categoryString),
let level = LogLevel(rawValue: levelInt) {
categoryLevels[category] = level
}
}
}
globalMinLevel = LogLevel(rawValue: UserDefaults.standard.integer(forKey: "LogGlobalLevel")) ?? .debug
// Load boolean settings with defaults
if UserDefaults.standard.object(forKey: "LogShowPerformance") != nil {
showPerformanceLogs = UserDefaults.standard.bool(forKey: "LogShowPerformance")
}
if UserDefaults.standard.object(forKey: "LogShowTimestamps") != nil {
showTimestamps = UserDefaults.standard.bool(forKey: "LogShowTimestamps")
}
if UserDefaults.standard.object(forKey: "LogIncludeSourceLocation") != nil {
includeSourceLocation = UserDefaults.standard.bool(forKey: "LogIncludeSourceLocation")
}
if UserDefaults.standard.object(forKey: "LogIsEnabled") != nil {
isLoggingEnabled = UserDefaults.standard.bool(forKey: "LogIsEnabled")
}
}
private func saveConfiguration() {
let config = categoryLevels.mapKeys { $0.rawValue }.mapValues { $0.rawValue }
if let data = try? JSONEncoder().encode(config) {
UserDefaults.standard.set(data, forKey: "LogConfiguration")
}
UserDefaults.standard.set(globalMinLevel.rawValue, forKey: "LogGlobalLevel")
UserDefaults.standard.set(showPerformanceLogs, forKey: "LogShowPerformance")
UserDefaults.standard.set(showTimestamps, forKey: "LogShowTimestamps")
UserDefaults.standard.set(includeSourceLocation, forKey: "LogIncludeSourceLocation")
UserDefaults.standard.set(isLoggingEnabled, forKey: "LogIsEnabled")
}
}
struct Logger {
private let logger: os.Logger
private let category: LogCategory
private let config = LogConfiguration.shared
init(subsystem: String = Bundle.main.bundleIdentifier ?? "com.romm.app", category: LogCategory) {
self.logger = os.Logger(subsystem: subsystem, category: category.rawValue)
self.category = category
}
// MARK: - Log Levels
func debug(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
guard config.shouldLog(.debug, for: category) else { return }
let formattedMessage = formatMessage(message, level: .debug, file: file, function: function, line: line)
logger.debug("\(formattedMessage)")
storeLog(message: message, level: .debug, file: file, function: function, line: line)
}
func info(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
guard config.shouldLog(.info, for: category) else { return }
let formattedMessage = formatMessage(message, level: .info, file: file, function: function, line: line)
logger.info("\(formattedMessage)")
storeLog(message: message, level: .info, file: file, function: function, line: line)
}
func notice(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
guard config.shouldLog(.notice, for: category) else { return }
let formattedMessage = formatMessage(message, level: .notice, file: file, function: function, line: line)
logger.notice("\(formattedMessage)")
storeLog(message: message, level: .notice, file: file, function: function, line: line)
}
func warning(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
guard config.shouldLog(.warning, for: category) else { return }
let formattedMessage = formatMessage(message, level: .warning, file: file, function: function, line: line)
logger.warning("\(formattedMessage)")
storeLog(message: message, level: .warning, file: file, function: function, line: line)
}
func error(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
guard config.shouldLog(.error, for: category) else { return }
let formattedMessage = formatMessage(message, level: .error, file: file, function: function, line: line)
logger.error("\(formattedMessage)")
storeLog(message: message, level: .error, file: file, function: function, line: line)
}
func critical(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
guard config.shouldLog(.critical, for: category) else { return }
let formattedMessage = formatMessage(message, level: .critical, file: file, function: function, line: line)
logger.critical("\(formattedMessage)")
storeLog(message: message, level: .critical, file: file, function: function, line: line)
}
// MARK: - Store Log
private func storeLog(message: String, level: LogLevel, file: String, function: String, line: Int) {
#if DEBUG
guard config.isLoggingEnabled else { return }
let entry = LogEntry(
level: level,
category: category,
message: message,
file: file,
function: function,
line: line
)
Task {
await LogStore.shared.addEntry(entry)
}
#endif
}
// MARK: - Convenience Methods
func logNetworkRequest(method: String, url: String, statusCode: Int? = nil) {
guard config.shouldLog(.info, for: category) else { return }
if let statusCode = statusCode {
info("🌐 \(method) \(url) - Status: \(statusCode)")
} else {
info("🌐 \(method) \(url)")
}
}
func logNetworkError(method: String, url: String, error: Error) {
guard config.shouldLog(.error, for: category) else { return }
self.error("\(method) \(url) - Error: \(error.localizedDescription)")
}
func logPerformance(_ operation: String, duration: TimeInterval) {
guard config.showPerformanceLogs && config.shouldLog(.info, for: category) else { return }
info("⏱️ \(operation) completed in \(String(format: "%.3f", duration))s")
}
// MARK: - Private Helpers
private func formatMessage(_ message: String, level: LogLevel, file: String, function: String, line: Int) -> String {
var components: [String] = []
if config.showTimestamps {
let timestamp = DateFormatter.logTimestamp.string(from: Date())
components.append(timestamp)
}
components.append(level.emoji)
components.append("[\(category.rawValue)]")
if config.includeSourceLocation {
components.append("[\(sourceFileName(filePath: file)):\(line)]")
components.append(function)
}
components.append("-")
components.append(message)
return components.joined(separator: " ")
}
private func sourceFileName(filePath: String) -> String {
return URL(fileURLWithPath: filePath).lastPathComponent.replacingOccurrences(of: ".swift", with: "")
}
}
// MARK: - Category-specific Loggers
extension Logger {
static let network = Logger(category: .network)
static let ui = Logger(category: .ui)
static let data = Logger(category: .data)
static let auth = Logger(category: .auth)
static let performance = Logger(category: .performance)
static let general = Logger(category: .general)
static let manual = Logger(category: .manual)
static let viewModel = Logger(category: .viewModel)
static let sync = Logger(category: .sync)
}
// MARK: - Performance Measurement Helper
struct PerformanceMeasurement {
private let startTime = CFAbsoluteTimeGetCurrent()
private let operation: String
private let logger: Logger
init(operation: String, logger: Logger = .performance) {
self.operation = operation
self.logger = logger
logger.debug("🚀 Starting \(operation)")
}
func end() {
let duration = CFAbsoluteTimeGetCurrent() - startTime
logger.logPerformance(operation, duration: duration)
}
}
// MARK: - DateFormatter Extension
extension DateFormatter {
static let logTimestamp: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss.SSS"
return formatter
}()
}
// MARK: - Dictionary Extension
extension Dictionary {
func mapKeys<T>(_ transform: (Key) throws -> T) rethrows -> [T: Value] {
return try Dictionary<T, Value>(uniqueKeysWithValues: map { (try transform($0.key), $0.value) })
}
}