Files
yattee/Yattee/Services/Logging/LoggingService.swift
Arkadiusz Fal 4770bbe3b9 Remove dead code found by Periphery on all platforms
Delete declarations that Periphery reports unused on every platform that
compiles them (iOS, macOS, tvOS), including nine views used only by their
own previews. Platform-gated code still used on another platform, device-
only MPV render paths, migration models, CloudKit record names, App
Intents metadata and unused parameters are kept.
2026-10-08 23:57:56 +02:00

395 lines
13 KiB
Swift

//
// LoggingService.swift
// Yattee
//
// Centralized logging service for in-app log viewing.
//
import Foundation
import OSLog
/// Log entry severity level.
enum LogLevel: String, Codable, CaseIterable, Sendable {
case debug
case info
case warning
case error
var icon: String {
switch self {
case .debug: return "ant"
case .info: return "info.circle"
case .warning: return "exclamationmark.triangle"
case .error: return "xmark.circle"
}
}
}
/// Log entry category.
enum LogCategory: String, Codable, CaseIterable, Sendable {
case api = "API"
case player = "Player"
case mpv = "MPV"
case cloudKit = "CloudKit"
case downloads = "Downloads"
case navigation = "Navigation"
case notifications = "Notifications"
case remoteControl = "RemoteControl"
case keychain = "Keychain"
case imageLoading = "ImageLoading"
case mediaSources = "MediaSources"
case subscriptions = "Subscriptions"
case general = "General"
var icon: String {
switch self {
case .api: return "network"
case .player: return "play.circle"
case .mpv: return "film"
case .cloudKit: return "icloud"
case .downloads: return "arrow.down.circle"
case .navigation: return "arrow.triangle.turn.up.right.diamond"
case .notifications: return "bell.badge"
case .remoteControl: return "appletvremote.gen4"
case .keychain: return "key.fill"
case .imageLoading: return "photo"
case .mediaSources: return "externaldrive.connected.to.line.below"
case .subscriptions: return "person.2"
case .general: return "doc.text"
}
}
}
/// A single log entry.
struct LogEntry: Identifiable, Codable, Sendable {
let id: UUID
let timestamp: Date
let level: LogLevel
let category: LogCategory
let message: String
let details: String?
init(
id: UUID = UUID(),
timestamp: Date = Date(),
level: LogLevel,
category: LogCategory,
message: String,
details: String? = nil
) {
self.id = id
self.timestamp = timestamp
self.level = level
self.category = category
self.message = message
self.details = details
}
var formattedTimestamp: String {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss.SSS"
return formatter.string(from: timestamp)
}
}
/// Centralized logging service for in-app log viewing.
@MainActor
@Observable
final class LoggingService: Sendable {
// MARK: - Singleton
/// Shared singleton instance accessible from any isolation context.
/// Safe because instance logging methods are nonisolated (thread-safe via Task dispatch to MainActor).
nonisolated static let shared: LoggingService = {
MainActor.assumeIsolated {
LoggingService()
}
}()
// MARK: - Properties
/// Whether logging is enabled.
var isEnabled: Bool {
get { UserDefaults.standard.bool(forKey: "loggingEnabled") }
set {
UserDefaults.standard.set(newValue, forKey: "loggingEnabled")
if !newValue {
entries.removeAll()
}
}
}
/// Maximum number of log entries to keep.
var maxEntries: Int = 5000
/// All log entries.
private(set) var entries: [LogEntry] = []
/// Filtered entries based on current filter settings.
var filteredEntries: [LogEntry] {
var result = entries
if !selectedCategories.isEmpty {
result = result.filter { selectedCategories.contains($0.category) }
}
if !selectedLevels.isEmpty {
result = result.filter { selectedLevels.contains($0.level) }
}
if !searchText.isEmpty {
result = result.filter {
$0.message.localizedCaseInsensitiveContains(searchText) ||
($0.details?.localizedCaseInsensitiveContains(searchText) ?? false)
}
}
return result
}
/// Filter: selected categories.
var selectedCategories: Set<LogCategory> = []
/// Filter: selected levels.
var selectedLevels: Set<LogLevel> = []
/// Filter: search text.
var searchText: String = ""
// MARK: - Private
/// OSLogger instance. Logger is Sendable and thread-safe internally.
private let osLogger = Logger(subsystem: AppIdentifiers.logSubsystem, category: "LoggingService")
// MARK: - Initialization
private init() {}
// MARK: - Logging Methods
/// Log a debug message.
nonisolated func debug(_ message: String, category: LogCategory = .general, details: String? = nil) {
log(level: .debug, category: category, message: message, details: details)
}
/// Log an info message.
nonisolated func info(_ message: String, category: LogCategory = .general, details: String? = nil) {
log(level: .info, category: category, message: message, details: details)
}
/// Log a warning message.
nonisolated func warning(_ message: String, category: LogCategory = .general, details: String? = nil) {
log(level: .warning, category: category, message: message, details: details)
}
/// Log an error message.
nonisolated func error(_ message: String, category: LogCategory = .general, details: String? = nil) {
log(level: .error, category: category, message: message, details: details)
}
/// Log a message with the specified level.
/// This method is nonisolated to allow safe logging from any thread.
/// OSLog output is synchronous; in-app storage is dispatched to MainActor.
nonisolated func log(level: LogLevel, category: LogCategory, message: String, details: String? = nil) {
// Log to OSLog in DEBUG builds for Xcode console visibility
// OSLog/Logger is thread-safe, so this can be called from any thread.
// The master "Enable Logging" toggle gates this too - it takes
// precedence over everything, so logging off means a silent console.
// (Read UserDefaults directly: this runs off the MainActor.)
#if DEBUG
if UserDefaults.standard.bool(forKey: "loggingEnabled") {
let fullMessage = details.map { "\(message) - \($0)" } ?? message
// .public: without it os_log masks the text as <private> when the
// process isn't attached to Xcode (e.g. syslog capture over USB).
switch level {
case .debug:
osLogger.debug("[\(category.rawValue, privacy: .public)] \(fullMessage, privacy: .public)")
case .info:
osLogger.info("[\(category.rawValue, privacy: .public)] \(fullMessage, privacy: .public)")
case .warning:
osLogger.warning("[\(category.rawValue, privacy: .public)] \(fullMessage, privacy: .public)")
case .error:
osLogger.error("[\(category.rawValue, privacy: .public)] \(fullMessage, privacy: .public)")
}
}
#endif
// Dispatch in-app storage to MainActor asynchronously
// This ensures thread-safety for the entries array
Task { @MainActor [weak self] in
guard let self else { return }
guard self.isEnabled else { return }
let entry = LogEntry(
level: level,
category: category,
message: message,
details: details
)
self.entries.insert(entry, at: 0)
// Trim old entries
if self.entries.count > self.maxEntries {
self.entries = Array(self.entries.prefix(self.maxEntries))
}
}
}
// MARK: - Management
/// Clear all log entries.
func clearLogs() {
entries.removeAll()
}
/// Export logs as text.
func exportLogs() -> String {
let header = "Yattee Logs - Exported \(Date().formatted())\n"
let separator = String(repeating: "=", count: 60) + "\n"
let logLines = entries.reversed().map { entry in
let details = entry.details.map { "\n Details: \($0)" } ?? ""
return "[\(entry.formattedTimestamp)] [\(entry.level.rawValue.uppercased())] [\(entry.category.rawValue)] \(entry.message)\(details)"
}.joined(separator: "\n")
return header + separator + logLines
}
/// Reset filters.
func resetFilters() {
selectedCategories = []
selectedLevels = []
searchText = ""
}
}
// MARK: - Convenience Extensions
extension LoggingService {
/// Log an API request.
nonisolated func logAPIRequest(_ method: String, url: URL, details: String? = nil) {
info("\(method) \(url.absoluteString)", category: .api, details: details)
}
/// Log an API response.
nonisolated func logAPIResponse(_ url: URL, statusCode: Int, duration: TimeInterval) {
let durationMs = Int(duration * 1000)
info("Response \(statusCode) from \(url.host ?? url.absoluteString) (\(durationMs)ms)", category: .api)
}
/// Log an API error.
nonisolated func logAPIError(_ url: URL, error: Error) {
self.error("API Error: \(url.absoluteString)", category: .api, details: error.localizedDescription)
}
/// Log a player event.
nonisolated func logPlayer(_ message: String, details: String? = nil) {
info(message, category: .player, details: details)
}
/// Log a player error.
nonisolated func logPlayerError(_ message: String, error: Error? = nil) {
self.error(message, category: .player, details: error?.localizedDescription)
}
/// Log a CloudKit event.
nonisolated func logCloudKit(_ message: String, details: String? = nil) {
info(message, category: .cloudKit, details: details)
}
/// Log a CloudKit error.
nonisolated func logCloudKitError(_ message: String, error: Error? = nil) {
self.error(message, category: .cloudKit, details: error?.localizedDescription)
}
/// Log a download event.
nonisolated func logDownload(_ message: String, details: String? = nil) {
info(message, category: .downloads, details: details)
}
/// Log a download error.
nonisolated func logDownloadError(_ message: String, error: Error? = nil) {
self.error(message, category: .downloads, details: error?.localizedDescription)
}
/// Log a notification error.
nonisolated func logNotificationError(_ message: String, error: Error? = nil) {
self.error(message, category: .notifications, details: error?.localizedDescription)
}
/// Log a remote control event.
nonisolated func logRemoteControl(_ message: String, details: String? = nil) {
info(message, category: .remoteControl, details: details)
}
/// Log a remote control warning.
nonisolated func logRemoteControlWarning(_ message: String, details: String? = nil) {
warning(message, category: .remoteControl, details: details)
}
/// Log a remote control error.
nonisolated func logRemoteControlError(_ message: String, error: Error? = nil) {
self.error(message, category: .remoteControl, details: error?.localizedDescription)
}
/// Log a remote control debug message.
nonisolated func logRemoteControlDebug(_ message: String, details: String? = nil) {
debug(message, category: .remoteControl, details: details)
}
/// Log an MPV event.
nonisolated func logMPV(_ message: String, details: String? = nil) {
info(message, category: .mpv, details: details)
}
/// Log an MPV debug message.
nonisolated func logMPVDebug(_ message: String, details: String? = nil) {
debug(message, category: .mpv, details: details)
}
/// Log an MPV warning.
nonisolated func logMPVWarning(_ message: String, details: String? = nil) {
warning(message, category: .mpv, details: details)
}
/// Log an MPV error.
nonisolated func logMPVError(_ message: String, error: Error? = nil) {
self.error(message, category: .mpv, details: error?.localizedDescription)
}
// MARK: - Media Sources Logging
/// Log a media sources event.
nonisolated func logMediaSources(_ message: String, details: String? = nil) {
info(message, category: .mediaSources, details: details)
}
/// Log a media sources error.
nonisolated func logMediaSourcesError(_ message: String, error: Error? = nil) {
self.error(message, category: .mediaSources, details: error?.localizedDescription)
}
/// Log a media sources debug message.
nonisolated func logMediaSourcesDebug(_ message: String, details: String? = nil) {
debug(message, category: .mediaSources, details: details)
}
/// Log a media sources warning.
nonisolated func logMediaSourcesWarning(_ message: String, details: String? = nil) {
warning(message, category: .mediaSources, details: details)
}
// MARK: - Subscriptions Logging
/// Log a subscriptions event.
nonisolated func logSubscriptions(_ message: String, details: String? = nil) {
info(message, category: .subscriptions, details: details)
}
/// Log a subscriptions error.
nonisolated func logSubscriptionsError(_ message: String, error: Error? = nil) {
self.error(message, category: .subscriptions, details: error?.localizedDescription)
}
}