import AppKit import UserNotifications import OSLog import Sparkle import GhosttyKit class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, GhosttyAppDelegate { // The application logger. We should probably move this at some point to a dedicated // class/struct but for now it lives here! 🤷‍♂️ static let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, category: String(describing: AppDelegate.self) ) /// Various menu items so that we can programmatically sync the keyboard shortcut with the Ghostty config @IBOutlet private var menuServices: NSMenu? @IBOutlet private var menuCheckForUpdates: NSMenuItem? @IBOutlet private var menuOpenConfig: NSMenuItem? @IBOutlet private var menuReloadConfig: NSMenuItem? @IBOutlet private var menuSecureInput: NSMenuItem? @IBOutlet private var menuQuit: NSMenuItem? @IBOutlet private var menuNewWindow: NSMenuItem? @IBOutlet private var menuNewTab: NSMenuItem? @IBOutlet private var menuSplitRight: NSMenuItem? @IBOutlet private var menuSplitDown: NSMenuItem? @IBOutlet private var menuClose: NSMenuItem? @IBOutlet private var menuCloseWindow: NSMenuItem? @IBOutlet private var menuCloseAllWindows: NSMenuItem? @IBOutlet private var menuCopy: NSMenuItem? @IBOutlet private var menuPaste: NSMenuItem? @IBOutlet private var menuSelectAll: NSMenuItem? @IBOutlet private var menuToggleFullScreen: NSMenuItem? @IBOutlet private var menuZoomSplit: NSMenuItem? @IBOutlet private var menuPreviousSplit: NSMenuItem? @IBOutlet private var menuNextSplit: NSMenuItem? @IBOutlet private var menuSelectSplitAbove: NSMenuItem? @IBOutlet private var menuSelectSplitBelow: NSMenuItem? @IBOutlet private var menuSelectSplitLeft: NSMenuItem? @IBOutlet private var menuSelectSplitRight: NSMenuItem? @IBOutlet private var menuIncreaseFontSize: NSMenuItem? @IBOutlet private var menuDecreaseFontSize: NSMenuItem? @IBOutlet private var menuResetFontSize: NSMenuItem? @IBOutlet private var menuSlideTerminal: NSMenuItem? @IBOutlet private var menuTerminalInspector: NSMenuItem? @IBOutlet private var menuEqualizeSplits: NSMenuItem? @IBOutlet private var menuMoveSplitDividerUp: NSMenuItem? @IBOutlet private var menuMoveSplitDividerDown: NSMenuItem? @IBOutlet private var menuMoveSplitDividerLeft: NSMenuItem? @IBOutlet private var menuMoveSplitDividerRight: NSMenuItem? /// The dock menu private var dockMenu: NSMenu = NSMenu() /// This is only true before application has become active. private var applicationHasBecomeActive: Bool = false /// This is set in applicationDidFinishLaunching with the system uptime so we can determine the /// seconds since the process was launched. private var applicationLaunchTime: TimeInterval = 0 /// The ghostty global state. Only one per process. let ghostty: Ghostty.App = Ghostty.App() /// Manages our terminal windows. let terminalManager: TerminalManager /// Our slide terminal. This starts out uninitialized and only initializes if used. private var slideController: SlideTerminalController? = nil /// Manages updates let updaterController: SPUStandardUpdaterController let updaterDelegate: UpdaterDelegate = UpdaterDelegate() /// The elapsed time since the process was started var timeSinceLaunch: TimeInterval { return ProcessInfo.processInfo.systemUptime - applicationLaunchTime } override init() { terminalManager = TerminalManager(ghostty) updaterController = SPUStandardUpdaterController( // Important: we must not start the updater here because we need to read our configuration // first to determine whether we're automatically checking, downloading, etc. The updater // is started later in applicationDidFinishLaunching startingUpdater: false, updaterDelegate: updaterDelegate, userDriverDelegate: nil ) super.init() ghostty.delegate = self } //MARK: - NSApplicationDelegate func applicationWillFinishLaunching(_ notification: Notification) { UserDefaults.standard.register(defaults: [ // Disable the automatic full screen menu item because we handle // it manually. "NSFullScreenMenuItemEverywhere": false, ]) } func applicationDidFinishLaunching(_ notification: Notification) { // System settings overrides UserDefaults.standard.register(defaults: [ // Disable this so that repeated key events make it through to our terminal views. "ApplePressAndHoldEnabled": false, ]) // Store our start time applicationLaunchTime = ProcessInfo.processInfo.systemUptime // Check if secure input was enabled when we last quit. if (UserDefaults.standard.bool(forKey: "SecureInput") != SecureInput.shared.enabled) { toggleSecureInput(self) } // Hook up updater menu menuCheckForUpdates?.target = updaterController menuCheckForUpdates?.action = #selector(SPUStandardUpdaterController.checkForUpdates(_:)) // Initial config loading configDidReload(ghostty) // Start our update checker. updaterController.startUpdater() // Register our service provider. This must happen after everything is initialized. NSApp.servicesProvider = ServiceProvider() // This registers the Ghostty => Services menu to exist. NSApp.servicesMenu = menuServices // Configure user notifications let actions = [ UNNotificationAction(identifier: Ghostty.userNotificationActionShow, title: "Show") ] let center = UNUserNotificationCenter.current() center.setNotificationCategories([ UNNotificationCategory( identifier: Ghostty.userNotificationCategory, actions: actions, intentIdentifiers: [], options: [.customDismissAction] ) ]) center.delegate = self } func applicationDidBecomeActive(_ notification: Notification) { guard !applicationHasBecomeActive else { return } applicationHasBecomeActive = true // Let's launch our first window. We only do this if we have no other windows. It // is possible to have other windows in a few scenarios: // - if we're opening a URL since `application(_:openFile:)` is called before this. // - if we're restoring from persisted state if terminalManager.windows.count == 0 { terminalManager.newWindow() } } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return ghostty.config.shouldQuitAfterLastWindowClosed } func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { let windows = NSApplication.shared.windows if (windows.isEmpty) { return .terminateNow } // This probably isn't fully safe. The isEmpty check above is aspirational, it doesn't // quite work with SwiftUI because windows are retained on close. So instead we check // if there are any that are visible. I'm guessing this breaks under certain scenarios. if (windows.allSatisfy { !$0.isVisible }) { return .terminateNow } // If the user is shutting down, restarting, or logging out, we don't confirm quit. why: if let event = NSAppleEventManager.shared().currentAppleEvent { // If all Ghostty windows are in the background (i.e. you Cmd-Q from the Cmd-Tab // view), then this is null. I don't know why (pun intended) but we have to // guard against it. guard let keyword = AEKeyword("why?") else { break why } if let why = event.attributeDescriptor(forKeyword: keyword) { switch (why.typeCodeValue) { case kAEShutDown: fallthrough case kAERestart: fallthrough case kAEReallyLogOut: return .terminateNow default: break } } } // If our app says we don't need to confirm, we can exit now. if (!ghostty.needsConfirmQuit) { return .terminateNow } // We have some visible window. Show an app-wide modal to confirm quitting. let alert = NSAlert() alert.messageText = "Quit Ghostty?" alert.informativeText = "All terminal sessions will be terminated." alert.addButton(withTitle: "Close Ghostty") alert.addButton(withTitle: "Cancel") alert.alertStyle = .warning switch (alert.runModal()) { case .alertFirstButtonReturn: return .terminateNow default: return .terminateCancel } } /// This is called when the application is already open and someone double-clicks the icon /// or clicks the dock icon. func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { // If we have visible windows then we allow macOS to do its default behavior // of focusing one of them. guard !flag else { return true } // If we have any windows in our terminal manager we don't do anything. // This is possible with flag set to false if there a race where the // window is still initializing and is not visible but the user clicked // the dock icon. guard terminalManager.windows.count == 0 else { return true } // No visible windows, open a new one. terminalManager.newWindow() return false } func application(_ sender: NSApplication, openFile filename: String) -> Bool { // Ghostty will validate as well but we can avoid creating an entirely new // surface by doing our own validation here. We can also show a useful error // this way. var isDirectory = ObjCBool(true) guard FileManager.default.fileExists(atPath: filename, isDirectory: &isDirectory) else { return false } // Initialize the surface config which will be used to create the tab or window for the opened file. var config = Ghostty.SurfaceConfiguration() if (isDirectory.boolValue) { // When opening a directory, create a new tab in the main window with that as the working directory. // If no windows exist, a new one will be created. config.workingDirectory = filename terminalManager.newTab(withBaseConfig: config) } else { // When opening a file, open a new window with that file as the command, // and its parent directory as the working directory. config.command = filename config.workingDirectory = (filename as NSString).deletingLastPathComponent terminalManager.newWindow(withBaseConfig: config) } return true } /// This is called for the dock right-click menu. func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { return dockMenu } /// Sync all of our menu item keyboard shortcuts with the Ghostty configuration. private func syncMenuShortcuts() { guard ghostty.readiness == .ready else { return } syncMenuShortcut(action: "open_config", menuItem: self.menuOpenConfig) syncMenuShortcut(action: "reload_config", menuItem: self.menuReloadConfig) syncMenuShortcut(action: "quit", menuItem: self.menuQuit) syncMenuShortcut(action: "new_window", menuItem: self.menuNewWindow) syncMenuShortcut(action: "new_tab", menuItem: self.menuNewTab) syncMenuShortcut(action: "close_surface", menuItem: self.menuClose) syncMenuShortcut(action: "close_window", menuItem: self.menuCloseWindow) syncMenuShortcut(action: "close_all_windows", menuItem: self.menuCloseAllWindows) syncMenuShortcut(action: "new_split:right", menuItem: self.menuSplitRight) syncMenuShortcut(action: "new_split:down", menuItem: self.menuSplitDown) syncMenuShortcut(action: "copy_to_clipboard", menuItem: self.menuCopy) syncMenuShortcut(action: "paste_from_clipboard", menuItem: self.menuPaste) syncMenuShortcut(action: "select_all", menuItem: self.menuSelectAll) syncMenuShortcut(action: "toggle_split_zoom", menuItem: self.menuZoomSplit) syncMenuShortcut(action: "goto_split:previous", menuItem: self.menuPreviousSplit) syncMenuShortcut(action: "goto_split:next", menuItem: self.menuNextSplit) syncMenuShortcut(action: "goto_split:top", menuItem: self.menuSelectSplitAbove) syncMenuShortcut(action: "goto_split:bottom", menuItem: self.menuSelectSplitBelow) syncMenuShortcut(action: "goto_split:left", menuItem: self.menuSelectSplitLeft) syncMenuShortcut(action: "goto_split:right", menuItem: self.menuSelectSplitRight) syncMenuShortcut(action: "resize_split:up,10", menuItem: self.menuMoveSplitDividerUp) syncMenuShortcut(action: "resize_split:down,10", menuItem: self.menuMoveSplitDividerDown) syncMenuShortcut(action: "resize_split:right,10", menuItem: self.menuMoveSplitDividerRight) syncMenuShortcut(action: "resize_split:left,10", menuItem: self.menuMoveSplitDividerLeft) syncMenuShortcut(action: "equalize_splits", menuItem: self.menuEqualizeSplits) syncMenuShortcut(action: "increase_font_size:1", menuItem: self.menuIncreaseFontSize) syncMenuShortcut(action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize) syncMenuShortcut(action: "reset_font_size", menuItem: self.menuResetFontSize) syncMenuShortcut(action: "toggle_slide_terminal", menuItem: self.menuSlideTerminal) syncMenuShortcut(action: "inspector:toggle", menuItem: self.menuTerminalInspector) syncMenuShortcut(action: "toggle_secure_input", menuItem: self.menuSecureInput) // This menu item is NOT synced with the configuration because it disables macOS // global fullscreen keyboard shortcut. The shortcut in the Ghostty config will continue // to work but it won't be reflected in the menu item. // // syncMenuShortcut(action: "toggle_fullscreen", menuItem: self.menuToggleFullScreen) // Dock menu reloadDockMenu() } /// Syncs a single menu shortcut for the given action. The action string is the same /// action string used for the Ghostty configuration. private func syncMenuShortcut(action: String, menuItem: NSMenuItem?) { guard let menu = menuItem else { return } guard let equiv = ghostty.config.keyEquivalent(for: action) else { // No shortcut, clear the menu item menu.keyEquivalent = "" menu.keyEquivalentModifierMask = [] return } menu.keyEquivalent = equiv.key menu.keyEquivalentModifierMask = equiv.modifiers } private func focusedSurface() -> ghostty_surface_t? { return terminalManager.focusedSurface?.surface } //MARK: - Restorable State /// We support NSSecureCoding for restorable state. Required as of macOS Sonoma (14) but a good idea anyways. func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { return true } func application(_ app: NSApplication, willEncodeRestorableState coder: NSCoder) { Self.logger.debug("application will save window state") } func application(_ app: NSApplication, didDecodeRestorableState coder: NSCoder) { Self.logger.debug("application will restore window state") } //MARK: - UNUserNotificationCenterDelegate func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive: UNNotificationResponse, withCompletionHandler: () -> Void ) { ghostty.handleUserNotification(response: didReceive) withCompletionHandler() } func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent: UNNotification, withCompletionHandler: (UNNotificationPresentationOptions) -> Void ) { let shouldPresent = ghostty.shouldPresentNotification(notification: willPresent) let options: UNNotificationPresentationOptions = shouldPresent ? [.banner, .sound] : [] withCompletionHandler(options) } //MARK: - GhosttyAppDelegate func findSurface(forUUID uuid: UUID) -> Ghostty.SurfaceView? { for c in terminalManager.windows { if let v = c.controller.surfaceTree?.findUUID(uuid: uuid) { return v } } return nil } func configDidReload(_ state: Ghostty.App) { // Depending on the "window-save-state" setting we have to set the NSQuitAlwaysKeepsWindows // configuration. This is the only way to carefully control whether macOS invokes the // state restoration system. switch (ghostty.config.windowSaveState) { case "never": UserDefaults.standard.setValue(false, forKey: "NSQuitAlwaysKeepsWindows") case "always": UserDefaults.standard.setValue(true, forKey: "NSQuitAlwaysKeepsWindows") case "default": fallthrough default: UserDefaults.standard.removeObject(forKey: "NSQuitAlwaysKeepsWindows") } // Sync our auto-update settings updaterController.updater.automaticallyChecksForUpdates = ghostty.config.autoUpdate == .check || ghostty.config.autoUpdate == .download updaterController.updater.automaticallyDownloadsUpdates = ghostty.config.autoUpdate == .download // Config could change keybindings, so update everything that depends on that syncMenuShortcuts() terminalManager.relabelAllTabs() // Config could change window appearance. We wrap this in an async queue because when // this is called as part of application launch it can deadlock with an internal // AppKit mutex on the appearance. DispatchQueue.main.async { self.syncAppearance() } // Update all of our windows terminalManager.windows.forEach { window in window.controller.configDidReload() } // If we have configuration errors, we need to show them. let c = ConfigurationErrorsController.sharedInstance c.errors = state.config.errors if (c.errors.count > 0) { if (c.window == nil || !c.window!.isVisible) { c.showWindow(self) } } // We need to handle our global event tap depending on if there are global // events that we care about in Ghostty. if (ghostty_app_has_global_keybinds(ghostty.app!)) { if (timeSinceLaunch > 5) { // If the process has been running for awhile we enable right away // because no windows are likely to pop up. GlobalEventTap.shared.enable() } else { // If the process just started, we wait a couple seconds to allow // the initial windows and so on to load so our permissions dialog // doesn't get buried. DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { GlobalEventTap.shared.enable() } } } else { GlobalEventTap.shared.disable() } } /// Sync the appearance of our app with the theme specified in the config. private func syncAppearance() { guard let theme = ghostty.config.windowTheme else { return } switch (theme) { case "dark": let appearance = NSAppearance(named: .darkAqua) NSApplication.shared.appearance = appearance case "light": let appearance = NSAppearance(named: .aqua) NSApplication.shared.appearance = appearance case "auto": let color = OSColor(ghostty.config.backgroundColor) let appearance = NSAppearance(named: color.isLightColor ? .aqua : .darkAqua) NSApplication.shared.appearance = appearance default: NSApplication.shared.appearance = nil } } //MARK: - Dock Menu private func reloadDockMenu() { let newWindow = NSMenuItem(title: "New Window", action: #selector(newWindow), keyEquivalent: "") let newTab = NSMenuItem(title: "New Tab", action: #selector(newTab), keyEquivalent: "") dockMenu.removeAllItems() dockMenu.addItem(newWindow) dockMenu.addItem(newTab) } //MARK: - Global State func setSecureInput(_ mode: Ghostty.SetSecureInput) { let input = SecureInput.shared switch (mode) { case .on: input.global = true case .off: input.global = false case .toggle: input.global.toggle() } self.menuSecureInput?.state = if (input.global) { .on } else { .off } UserDefaults.standard.set(input.global, forKey: "SecureInput") } //MARK: - IB Actions @IBAction func openConfig(_ sender: Any?) { ghostty.openConfig() } @IBAction func reloadConfig(_ sender: Any?) { ghostty.reloadConfig() } @IBAction func newWindow(_ sender: Any?) { terminalManager.newWindow() // We also activate our app so that it becomes front. This may be // necessary for the dock menu. NSApp.activate(ignoringOtherApps: true) } @IBAction func newTab(_ sender: Any?) { terminalManager.newTab() // We also activate our app so that it becomes front. This may be // necessary for the dock menu. NSApp.activate(ignoringOtherApps: true) } @IBAction func closeAllWindows(_ sender: Any?) { terminalManager.closeAllWindows() AboutController.shared.hide() } @IBAction func showAbout(_ sender: Any?) { AboutController.shared.show() } @IBAction func showHelp(_ sender: Any) { guard let url = URL(string: "https://github.com/ghostty-org/ghostty") else { return } NSWorkspace.shared.open(url) } @IBAction func toggleSecureInput(_ sender: Any) { setSecureInput(.toggle) } @IBAction func toggleSlideTerminal(_ sender: Any) { if slideController == nil { slideController = SlideTerminalController(ghostty, baseConfig: nil) } guard let slideController = self.slideController else { return } slideController.slideToggle() } }