Files
rune 8451db1142 UI redesign Phase 1: NavigationSplitView with collapsible sidebar
- Replace root VStack with NavigationSplitView (2-column, collapsible sidebar)
- Add SidebarView: new chat button, conversation search, list with swipe actions
- Slim HeaderView to text-only (provider + model + star); remove all icon rows
- Move status pills (Online, MCP, Synced) to footer right side
- Remove version number and shortcut hints from footer
- Add resizable InputBar with drag handle (persisted height) and globe/network.slash online toggle
- Fix Norwegian menu appearing on English systems (CFBundleLocalizations in Info.plist)
- Add View menu (Model Info, History, Stats, Credits, Online Mode toggle ⌘⇧O)
- Add ⌘L as alias for Search Conversations (muscle memory for /load users)
- Add Check for Updates to Help menu with download URL from Gitea API
- Add one-time Intel/Rosetta deprecation warning on first launch
- Swift 6: fix self.Self.isoString() call sites in DatabaseService

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 11:18:48 +02:00

157 lines
5.3 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"
}
}
static func < (lhs: LogLevel, rhs: LogLevel) -> Bool {
lhs.rawValue < rhs.rawValue
}
}
// MARK: - File Logger
final class FileLogger: @unchecked Sendable {
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 (read from UserDefaults for thread safety)
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()
}
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")
}