import AppKit import CoreServices import UserNotifications import Sentry final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation { static var shared: AppDelegate? weak var tabManager: TabManager? weak var notificationStore: TerminalNotificationStore? weak var sidebarState: SidebarState? private var workspaceObserver: NSObjectProtocol? private let updateController = UpdateController() private lazy var titlebarAccessoryController = UpdateTitlebarAccessoryController(viewModel: updateViewModel) private let windowDecorationsController = WindowDecorationsController() var updateViewModel: UpdateViewModel { updateController.viewModel } override init() { super.init() Self.shared = self } func applicationDidFinishLaunching(_ notification: Notification) { SentrySDK.start { options in options.dsn = "https://ecba1ec90ecaee02a102fba931b6d2b3@o4507547940749312.ingest.us.sentry.io/4510796264636416" options.debug = true options.sendDefaultPii = true } registerLaunchServicesBundle() enforceSingleInstance() NSWindow.allowsAutomaticWindowTabbing = false disableNativeTabbingShortcut() ensureApplicationIcon() observeDuplicateLaunches() configureUserNotifications() updateController.startUpdater() titlebarAccessoryController.start() windowDecorationsController.start() #if DEBUG UpdateTestSupport.applyIfNeeded(to: updateController.viewModel) if ProcessInfo.processInfo.environment["CMUX_UI_TEST_TRIGGER_UPDATE_CHECK"] == "1" { DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in guard let self else { return } if UpdateTestSupport.performMockFeedCheckIfNeeded(on: self.updateController.viewModel) { return } self.updateController.checkForUpdatesWhenReady() } } #endif } func applicationDidBecomeActive(_ notification: Notification) { guard let tabManager, let notificationStore else { return } guard let tabId = tabManager.selectedTabId else { return } let surfaceId = tabManager.focusedSurfaceId(for: tabId) guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return } if let surfaceId, let tab = tabManager.tabs.first(where: { $0.id == tabId }) { tab.triggerNotificationFocusFlash(surfaceId: surfaceId, requiresSplit: false, shouldFocus: false) } notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId) } func applicationWillTerminate(_ notification: Notification) { notificationStore?.clearAll() } func configure(tabManager: TabManager, notificationStore: TerminalNotificationStore, sidebarState: SidebarState) { self.tabManager = tabManager self.notificationStore = notificationStore self.sidebarState = sidebarState } @objc func checkForUpdates(_ sender: Any?) { updateViewModel.overrideState = nil updateController.checkForUpdates() } #if DEBUG @objc func showUpdatePill(_ sender: Any?) { updateViewModel.overrideState = .notFound(.init(acknowledgement: {})) } @objc func showUpdatePillLoading(_ sender: Any?) { updateViewModel.overrideState = .checking(.init(cancel: {})) } @objc func hideUpdatePill(_ sender: Any?) { updateViewModel.overrideState = .idle } @objc func clearUpdatePillOverride(_ sender: Any?) { updateViewModel.overrideState = nil } @objc func copyUpdateLogs(_ sender: Any?) { let logText = UpdateLogStore.shared.snapshot() let payload: String if logText.isEmpty { payload = "No update logs captured.\nLog file: \(UpdateLogStore.shared.logPath())" } else { payload = logText + "\nLog file: \(UpdateLogStore.shared.logPath())" } let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(payload, forType: .string) } #endif #if DEBUG @objc func openDebugScrollbackTab(_ sender: Any?) { guard let tabManager else { return } let tab = tabManager.addTab() let config = GhosttyConfig.load() let lineCount = min(max(config.scrollbackLimit * 2, 2000), 60000) let command = "for i in {1..\(lineCount)}; do printf \"scrollback %06d\\n\" $i; done\n" sendTextWhenReady(command, to: tab) } private func sendTextWhenReady(_ text: String, to tab: Tab, attempt: Int = 0) { let maxAttempts = 60 if let surface = tab.focusedSurface, surface.surface != nil { surface.sendText(text) return } guard attempt < maxAttempts else { NSLog("Debug scrollback: surface not ready after \(maxAttempts) attempts") return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in self?.sendTextWhenReady(text, to: tab, attempt: attempt + 1) } } @objc func triggerSentryTestCrash(_ sender: Any?) { SentrySDK.crash() } #endif func attachUpdateAccessory(to window: NSWindow) { titlebarAccessoryController.start() titlebarAccessoryController.attach(to: window) } func applyWindowDecorations(to window: NSWindow) { windowDecorationsController.apply(to: window) } func toggleNotificationsPopover(animated: Bool = true) { titlebarAccessoryController.toggleNotificationsPopover(animated: animated) } func validateMenuItem(_ item: NSMenuItem) -> Bool { updateController.validateMenuItem(item) } private func configureUserNotifications() { let actions = [ UNNotificationAction( identifier: TerminalNotificationStore.actionShowIdentifier, title: "Show" ) ] let category = UNNotificationCategory( identifier: TerminalNotificationStore.categoryIdentifier, actions: actions, intentIdentifiers: [], options: [.customDismissAction] ) let center = UNUserNotificationCenter.current() center.setNotificationCategories([category]) center.delegate = self } private func disableNativeTabbingShortcut() { guard let menu = NSApp.mainMenu else { return } disableMenuItemShortcut(in: menu, action: #selector(NSWindow.toggleTabBar(_:))) } private func disableMenuItemShortcut(in menu: NSMenu, action: Selector) { for item in menu.items { if item.action == action { item.keyEquivalent = "" item.keyEquivalentModifierMask = [] item.isEnabled = false } if let submenu = item.submenu { disableMenuItemShortcut(in: submenu, action: action) } } } private func ensureApplicationIcon() { if let icon = NSImage(named: NSImage.applicationIconName) { NSApplication.shared.applicationIconImage = icon } } private func registerLaunchServicesBundle() { let bundleURL = Bundle.main.bundleURL.standardizedFileURL let registerStatus = LSRegisterURL(bundleURL as CFURL, true) if registerStatus != noErr { NSLog("LaunchServices registration failed (status: \(registerStatus)) for \(bundleURL.path)") } } private func enforceSingleInstance() { guard let bundleId = Bundle.main.bundleIdentifier else { return } let currentPid = ProcessInfo.processInfo.processIdentifier let currentURL = Bundle.main.bundleURL.standardizedFileURL for app in NSRunningApplication.runningApplications(withBundleIdentifier: bundleId) { guard app.processIdentifier != currentPid else { continue } if let url = app.bundleURL?.standardizedFileURL, url == currentURL { continue } app.terminate() if !app.isTerminated { _ = app.forceTerminate() } } } private func observeDuplicateLaunches() { guard let bundleId = Bundle.main.bundleIdentifier else { return } let currentPid = ProcessInfo.processInfo.processIdentifier let currentURL = Bundle.main.bundleURL.standardizedFileURL workspaceObserver = NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.didLaunchApplicationNotification, object: nil, queue: .main ) { [weak self] notification in guard self != nil else { return } guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return } guard app.bundleIdentifier == bundleId, app.processIdentifier != currentPid else { return } if let url = app.bundleURL?.standardizedFileURL, url == currentURL { return } app.terminate() if !app.isTerminated { _ = app.forceTerminate() } NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) } } func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { handleNotificationResponse(response) completionHandler() } func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { completionHandler([.banner, .sound, .list]) } private func handleNotificationResponse(_ response: UNNotificationResponse) { guard let tabIdString = response.notification.request.content.userInfo["tabId"] as? String, let tabId = UUID(uuidString: tabIdString) else { return } let surfaceId: UUID? = { guard let surfaceIdString = response.notification.request.content.userInfo["surfaceId"] as? String else { return nil } return UUID(uuidString: surfaceIdString) }() switch response.actionIdentifier { case UNNotificationDefaultActionIdentifier, TerminalNotificationStore.actionShowIdentifier: DispatchQueue.main.async { self.tabManager?.focusTabFromNotification(tabId, surfaceId: surfaceId) self.markReadIfFocused(response: response, tabId: tabId, surfaceId: surfaceId) } case UNNotificationDismissActionIdentifier: DispatchQueue.main.async { if let notificationId = UUID(uuidString: response.notification.request.identifier) { self.notificationStore?.markRead(id: notificationId) } else if let notificationIdString = response.notification.request.content.userInfo["notificationId"] as? String, let notificationId = UUID(uuidString: notificationIdString) { self.notificationStore?.markRead(id: notificationId) } } default: break } } private func markReadIfFocused(response: UNNotificationResponse, tabId: UUID, surfaceId: UUID?) { let notificationId: UUID? = { if let id = UUID(uuidString: response.notification.request.identifier) { return id } if let idString = response.notification.request.content.userInfo["notificationId"] as? String, let id = UUID(uuidString: idString) { return id } return nil }() guard let notificationId else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { guard let tabManager = self.tabManager else { return } guard tabManager.selectedTabId == tabId else { return } if let surfaceId { guard tabManager.focusedSurfaceId(for: tabId) == surfaceId else { return } } self.notificationStore?.markRead(id: notificationId) } } }