Files
rune f5fd09ac33 Self-heal off-screen main window instead of requiring quit/relaunch
macOS's built-in window-restore (Resume) can place the window at
coordinates for a display that's no longer connected, leaving it
visible-but-invisible with no way to recover short of quitting. Now
re-centers automatically on app activate/Dock reopen, plus a manual
"Reset Window Position" menu command as a guaranteed fallback.
2026-08-18 14:20:17 +02:00

301 lines
15 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
}
// 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
}