ghostty/macos/Sources/App/macOS/AppDelegate.swift
2024-09-27 18:36:06 -07:00

563 lines
22 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()
}
}