Files
oai-swift/oAI/oAIApp.swift
T
rune 5f39448896 Add a CLI server bind timeout and an app-wide heartbeat log
Prompted by a real incident: Confab's process silently wedged, and
was indistinguishable from "just idle" after the fact — lsof showed
the CLI socket held open, every connection was refused, and nothing
had been logged for the rest of that session. No crash, no error.

- CLIServerService: NWListener's state transitions are normally
  near-instant, but had no bound on that assumption. Now times out
  after 8s if .ready is never reached, logging clearly and clearing
  the listener slot instead of silently pretending to work forever.
- oAIApp: a 5-minute heartbeat log line, otherwise meaningless on its
  own, turns "the log's gone quiet" from an ambiguous signal into a
  plain read of roughly when a future freeze started.

Neither fixes a known root cause (none was found - no crash report,
no error, just silence), so this is diagnostics and a narrow
failsafe, not a claim the underlying freeze is resolved.
2026-08-14 11:21:46 +02:00

256 lines
12 KiB
Swift

//
// oAIApp.swift
// Confab
//
// Main app entry point
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of Confab.
//
// Confab 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 Confab 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://confab.no>.
import SwiftUI
#if os(macOS)
import AppKit
/// Intercepts Quit so an unsaved conversation gets the standard "Do you want to save?" prompt
/// (via `ChatViewModel.confirmDiscardIfNeeded`) with a real chance to cancel the quit.
final class AppDelegate: NSObject, NSApplicationDelegate {
// `NSApplication.shared.delegate as? AppDelegate` (formerly used in ContentView.onAppear to
// wire `chatViewModel`) ALWAYS fails: `@NSApplicationDelegateAdaptor` registers an internal
// `SwiftUI.AppDelegate` wrapper as the real `NSApp.delegate`, which forwards protocol methods
// (like `applicationShouldTerminate`) to this instance — but querying `NSApp.delegate` returns
// that SwiftUI wrapper, a same-named-but-different type, not this class. Confirmed via logging
// ("delegate is Optional(<SwiftUI.AppDelegate: ...>)"). Track our own instance directly instead.
static var shared: AppDelegate?
var chatViewModel: ChatViewModel?
override init() {
super.init()
AppDelegate.shared = self
}
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
guard let chatViewModel, chatViewModel.hasUnsavedChanges else { return .terminateNow }
var shouldTerminate = false
chatViewModel.confirmDiscardIfNeeded(
then: { shouldTerminate = true },
onCancel: { shouldTerminate = false }
)
return shouldTerminate ? .terminateNow : .terminateCancel
}
// `chatViewModel` is wired from `ContentView.onAppear`, not from `oAIApp.init()` — reading
// the `@State private var chatViewModel` there returns a throwaway instance distinct from
// the one SwiftUI actually renders (confirmed via ObjectIdentifier logging: two different
// addresses), which silently broke crash-recovery restore. `ContentView.onAppear` reads the
// same environment-injected instance the view tree observes, so it's guaranteed correct.
}
#endif
@main
struct oAIApp: App {
@State private var chatViewModel = ChatViewModel()
@State private var showAbout = false
#if os(macOS)
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
#endif
init() {
// Start email handler on app launch
EmailHandlerService.shared.start()
// Start the local CLI server (Settings > Advanced > CLI Access) — no-op if disabled
CLIServerService.shared.start()
// Start external MCP servers
Task { @MainActor in ExternalMCPManager.shared.startAll() }
// Sync Git changes on app launch (pull + import)
Task {
await GitSyncService.shared.syncOnStartup()
}
// Reconcile starred models with the iCloud copy (cross-machine favorites sync)
Task {
await BackupService.shared.syncFavoritesOnLaunch()
}
// Check for updates in the background
UpdateCheckService.shared.checkForUpdates()
// Periodic heartbeat: on its own, a quiet Confab.log is ambiguous — it could mean the
// user just wasn't chatting, or that the app silently froze (confirmed possible on
// 2026-08-14: a stuck process left the CLI socket open per `lsof` yet refused every
// connection, with the log not written to at all for the rest of that session — no
// crash, no error, nothing to distinguish "idle" from "wedged" after the fact). A
// regular, otherwise-meaningless log line turns that ambiguity into a plain read: if the
// *next* freeze happens, the gap since the last heartbeat pins down roughly when.
Timer.scheduledTimer(withTimeInterval: 300, repeats: true) { _ in
Log.general.info("heartbeat")
}
}
var body: some Scene {
WindowGroup {
ContentView()
.environment(chatViewModel)
.preferredColorScheme(.dark)
.sheet(isPresented: $showAbout) {
AboutView()
}
#if os(macOS)
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
Task {
await BackupService.shared.syncFavoritesOnLaunch()
}
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
Task { @MainActor in ExternalMCPManager.shared.stopAll() }
CLIServerService.shared.stop()
}
#endif
}
#if os(macOS)
.windowStyle(.hiddenTitleBar)
.windowToolbarStyle(.unified)
.defaultSize(width: 1024, height: 800)
.windowResizability(.contentMinSize)
.commands {
// ── Apple menu ────────────────────────────────────────────────
CommandGroup(replacing: .appInfo) {
Button("About Confab") { showAbout = true }
}
CommandGroup(replacing: .appSettings) {
Button("Settings…") { chatViewModel.showSettings = true }
.keyboardShortcut(",", modifiers: .command)
}
// ── File menu ─────────────────────────────────────────────────
// Replacing .newItem removes the auto-added "New Window" entry
CommandGroup(replacing: .newItem) {
Button("New Chat") { chatViewModel.newConversation() }
.keyboardShortcut("n", modifiers: .command)
Button("Clear Chat") { chatViewModel.clearChat() }
.keyboardShortcut("k", modifiers: .command)
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
}
CommandGroup(after: .newItem) {
Button("Open Chat…") { chatViewModel.showConversations = true }
.keyboardShortcut("o", modifiers: .command)
Button("Search Conversations") { chatViewModel.showConversations = true }
.keyboardShortcut("l", modifiers: .command)
}
CommandGroup(replacing: .saveItem) {
Button("Save Chat") { chatViewModel.saveFromMenu() }
.keyboardShortcut("s", modifiers: .command)
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
Button("Save Chat As…") { chatViewModel.saveAsFromMenu() }
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
Button("Stats") { chatViewModel.showStats = true }
.keyboardShortcut("s", modifiers: [.command, .shift])
}
CommandGroup(after: .importExport) {
Button("Export as Markdown…") {
let name = chatViewModel.currentConversationName ?? "conversation"
let safe = name.components(separatedBy: .whitespacesAndNewlines).joined(separator: "-")
chatViewModel.exportConversationWithSavePanel(format: "md", defaultFilename: "\(safe).md")
}
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
Button("Export as HTML…") {
let name = chatViewModel.currentConversationName ?? "conversation"
let safe = name.components(separatedBy: .whitespacesAndNewlines).joined(separator: "-")
chatViewModel.exportConversationWithSavePanel(format: "html", defaultFilename: "\(safe).html")
}
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
Button("Export as PDF…") {
let name = chatViewModel.currentConversationName ?? "conversation"
let safe = name.components(separatedBy: .whitespacesAndNewlines).joined(separator: "-")
chatViewModel.exportConversationWithSavePanel(format: "pdf", defaultFilename: "\(safe).pdf")
}
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
}
// ── Chat menu ─────────────────────────────────────────────────
// Named "Chat" (not "View") to avoid colliding with the "View" menu
// macOS auto-adds for NavigationSplitView (Enter Full Screen, etc.) —
// two menus both titled "View" would otherwise appear in the menu bar.
CommandMenu("Chat") {
Button("Select Model") { chatViewModel.showModelSelector = true }
.keyboardShortcut("m", modifiers: .command)
Button("Model Info") {
chatViewModel.modelInfoTarget = chatViewModel.selectedModel
}
.keyboardShortcut("i", modifiers: .command)
.disabled(chatViewModel.selectedModel == nil)
Divider()
Button("Command History") { chatViewModel.showHistory = true }
.keyboardShortcut("h", modifiers: [.command, .shift])
Button("Credits") { chatViewModel.showCredits = true }
Divider()
Button(chatViewModel.onlineMode ? "Online Mode: On" : "Online Mode: Off") {
chatViewModel.onlineMode.toggle()
}
.keyboardShortcut("o", modifiers: [.command, .shift])
}
// ── Help menu ─────────────────────────────────────────────────
CommandGroup(replacing: .help) {
Button("Confab Help") { Self.openHelpBook() }
.keyboardShortcut("?", modifiers: .command)
Divider()
Button("Read Release Notes") {
let current = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
chatViewModel.releaseNotesRequest = ReleaseNotesRequest(versionTag: "v\(current)", isCurrentlyInstalled: true)
}
Button(UpdateCheckService.shared.isCheckingManually ? "Checking…" : "Check for Updates…") {
UpdateCheckService.shared.checkForUpdatesManually()
}
.disabled(UpdateCheckService.shared.isCheckingManually)
}
}
#endif
}
#if os(macOS)
/// Opens the Help Book's index.html directly in the default browser rather than through
/// NSHelpManager/Help Viewer — see CLAUDE.md's macOS 27 beta note for why (Apple's Tips.app
/// replacement for Help Viewer can't resolve anchors on this beta). Revisit once macOS 27
/// reaches RC. No anchor/fragment support: NSWorkspace.shared.open() silently drops #fragments
/// for file:// URLs before handing off to the browser (confirmed via location.hash coming back
/// empty in the opened page) — deep links into a specific section aren't reliable through this
/// API, so callers needing to point at specific content should show it in-app instead (see
/// GitSyncManualFixSheet for an example) rather than trying to anchor into this Help Book.
nonisolated static func openHelpBook() {
guard let helpBookURL = Bundle.main.url(forResource: "Confab.help", withExtension: nil) else { return }
let url = helpBookURL.appendingPathComponent("Contents/Resources/en.lproj/index.html")
NSWorkspace.shared.open(url)
}
#endif
}