92e393ab03
- Message, Conversation, EmailLog: add nonisolated to inits — plain value types have no actor isolation, but the macOS 27 SDK was inferring it - EncryptionService: replace lazy var encryptionKey (which mutates self and gets inferred as @MainActor) with an eagerly-initialized let in init() - FileLogger: add nonisolated to shared, write, and minimumLevel so they are callable from nonisolated AppLogger methods without warnings - LogLevel.<: add nonisolated to the Comparable conformance method Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
157 lines
5.4 KiB
Swift
157 lines
5.4 KiB
Swift
//
|
|
// Logging.swift
|
|
// oAI
|
|
//
|
|
// Dual logging: os.Logger (unified log) + file (~Library/Logs/oAI.log)
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// Copyright (C) 2026 Rune Olsen
|
|
//
|
|
// This file is part of oAI.
|
|
//
|
|
// oAI is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as
|
|
// published by the Free Software Foundation, either version 3 of the
|
|
// License, or (at your option) any later version.
|
|
//
|
|
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
|
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
|
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
|
// Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public
|
|
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
|
|
import Foundation
|
|
import os
|
|
|
|
// MARK: - Log Level
|
|
|
|
enum LogLevel: Int, Comparable, CaseIterable, Sendable {
|
|
case debug = 0
|
|
case info = 1
|
|
case warning = 2
|
|
case error = 3
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .debug: return "DEBUG"
|
|
case .info: return "INFO"
|
|
case .warning: return "WARN"
|
|
case .error: return "ERROR"
|
|
}
|
|
}
|
|
|
|
var displayName: String {
|
|
switch self {
|
|
case .debug: return "Debug"
|
|
case .info: return "Info"
|
|
case .warning: return "Warning"
|
|
case .error: return "Error"
|
|
}
|
|
}
|
|
|
|
nonisolated static func < (lhs: LogLevel, rhs: LogLevel) -> Bool {
|
|
lhs.rawValue < rhs.rawValue
|
|
}
|
|
}
|
|
|
|
// MARK: - File Logger
|
|
|
|
final class FileLogger: @unchecked Sendable {
|
|
nonisolated static let shared = FileLogger()
|
|
|
|
private let fileHandle: FileHandle?
|
|
private let queue = DispatchQueue(label: "com.oai.filelogger")
|
|
private let dateFormatter: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
|
|
return f
|
|
}()
|
|
|
|
/// Current minimum log level (backed by UserDefaults — thread-safe).
|
|
nonisolated var minimumLevel: LogLevel {
|
|
get {
|
|
let raw = UserDefaults.standard.integer(forKey: "logLevel")
|
|
return LogLevel(rawValue: raw) ?? .info
|
|
}
|
|
set {
|
|
UserDefaults.standard.set(newValue.rawValue, forKey: "logLevel")
|
|
}
|
|
}
|
|
|
|
private init() {
|
|
let logsDir = FileManager.default.homeDirectoryForCurrentUser
|
|
.appendingPathComponent("Library/Logs")
|
|
let logFile = logsDir.appendingPathComponent("oAI.log")
|
|
|
|
// Ensure file exists
|
|
if !FileManager.default.fileExists(atPath: logFile.path) {
|
|
FileManager.default.createFile(atPath: logFile.path, contents: nil)
|
|
}
|
|
|
|
fileHandle = try? FileHandle(forWritingTo: logFile)
|
|
fileHandle?.seekToEndOfFile()
|
|
}
|
|
|
|
nonisolated func write(_ level: LogLevel, category: String, message: String) {
|
|
guard level >= minimumLevel else { return }
|
|
queue.async { [weak self] in
|
|
guard let self, let fh = self.fileHandle else { return }
|
|
let timestamp = self.dateFormatter.string(from: Date())
|
|
let line = "[\(timestamp)] [\(level.label)] [\(category)] \(message)\n"
|
|
if let data = line.data(using: .utf8) {
|
|
fh.write(data)
|
|
}
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
fileHandle?.closeFile()
|
|
}
|
|
}
|
|
|
|
// MARK: - App Logger (wraps os.Logger + file)
|
|
|
|
// os.Logger methods are @MainActor in macOS 27. AppLogger is Sendable and all methods are
|
|
// nonisolated — FileLogger runs on its own serial queue, os.Logger dispatches to main actor.
|
|
struct AppLogger: Sendable {
|
|
let subsystem: String
|
|
let category: String
|
|
|
|
nonisolated func debug(_ message: String) {
|
|
FileLogger.shared.write(.debug, category: category, message: message)
|
|
Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).debug("\(message, privacy: .public)") }
|
|
}
|
|
|
|
nonisolated func info(_ message: String) {
|
|
FileLogger.shared.write(.info, category: category, message: message)
|
|
Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).info("\(message, privacy: .public)") }
|
|
}
|
|
|
|
nonisolated func warning(_ message: String) {
|
|
FileLogger.shared.write(.warning, category: category, message: message)
|
|
Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).warning("\(message, privacy: .public)") }
|
|
}
|
|
|
|
nonisolated func error(_ message: String) {
|
|
FileLogger.shared.write(.error, category: category, message: message)
|
|
Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).error("\(message, privacy: .public)") }
|
|
}
|
|
}
|
|
|
|
// MARK: - Log Namespace
|
|
|
|
enum Log {
|
|
private nonisolated static let subsystem = "com.oai.oAI"
|
|
|
|
nonisolated static let api = AppLogger(subsystem: subsystem, category: "api")
|
|
nonisolated static let db = AppLogger(subsystem: subsystem, category: "database")
|
|
nonisolated static let mcp = AppLogger(subsystem: subsystem, category: "mcp")
|
|
nonisolated static let settings = AppLogger(subsystem: subsystem, category: "settings")
|
|
nonisolated static let search = AppLogger(subsystem: subsystem, category: "search")
|
|
nonisolated static let ui = AppLogger(subsystem: subsystem, category: "ui")
|
|
nonisolated static let general = AppLogger(subsystem: subsystem, category: "general")
|
|
}
|