mirror of
https://github.com/yattee/yattee.git
synced 2026-04-12 10:36:57 +00:00
Yattee v2 rewrite
This commit is contained in:
340
Yattee/Services/Logging/LogExportHTTPServer.swift
Normal file
340
Yattee/Services/Logging/LogExportHTTPServer.swift
Normal file
@@ -0,0 +1,340 @@
|
||||
//
|
||||
// LogExportHTTPServer.swift
|
||||
// Yattee
|
||||
//
|
||||
// Lightweight HTTP server for exporting logs on tvOS.
|
||||
// Uses NWListener to serve logs as a downloadable text file.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
#if os(tvOS)
|
||||
import Darwin
|
||||
import UIKit
|
||||
|
||||
/// Lightweight HTTP server for exporting logs on tvOS.
|
||||
/// Starts a temporary server that serves logs at /logs.txt for download.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class LogExportHTTPServer {
|
||||
// MARK: - State
|
||||
|
||||
/// Whether the server is currently running.
|
||||
private(set) var isRunning = false
|
||||
|
||||
/// The URL where logs can be downloaded (e.g., "http://192.168.1.50:8080/logs.txt").
|
||||
private(set) var serverURL: String?
|
||||
|
||||
/// The port the server is listening on.
|
||||
private(set) var port: UInt16?
|
||||
|
||||
/// Error message if server failed to start.
|
||||
private(set) var errorMessage: String?
|
||||
|
||||
/// Seconds remaining until auto-stop.
|
||||
private(set) var secondsRemaining: Int = 0
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private var listener: NWListener?
|
||||
private var autoStopTask: Task<Void, Never>?
|
||||
private var countdownTask: Task<Void, Never>?
|
||||
private let queue = DispatchQueue(label: "stream.yattee.logexport", qos: .userInitiated)
|
||||
|
||||
/// Auto-stop timeout in seconds (5 minutes).
|
||||
let autoStopTimeout: Int = 300
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Start the HTTP server.
|
||||
func start() {
|
||||
guard !isRunning else { return }
|
||||
|
||||
errorMessage = nil
|
||||
|
||||
guard let ipAddress = getLocalIPAddress() else {
|
||||
errorMessage = String(localized: "settings.advanced.logs.export.noNetwork")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let parameters = NWParameters.tcp
|
||||
parameters.acceptLocalOnly = true
|
||||
|
||||
// Use port 0 to let the system assign an available port
|
||||
let listener = try NWListener(using: parameters, on: .any)
|
||||
|
||||
listener.stateUpdateHandler = { [weak self] state in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handleListenerState(state, ipAddress: ipAddress)
|
||||
}
|
||||
}
|
||||
|
||||
listener.newConnectionHandler = { [weak self] connection in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handleConnection(connection)
|
||||
}
|
||||
}
|
||||
|
||||
listener.start(queue: queue)
|
||||
self.listener = listener
|
||||
isRunning = true
|
||||
secondsRemaining = autoStopTimeout
|
||||
startAutoStopTimer()
|
||||
startCountdownTimer()
|
||||
|
||||
LoggingService.shared.info("Log export HTTP server starting", category: .general)
|
||||
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
LoggingService.shared.error("Failed to start log export server", category: .general, details: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the HTTP server.
|
||||
func stop() {
|
||||
guard isRunning else { return }
|
||||
|
||||
listener?.cancel()
|
||||
listener = nil
|
||||
autoStopTask?.cancel()
|
||||
autoStopTask = nil
|
||||
countdownTask?.cancel()
|
||||
countdownTask = nil
|
||||
isRunning = false
|
||||
serverURL = nil
|
||||
port = nil
|
||||
secondsRemaining = 0
|
||||
|
||||
LoggingService.shared.info("Log export HTTP server stopped", category: .general)
|
||||
}
|
||||
|
||||
// MARK: - Private: Listener State
|
||||
|
||||
private func handleListenerState(_ state: NWListener.State, ipAddress: String) {
|
||||
switch state {
|
||||
case .ready:
|
||||
if let port = listener?.port?.rawValue {
|
||||
self.port = port
|
||||
self.serverURL = "http://\(ipAddress):\(port)"
|
||||
LoggingService.shared.info("Log export server ready", category: .general, details: serverURL)
|
||||
}
|
||||
|
||||
case .failed(let error):
|
||||
errorMessage = error.localizedDescription
|
||||
LoggingService.shared.error("Log export server failed", category: .general, details: error.localizedDescription)
|
||||
stop()
|
||||
|
||||
case .cancelled:
|
||||
isRunning = false
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private: Connection Handling
|
||||
|
||||
private func handleConnection(_ connection: NWConnection) {
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
self.receiveHTTPRequest(on: connection)
|
||||
case .failed, .cancelled:
|
||||
connection.cancel()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
connection.start(queue: queue)
|
||||
}
|
||||
|
||||
private nonisolated func receiveHTTPRequest(on connection: NWConnection) {
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self] data, _, _, error in
|
||||
guard let self, let data, error == nil else {
|
||||
connection.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
// Parse HTTP request (minimal parsing)
|
||||
if let request = String(data: data, encoding: .utf8) {
|
||||
Task { @MainActor in
|
||||
self.handleHTTPRequest(request, on: connection)
|
||||
}
|
||||
} else {
|
||||
connection.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleHTTPRequest(_ request: String, on connection: NWConnection) {
|
||||
// Check if it's a GET request for /logs.txt
|
||||
let lines = request.components(separatedBy: "\r\n")
|
||||
guard let requestLine = lines.first else {
|
||||
sendErrorResponse(on: connection, code: 400, message: "Bad Request")
|
||||
return
|
||||
}
|
||||
|
||||
let parts = requestLine.components(separatedBy: " ")
|
||||
guard parts.count >= 2 else {
|
||||
sendErrorResponse(on: connection, code: 400, message: "Bad Request")
|
||||
return
|
||||
}
|
||||
|
||||
let method = parts[0]
|
||||
var path = parts[1]
|
||||
|
||||
// Handle absolute URLs (some clients send full URL instead of just path)
|
||||
if path.hasPrefix("http://") || path.hasPrefix("https://") {
|
||||
if let url = URL(string: path) {
|
||||
path = url.path.isEmpty ? "/" : url.path
|
||||
}
|
||||
}
|
||||
|
||||
// Handle GET /logs.txt or GET /
|
||||
if method == "GET" && (path == "/logs.txt" || path == "/" || path.hasPrefix("/?")) {
|
||||
sendLogsResponse(on: connection)
|
||||
} else if method == "GET" && path == "/favicon.ico" {
|
||||
// Ignore favicon requests
|
||||
sendErrorResponse(on: connection, code: 404, message: "Not Found")
|
||||
} else {
|
||||
sendErrorResponse(on: connection, code: 404, message: "Not Found")
|
||||
}
|
||||
}
|
||||
|
||||
private func sendLogsResponse(on connection: NWConnection) {
|
||||
let logs = LoggingService.shared.exportLogs()
|
||||
let logsData = logs.data(using: .utf8) ?? Data()
|
||||
let filename = generateLogFilename()
|
||||
|
||||
let headers = """
|
||||
HTTP/1.1 200 OK\r
|
||||
Content-Type: text/plain; charset=utf-8\r
|
||||
Content-Disposition: attachment; filename="\(filename)"\r
|
||||
Content-Length: \(logsData.count)\r
|
||||
Connection: close\r
|
||||
\r
|
||||
|
||||
"""
|
||||
|
||||
var responseData = headers.data(using: .utf8) ?? Data()
|
||||
responseData.append(logsData)
|
||||
|
||||
connection.send(content: responseData, completion: .contentProcessed { _ in
|
||||
connection.cancel()
|
||||
})
|
||||
|
||||
LoggingService.shared.info("Logs downloaded via HTTP", category: .general)
|
||||
}
|
||||
|
||||
/// Generate a filename with device name, build number, and timestamp.
|
||||
private func generateLogFilename() -> String {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "yyyy-MM-dd_HH-mm-ss"
|
||||
let timestamp = dateFormatter.string(from: Date())
|
||||
|
||||
// Get device name and sanitize it for filename
|
||||
let deviceName = UIDevice.current.name
|
||||
.replacingOccurrences(of: " ", with: "-")
|
||||
.replacingOccurrences(of: "'", with: "")
|
||||
.filter { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" }
|
||||
|
||||
// Get build number
|
||||
let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0"
|
||||
|
||||
return "yattee-logs_\(deviceName)_b\(buildNumber)_\(timestamp).txt"
|
||||
}
|
||||
|
||||
private func sendErrorResponse(on connection: NWConnection, code: Int, message: String) {
|
||||
let body = "<html><body><h1>\(code) \(message)</h1></body></html>"
|
||||
let bodyData = body.data(using: .utf8) ?? Data()
|
||||
|
||||
let headers = """
|
||||
HTTP/1.1 \(code) \(message)\r
|
||||
Content-Type: text/html; charset=utf-8\r
|
||||
Content-Length: \(bodyData.count)\r
|
||||
Connection: close\r
|
||||
\r
|
||||
|
||||
"""
|
||||
|
||||
var responseData = headers.data(using: .utf8) ?? Data()
|
||||
responseData.append(bodyData)
|
||||
|
||||
connection.send(content: responseData, completion: .contentProcessed { _ in
|
||||
connection.cancel()
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: - Private: Timers
|
||||
|
||||
private func startAutoStopTimer() {
|
||||
autoStopTask?.cancel()
|
||||
autoStopTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
try? await Task.sleep(for: .seconds(autoStopTimeout))
|
||||
guard !Task.isCancelled else { return }
|
||||
self.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private func startCountdownTimer() {
|
||||
countdownTask?.cancel()
|
||||
countdownTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
while !Task.isCancelled && self.secondsRemaining > 0 {
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
guard !Task.isCancelled else { return }
|
||||
self.secondsRemaining -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private: IP Address Detection
|
||||
|
||||
/// Get the local IP address, preferring WiFi (en0) or Ethernet (en1).
|
||||
private func getLocalIPAddress() -> String? {
|
||||
var address: String?
|
||||
var ifaddr: UnsafeMutablePointer<ifaddrs>?
|
||||
|
||||
guard getifaddrs(&ifaddr) == 0, let firstAddr = ifaddr else { return nil }
|
||||
defer { freeifaddrs(ifaddr) }
|
||||
|
||||
// Prefer en0 (WiFi) or en1 (Ethernet on Apple TV)
|
||||
let preferredInterfaces = ["en0", "en1"]
|
||||
|
||||
for ptr in sequence(first: firstAddr, next: { $0.pointee.ifa_next }) {
|
||||
let interface = ptr.pointee
|
||||
let family = interface.ifa_addr.pointee.sa_family
|
||||
|
||||
guard family == UInt8(AF_INET) else { continue } // IPv4 only
|
||||
|
||||
let name = String(cString: interface.ifa_name)
|
||||
guard preferredInterfaces.contains(name) else { continue }
|
||||
|
||||
var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
getnameinfo(
|
||||
interface.ifa_addr,
|
||||
socklen_t(interface.ifa_addr.pointee.sa_len),
|
||||
&hostname,
|
||||
socklen_t(hostname.count),
|
||||
nil,
|
||||
0,
|
||||
NI_NUMERICHOST
|
||||
)
|
||||
address = String(cString: hostname)
|
||||
|
||||
// Prefer en0 if both exist
|
||||
if name == "en0" { break }
|
||||
}
|
||||
|
||||
return address
|
||||
}
|
||||
}
|
||||
#endif
|
||||
392
Yattee/Services/Logging/LoggingService.swift
Normal file
392
Yattee/Services/Logging/LoggingService.swift
Normal file
@@ -0,0 +1,392 @@
|
||||
//
|
||||
// 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
|
||||
#if DEBUG
|
||||
let fullMessage = details.map { "\(message) - \($0)" } ?? message
|
||||
switch level {
|
||||
case .debug:
|
||||
osLogger.debug("[\(category.rawValue)] \(fullMessage)")
|
||||
case .info:
|
||||
osLogger.info("[\(category.rawValue)] \(fullMessage)")
|
||||
case .warning:
|
||||
osLogger.warning("[\(category.rawValue)] \(fullMessage)")
|
||||
case .error:
|
||||
osLogger.error("[\(category.rawValue)] \(fullMessage)")
|
||||
}
|
||||
#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 event.
|
||||
nonisolated func logNotification(_ message: String, details: String? = nil) {
|
||||
info(message, category: .notifications, details: details)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user