Files
oai-swift/oAI/Services/DraftRecoveryService.swift
T
rune e3557a87df Add unsaved-changes save prompt and crash-recovery draft
Replaces heuristic auto-save (goodbye-phrase detection, idle timeout,
min-message count, on-model-switch) with a standard macOS unsaved-changes
gate (Save/Don't Save/Cancel) on New Chat, Clear Chat, Load Conversation,
and Quit. The Save dialog gained a folder picker with inline "New Folder…"
creation.

Separately, the in-progress conversation is periodically mirrored to disk
(DraftRecoveryService, configurable interval in Settings, default 10s) and
offered back on next launch if oAI crashes or is force-quit, including the
model that was selected.

Two real bugs found via ObjectIdentifier/log-based diagnosis before this
worked correctly:
- oAIApp.init() wired AppDelegate.chatViewModel from its own @State read,
  which returned a throwaway ChatViewModel instance distinct from the one
  ContentView actually renders. Wiring moved to ContentView.onAppear.
- NSApplication.shared.delegate as? AppDelegate always failed silently:
  @NSApplicationDelegateAdaptor registers an internal SwiftUI.AppDelegate
  wrapper as the real NSApp.delegate (same name, different type in a
  different module), which forwards protocol methods but isn't castable
  to our type. AppDelegate now tracks itself via a static `shared`.

Also guards checkForCrashRecoveryDraft() against running under
XCTestConfigurationFilePath — oAITests is app-hosted, so xcodebuild test
launches this same app, and a leftover draft file on disk would otherwise
hang the entire test run on a blocking NSAlert with no one to click it.
2026-07-31 08:11:12 +02:00

78 lines
2.7 KiB
Swift

//
// DraftRecoveryService.swift
// oAI
//
// Crash-recovery draft for the in-progress conversation — a lightweight,
// invisible mirror of the current chat, distinct from named saved conversations.
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling oAI or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>.
import Foundation
struct DraftConversation: Codable, Sendable {
let messages: [Message]
let conversationId: UUID?
let conversationName: String?
let modelId: String?
let savedAt: Date
}
final class DraftRecoveryService: Sendable {
static let shared = DraftRecoveryService()
private let fileURL: URL
/// - Parameter fileURL: injection point for tests; production uses the default
/// Application Support location, matching `DatabaseService`'s pattern.
nonisolated init(fileURL: URL? = nil) {
if let fileURL {
self.fileURL = fileURL
} else {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory,
in: .userDomainMask).first!
let dir = appSupport.appendingPathComponent("oAI", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
self.fileURL = dir.appendingPathComponent("draft_conversation.json")
}
}
func save(_ draft: DraftConversation) {
do {
let data = try JSONEncoder().encode(draft)
try data.write(to: fileURL, options: .atomic)
Log.db.info("DraftRecoveryService: wrote draft to \(fileURL.path)")
} catch {
Log.db.error("DraftRecoveryService: save failed: \(error.localizedDescription)")
}
}
func load() -> DraftConversation? {
do {
let data = try Data(contentsOf: fileURL)
return try JSONDecoder().decode(DraftConversation.self, from: data)
} catch {
Log.db.info("DraftRecoveryService: load found nothing (\(error.localizedDescription))")
return nil
}
}
func clear() {
try? FileManager.default.removeItem(at: fileURL)
}
}