Covers the per-file license-header comment (~80 Swift files) plus the contact/website links in README.md, PRIVACY.md, and SECURITY.md.
233 lines
11 KiB
Swift
233 lines
11 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 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()
|
|
}
|
|
|
|
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() }
|
|
}
|
|
#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") { openHelp() }
|
|
.keyboardShortcut("?", modifiers: .command)
|
|
Divider()
|
|
Button(UpdateCheckService.shared.isCheckingManually ? "Checking…" : "Check for Updates…") {
|
|
UpdateCheckService.shared.checkForUpdatesManually()
|
|
}
|
|
.disabled(UpdateCheckService.shared.isCheckingManually)
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
#if os(macOS)
|
|
private func openHelp() {
|
|
// 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.
|
|
if let helpBookURL = Bundle.main.url(forResource: "Confab.help", withExtension: nil) {
|
|
NSWorkspace.shared.open(helpBookURL.appendingPathComponent("Contents/Resources/en.lproj/index.html"))
|
|
}
|
|
}
|
|
#endif
|
|
}
|