// // 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 // for // the full license text. For commercial licensing, contact Rune // Olsen via . 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()"). 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 } // macOS's built-in window-restoration (Resume) can place the main window at coordinates // that no longer correspond to any connected display (e.g. it was last positioned on an // external monitor that's now disconnected). The window is still technically "visible" // (isVisible == true), so clicking the Dock icon or Cmd+Tab-ing to the app doesn't trigger // any of the usual recovery paths — the process runs fine, there's just nothing on screen. // Self-heal by re-centering any main-sized window that doesn't intersect a current screen, // both whenever the app becomes active and when the Dock icon is clicked with the app // already running. func applicationDidBecomeActive(_ notification: Notification) { AppDelegate.repositionOffscreenWindows() } func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { AppDelegate.repositionOffscreenWindows() return true } /// Pure geometry check, kept separate from AppKit so it's directly testable. nonisolated static func isFrameOffscreen(_ frame: CGRect, screenFrames: [CGRect]) -> Bool { !screenFrames.contains { $0.intersects(frame) } } /// Re-centers the main window if it's off-screen, or unconditionally when `force` is true /// (used by the manual "Reset Window Position" menu command). static func repositionOffscreenWindows(force: Bool = false) { let screenFrames = NSScreen.screens.map(\.frame) for window in NSApp.windows where window.isVisible && !(window is NSPanel) && window.frame.width > 200 && window.frame.height > 200 { if force || isFrameOffscreen(window.frame, screenFrames: screenFrames) { window.center() } window.makeKeyAndOrderFront(nil) } NSApp.activate(ignoringOtherApps: true) } // `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]) Divider() // Fallback for when the window ends up off-screen (stale macOS window-restore // coordinates, e.g. after an external monitor is disconnected) — see // AppDelegate.repositionOffscreenWindows. No keyboard shortcut assigned yet; // add one once it's been live-verified against macOS's reserved bindings. Button("Reset Window Position") { AppDelegate.repositionOffscreenWindows(force: true) } } // ── 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 }