Files
wehub-resource-sync bd44b5aa08
Test / build-libghostty-vt (x86_64-windows-gnu) (push) Has been cancelled
Test / build-libghostty-vt-macos (aarch64-ios) (push) Has been cancelled
Test / build-libghostty-vt-macos (aarch64-macos) (push) Has been cancelled
Test / build-libghostty-vt-macos (x86_64-macos) (push) Has been cancelled
Test / build-nix (namespace-profile-ghostty-md-arm64) (push) Has been cancelled
Test / build-dist (push) Has been cancelled
Test / GTK x11=true wayland=true (push) Has been cancelled
Test / Build -Dsimd=false (push) Has been cancelled
Test / Build -Dsimd=true (push) Has been cancelled
Test / Build -Dsentry=false (push) Has been cancelled
Test / Build -Dsentry=true (push) Has been cancelled
Test / zig-fmt (push) Has been cancelled
Test / GitHub Actions Pins (push) Has been cancelled
Test / prettier (push) Has been cancelled
Test / swiftlint (push) Has been cancelled
Test / alejandra (push) Has been cancelled
Test / typos (push) Has been cancelled
Nix / Required Checks: Nix (push) Has been cancelled
Nix / check-zig-cache-hash (push) Has been cancelled
Test / skip (push) Has been cancelled
Test / Required Checks: Test (push) Has been cancelled
Test / build-bench (push) Has been cancelled
Test / list-examples (push) Has been cancelled
Test / Example ${{ matrix.dir }} (Windows) (push) Has been cancelled
Test / build-cmake (push) Has been cancelled
Test / test-lib-vt-pkgconfig (push) Has been cancelled
Test / build-flatpak (push) Has been cancelled
Test / build-snap (push) Has been cancelled
Test / build-libghostty-vt (aarch64-linux) (push) Has been cancelled
Test / build-libghostty-vt (aarch64-macos) (push) Has been cancelled
Test / build-libghostty-vt (wasm32-freestanding) (push) Has been cancelled
Test / build-libghostty-vt (x86_64-linux) (push) Has been cancelled
Test / build-libghostty-vt (x86_64-linux-musl) (push) Has been cancelled
Test / build-libghostty-vt (x86_64-macos) (push) Has been cancelled
Test / build-libghostty-vt-android (aarch64-linux-android) (push) Has been cancelled
Test / build-libghostty-vt-android (arm-linux-androideabi) (push) Has been cancelled
Test / build-libghostty-vt-android (x86_64-linux-android) (push) Has been cancelled
Test / build-libghostty-vt-windows (push) Has been cancelled
Test / build-libghostty-windows-gnu (push) Has been cancelled
Test / build-linux (namespace-profile-ghostty-md) (push) Has been cancelled
Test / build-linux (namespace-profile-ghostty-md-arm64) (push) Has been cancelled
Test / build-linux-libghostty (push) Has been cancelled
Test / build-nix (namespace-profile-ghostty-md) (push) Has been cancelled
Test / build-dist-lib-vt (push) Has been cancelled
Test / trigger-snap (push) Has been cancelled
Test / trigger-flatpak (push) Has been cancelled
Test / build-macos (push) Has been cancelled
Test / build-macos-freetype (push) Has been cancelled
Test / test (push) Has been cancelled
Test / test-lib-vt (push) Has been cancelled
Test / GTK x11=false wayland=false (push) Has been cancelled
Test / GTK x11=true wayland=false (push) Has been cancelled
Test / GTK x11=false wayland=true (push) Has been cancelled
Test / test-macos (push) Has been cancelled
Test / test-windows (push) Has been cancelled
Test / Build -Di18n=false (push) Has been cancelled
Test / Build -Di18n=true (push) Has been cancelled
Test / Build test/fuzz-libghostty (push) Has been cancelled
Test / shellcheck (push) Has been cancelled
Test / translations (push) Has been cancelled
Test / blueprint-compiler (push) Has been cancelled
Test / Test pkg/wuffs (push) Has been cancelled
Test / Test build on Debian 13 (push) Has been cancelled
Test / valgrind (push) Has been cancelled
Test / Example ${{ matrix.dir }} (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:02:48 +08:00

1385 lines
57 KiB
Swift

import AppKit
import SwiftUI
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 menuAbout: NSMenuItem?
@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 menuSplitLeft: NSMenuItem?
@IBOutlet private var menuSplitDown: NSMenuItem?
@IBOutlet private var menuSplitUp: NSMenuItem?
@IBOutlet private var menuClose: NSMenuItem?
@IBOutlet private var menuCloseTab: NSMenuItem?
@IBOutlet private var menuCloseWindow: NSMenuItem?
@IBOutlet private var menuCloseAllWindows: NSMenuItem?
@IBOutlet private var menuUndo: NSMenuItem?
@IBOutlet private var menuRedo: NSMenuItem?
@IBOutlet private var menuCopy: NSMenuItem?
@IBOutlet private var menuPaste: NSMenuItem?
@IBOutlet private var menuPasteSelection: NSMenuItem?
@IBOutlet private var menuSelectAll: NSMenuItem?
@IBOutlet private var menuFindParent: NSMenuItem?
@IBOutlet private var menuFind: NSMenuItem?
@IBOutlet private var menuSelectionForFind: NSMenuItem?
@IBOutlet private var menuScrollToSelection: NSMenuItem?
@IBOutlet private var menuFindNext: NSMenuItem?
@IBOutlet private var menuFindPrevious: NSMenuItem?
@IBOutlet private var menuHideFindBar: NSMenuItem?
@IBOutlet private var menuToggleVisibility: NSMenuItem?
@IBOutlet private var menuToggleFullScreen: NSMenuItem?
@IBOutlet private var menuBringAllToFront: 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 menuReturnToDefaultSize: NSMenuItem?
@IBOutlet private var menuFloatOnTop: NSMenuItem?
@IBOutlet private var menuUseAsDefault: NSMenuItem?
@IBOutlet private var menuSetAsDefaultTerminal: NSMenuItem?
@IBOutlet private var menuIncreaseFontSize: NSMenuItem?
@IBOutlet private var menuDecreaseFontSize: NSMenuItem?
@IBOutlet private var menuResetFontSize: NSMenuItem?
@IBOutlet private var menuChangeTitle: NSMenuItem?
@IBOutlet private var menuChangeTabTitle: NSMenuItem?
@IBOutlet private var menuReadonly: NSMenuItem?
@IBOutlet private var menuQuickTerminal: NSMenuItem?
@IBOutlet private var menuTerminalInspector: NSMenuItem?
@IBOutlet private var menuCommandPalette: 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
/// This is the current configuration from the Ghostty configuration that we need.
private var derivedConfig: DerivedConfig = DerivedConfig()
/// The ghostty global state. Only one per process.
let ghostty: Ghostty.App
/// The global undo manager for app-level state such as window restoration.
lazy var undoManager = ExpiringUndoManager()
/// The current state of the quick terminal.
private var quickTerminalControllerState: QuickTerminalState = .uninitialized
/// Whether the quick terminal has already been initialized.
var quickControllerInitialized: Bool {
if case .initialized = quickTerminalControllerState {
return true
}
return false
}
/// Our quick terminal. This starts out uninitialized and only initializes if used.
var quickController: QuickTerminalController {
switch quickTerminalControllerState {
case .initialized(let controller):
return controller
case .pendingRestore(let state):
let controller = QuickTerminalController(
ghostty,
position: derivedConfig.quickTerminalPosition,
baseConfig: state.baseConfig,
restorationState: state
)
quickTerminalControllerState = .initialized(controller)
return controller
case .uninitialized:
let controller = QuickTerminalController(
ghostty,
position: derivedConfig.quickTerminalPosition,
restorationState: nil
)
quickTerminalControllerState = .initialized(controller)
return controller
}
}
/// Manages updates
let updateController = UpdateController()
var updateViewModel: UpdateViewModel {
updateController.viewModel
}
/// The elapsed time since the process was started
var timeSinceLaunch: TimeInterval {
return ProcessInfo.processInfo.systemUptime - applicationLaunchTime
}
/// Tracks the windows that we hid for toggleVisibility.
private(set) var hiddenState: ToggleVisibilityState?
/// The observer for the app appearance.
private var appearanceObserver: NSKeyValueObservation?
/// Signals
private var signals: [DispatchSourceSignal] = []
private let appIconUpdater = AppIconUpdater()
@MainActor private lazy var menuShortcutManager = Ghostty.MenuShortcutManager()
override init() {
#if DEBUG
ghostty = Ghostty.App(configPath: ProcessInfo.processInfo.environment["GHOSTTY_CONFIG_PATH"])
#else
ghostty = Ghostty.App()
#endif
super.init()
ghostty.delegate = self
}
// MARK: - NSApplicationDelegate
func applicationWillFinishLaunching(_ notification: Notification) {
#if DEBUG
if
let suite = UserDefaults.ghosttySuite,
let clear = ProcessInfo.processInfo.environment["GHOSTTY_CLEAR_USER_DEFAULTS"],
(clear as NSString).boolValue {
UserDefaults.ghostty.removePersistentDomain(forName: suite)
}
#endif
UserDefaults.ghostty.register(defaults: [
// Disable the automatic full screen menu item because we handle
// it manually.
"NSFullScreenMenuItemEverywhere": false,
// On macOS 26 RC1, the autofill heuristic controller causes unusable levels
// of slowdowns and CPU usage in the terminal window under certain [unknown]
// conditions. We don't know exactly why/how. This disables the full heuristic
// controller.
//
// Practically, this means things like SMS autofill don't work, but that is
// a desirable behavior to NOT have happen for a terminal, so this is a win.
// Manual autofill via the `Edit => AutoFill` menu item still work as expected.
"NSAutoFillHeuristicControllerEnabled": false,
])
}
func applicationDidFinishLaunching(_ notification: Notification) {
// System settings overrides
UserDefaults.ghostty.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.ghostty.bool(forKey: "SecureInput") != SecureInput.shared.enabled {
toggleSecureInput(self)
}
// Initial config loading
ghosttyConfigDidChange(config: ghostty.config)
// Start our update checker.
updateController.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
// Setup a local event monitor for app-level keyboard shortcuts. See
// localEventHandler for more info why.
_ = NSEvent.addLocalMonitorForEvents(
matching: [.keyDown],
handler: localEventHandler)
// Notifications
NotificationCenter.default.addObserver(
self,
selector: #selector(windowDidBecomeKey),
name: NSWindow.didBecomeKeyNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(quickTerminalDidChangeVisibility),
name: .quickTerminalDidChangeVisibility,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(ghosttyConfigDidChange(_:)),
name: .ghosttyConfigDidChange,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(ghosttyBellDidRing(_:)),
name: .ghosttyBellDidRing,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(terminalWindowHasBell(_:)),
name: .terminalWindowBellDidChangeNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(ghosttyNewWindow(_:)),
name: Ghostty.Notification.ghosttyNewWindow,
object: nil)
NotificationCenter.default.addObserver(
self,
selector: #selector(ghosttyNewTab(_:)),
name: Ghostty.Notification.ghosttyNewTab,
object: nil)
// 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
// Observe our appearance so we can report the correct value to libghostty.
self.appearanceObserver = NSApplication.shared.observe(
\.effectiveAppearance,
options: [.new, .initial]
) { _, change in
guard let appearance = change.newValue else { return }
guard let app = self.ghostty.app else { return }
let scheme: ghostty_color_scheme_e
if appearance.isDark {
scheme = GHOSTTY_COLOR_SCHEME_DARK
} else {
scheme = GHOSTTY_COLOR_SCHEME_LIGHT
}
ghostty_app_set_color_scheme(app, scheme)
}
// Setup our menu
setupMenuImages()
// Setup signal handlers
setupSignals()
switch Ghostty.launchSource {
case .app:
// Don't have to do anything.
break
case .zig_run, .cli:
// Part of launch services (clicking an app, using `open`, etc.) activates
// the application and brings it to the front. When using the CLI we don't
// get this behavior, so we have to do it manually.
// This never gets called until we click the dock icon. This forces it
// activate immediately.
applicationDidBecomeActive(.init(name: NSApplication.didBecomeActiveNotification))
// We run in the background, this forces us to the front.
DispatchQueue.main.async {
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
NSApp.unhide(nil)
NSApp.arrangeInFront(nil)
}
}
}
func applicationDidHide(_ notification: Notification) {
// Keep track of our hidden state to restore properly
self.hiddenState = .init()
}
func applicationDidBecomeActive(_ notification: Notification) {
// If we're back manually then clear the hidden state because macOS handles it.
self.hiddenState = nil
// First launch stuff
if !applicationHasBecomeActive {
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 TerminalController.all.isEmpty && derivedConfig.initialWindow {
undoManager.disableUndoRegistration()
_ = TerminalController.newWindow(ghostty)
undoManager.enableUndoRegistration()
}
}
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return derivedConfig.shouldQuitAfterLastWindowClosed
}
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
let windows = NSApplication.shared.windows
if windows.isEmpty { return .terminateNow }
// If we've already accepted to install an update, then we don't need to
// confirm quit. The user is already expecting the update to happen.
if updateController.isInstalling {
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.
//
// NOTE(mitchellh): I don't think we need this check at all anymore. I'm keeping it
// here because I don't want to remove it in a patch release cycle but we should
// target removing it soon.
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, kAERestart, kAEReallyLogOut:
return .terminateNow
default:
break
}
}
}
// If our app says we don't need to confirm, we can exit now.
if !ghostty.needsConfirmQuit { return .terminateNow }
return terminate()
}
func applicationWillTerminate(_ notification: Notification) {
// We have no notifications we want to persist after death,
// so remove them all now. In the future we may want to be
// more selective and only remove surface-targeted notifications.
UNUserNotificationCenter.current().removeAllDeliveredNotifications()
}
/// 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 TerminalController.all.isEmpty else { return true }
// If the application isn't active yet then we don't want to process
// this because we're not ready. This happens sometimes in Xcode runs
// but I haven't seen it happen in releases. I'm unsure why.
guard applicationHasBecomeActive else { return true }
// No visible windows, open a new one.
_ = TerminalController.newWindow(ghostty)
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 }
// Set to true if confirmation is required before starting up the
// new terminal.
var requiresConfirm: Bool = 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, check the configuration to decide
// whether to open in a new tab or new window.
config.workingDirectory = filename
} else {
// Unconditionally require confirmation in the file execution case.
// In the future I have ideas about making this more fine-grained if
// we can not inherit of unsandboxed state. For now, we need to confirm
// because there is a sandbox escape possible if a sandboxed application
// somehow is tricked into `open`-ing a non-sandboxed application.
requiresConfirm = true
// When opening a file, we want to execute the file. To do this, we
// don't override the command directly, because it won't load the
// profile/rc files for the shell, which is super important on macOS
// due to things like Homebrew. Instead, we set the command to
// `<filename>; exit` which is what Terminal and iTerm2 do.
config.initialInput = "\(Ghostty.Shell.quote(filename)); exit\n"
// For commands executed directly, we want to ensure we wait after exit
// because in most cases scripts don't block on exit and we don't want
// the window to just flash closed once complete.
config.waitAfterCommand = true
// Set the parent directory to our working directory so that relative
// paths in scripts work.
config.workingDirectory = (filename as NSString).deletingLastPathComponent
}
if requiresConfirm {
// Confirmation required. We use an app-wide NSAlert for now. In the future we
// may want to show this as a sheet on the focused window (especially if we're
// opening a tab). I'm not sure.
let alert = NSAlert()
alert.messageText = "Allow Ghostty to execute \"\(filename)\"?"
alert.addButton(withTitle: "Allow")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
switch alert.runModal() {
case .alertFirstButtonReturn:
break
default:
return false
}
}
switch ghostty.config.macosDockDropBehavior {
case .new_tab:
_ = TerminalController.newTab(
ghostty,
from: TerminalController.preferredParent?.window,
withBaseConfig: config
)
case .new_window: _ = TerminalController.newWindow(ghostty, withBaseConfig: config)
}
return true
}
/// Setup signal handlers
private func setupSignals() {
// Register a signal handler for config reloading. It appears that all
// of this is required. I've commented each line because its a bit unclear.
// Warning: signal handlers don't work when run via Xcode. They have to be
// run on a real app bundle.
// We need to ignore signals we register with makeSignalSource or they
// don't seem to handle.
signal(SIGUSR2, SIG_IGN)
// Make the signal source and register our event handle. We keep a weak
// ref to ourself so we don't create a retain cycle.
let sigusr2 = DispatchSource.makeSignalSource(signal: SIGUSR2, queue: .main)
sigusr2.setEventHandler { [weak self] in
guard let self else { return }
Ghostty.logger.info("reloading configuration in response to SIGUSR2")
self.ghostty.reloadConfig()
}
// The signal source starts unactivated, so we have to resume it once
// we setup the event handler.
sigusr2.resume()
// We need to keep a strong reference to it so it isn't disabled.
signals.append(sigusr2)
}
// MARK: Notifications and Events
/// This handles events from the NSEvent.addLocalEventMonitor. We use this so we can get
/// events without any terminal windows open.
private func localEventHandler(_ event: NSEvent) -> NSEvent? {
return switch event.type {
case .keyDown:
localEventKeyDown(event)
default:
event
}
}
private func localEventKeyDown(_ event: NSEvent) -> NSEvent? {
// If the tab overview is visible and escape is pressed, close it.
// This can't POSSIBLY be right and is probably a FirstResponder problem
// that we should handle elsewhere in our program. But this works and it
// is guarded by the tab overview currently showing.
if event.keyCode == 0x35, // Escape key
let window = NSApp.keyWindow,
let tabGroup = window.tabGroup,
tabGroup.isOverviewVisible {
window.toggleTabOverview(nil)
return nil
}
// If we have a main window then we don't process any of the keys
// because we let it capture and propagate.
guard NSApp.mainWindow == nil else { return event }
// If this event as-is would result in a key binding then we send it.
if let app = ghostty.app, let config = ghostty.config.config {
var ghosttyEvent = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)
let match = (event.characters ?? "").withCString { ptr in
ghosttyEvent.text = ptr
if !ghostty_config_key_is_binding(config, ghosttyEvent) {
return false
}
return ghostty_app_key(app, ghosttyEvent)
}
// If the key was handled by Ghostty we stop the event chain. If
// the key wasn't handled then we let it fall through and continue
// processing. This is important because some bindings may have no
// affect at this scope.
if match {
return nil
}
}
// If this event would be handled by our menu then we do nothing.
if let mainMenu = NSApp.mainMenu,
mainMenu.performKeyEquivalent(with: event) {
return nil
}
// If we reach this point then we try to process the key event
// through the Ghostty key mechanism.
// Ghostty must be loaded
guard let ghostty = self.ghostty.app else { return event }
// Build our event input and call ghostty
if ghostty_app_key(ghostty, event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)) {
// The key was used so we want to stop it from going to our Mac app
Ghostty.logger.debug("local key event handled event=\(event, privacy: .public)")
return nil
}
return event
}
@objc private func windowDidBecomeKey(_ notification: Notification) {
syncFloatOnTopMenu(notification.object as? NSWindow)
}
@objc private func quickTerminalDidChangeVisibility(_ notification: Notification) {
guard let quickController = notification.object as? QuickTerminalController else { return }
self.menuQuickTerminal?.state = if quickController.visible { .on } else { .off }
}
@objc private func ghosttyConfigDidChange(_ notification: Notification) {
// We only care if the configuration is a global configuration, not a surface one.
guard notification.object == nil else { return }
// Get our managed configuration object out
guard let config = notification.userInfo?[
Notification.Name.GhosttyConfigChangeKey
] as? Ghostty.Config else { return }
ghosttyConfigDidChange(config: config)
}
@objc private func ghosttyBellDidRing(_ notification: Notification) {
if ghostty.config.bellFeatures.contains(.system) {
NSSound.beep()
}
if ghostty.config.bellFeatures.contains(.audio) {
if let configPath = ghostty.config.bellAudioPath,
let sound = NSSound(contentsOfFile: configPath.path, byReference: false) {
sound.volume = ghostty.config.bellAudioVolume
sound.play()
}
}
if ghostty.config.bellFeatures.contains(.attention) {
// Bounce the dock icon if we're not focused.
NSApp.requestUserAttention(.informationalRequest)
}
}
@objc private func terminalWindowHasBell(_ notification: Notification) {
guard notification.object is BaseTerminalController else { return }
syncDockBadge()
}
private func requestBadgeAuthorizationAndSet(_ center: UNUserNotificationCenter) {
center.requestAuthorization(options: [.badge]) { granted, error in
if let error = error {
Self.logger.warning("Error requesting badge authorization: \(error, privacy: .public)")
return
}
// Permission granted, set the badge
if granted {
DispatchQueue.main.async {
self.setDockBadge()
}
}
}
}
private func syncDockBadge() {
let center = UNUserNotificationCenter.current()
center.getNotificationSettings { settings in
switch settings.authorizationStatus {
case .authorized:
// If we're authorized and allow badges, then set the badge.
if settings.badgeSetting == .enabled {
DispatchQueue.main.async {
self.setDockBadge()
}
} else if settings.badgeSetting == .notSupported {
// If badge setting is not supported, we may be in a sandbox that doesn't allow it.
// We can still attempt to set the badge and hope for the best, but we should also
// request authorization just in case it is a permissions issue.
self.requestBadgeAuthorizationAndSet(center)
}
case .notDetermined:
// Not determined yet, request authorization for badge
self.requestBadgeAuthorizationAndSet(center)
case .denied, .provisional, .ephemeral:
// In these known non-authorized states, do not attempt to set the badge.
break
@unknown default:
// Handle future unknown states by doing nothing.
break
}
}
}
@objc private func ghosttyNewWindow(_ notification: Notification) {
let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
let config = configAny as? Ghostty.SurfaceConfiguration
_ = TerminalController.newWindow(ghostty, withBaseConfig: config)
}
@objc private func ghosttyNewTab(_ notification: Notification) {
guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return }
guard let window = surfaceView.window else { return }
// We only want to listen to new tabs if the focused parent is
// a regular terminal controller.
guard window.windowController is TerminalController else { return }
let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
let config = configAny as? Ghostty.SurfaceConfiguration
_ = TerminalController.newTab(ghostty, from: window, withBaseConfig: config)
}
private func setDockBadge() {
let bellCount = NSApp.windows
.compactMap { $0.windowController as? BaseTerminalController }
.reduce(0) { $0 + ($1.bell ? 1 : 0) }
let wantsBadge = ghostty.config.bellFeatures.contains(.attention) && bellCount > 0
let label = wantsBadge ? (bellCount > 99 ? "99+" : String(bellCount)) : nil
NSApp.dockTile.badgeLabel = label
NSApp.dockTile.display()
}
private func ghosttyConfigDidChange(config: Ghostty.Config) {
// Update the config we need to store
self.derivedConfig = DerivedConfig(config)
// 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 config.windowSaveState {
case "never": UserDefaults.ghostty.setValue(false, forKey: "NSQuitAlwaysKeepsWindows")
case "always": UserDefaults.ghostty.setValue(true, forKey: "NSQuitAlwaysKeepsWindows")
case "default": fallthrough
default: UserDefaults.ghostty.removeObject(forKey: "NSQuitAlwaysKeepsWindows")
}
// Sync our auto-update settings. If SUEnableAutomaticChecks (in our Info.plist) is
// explicitly false (NO), auto-updates are disabled. Otherwise, we use the behavior
// defined by our "auto-update" configuration (if set) or fall back to Sparkle
// user-based defaults.
if Bundle.main.infoDictionary?["SUEnableAutomaticChecks"] as? Bool == false {
updateController.updater.automaticallyChecksForUpdates = false
updateController.updater.automaticallyDownloadsUpdates = false
} else if let autoUpdate = config.autoUpdate {
updateController.updater.automaticallyChecksForUpdates =
autoUpdate == .check || autoUpdate == .download
updateController.updater.automaticallyDownloadsUpdates =
autoUpdate == .download
/*
To test `auto-update` easily, uncomment the line below and
delete `SUEnableAutomaticChecks` in Ghostty-Info.plist.
Note: When `auto-update = download`, you may need to
`Clean Build Folder` if a background install has already begun.
*/
// updateController.updater.checkForUpdatesInBackground()
}
// Config could change keybindings, so update everything that depends on that
DispatchQueue.main.async {
self.syncMenuShortcuts(config)
}
TerminalController.all.forEach { $0.relabelTabs() }
// Update our badge since config can change what we show.
syncDockBadge()
// 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(config: config) }
// Decide whether to hide/unhide app from dock and app switcher
switch config.macosHidden {
case .never:
NSApp.setActivationPolicy(.regular)
case .always:
NSApp.setActivationPolicy(.accessory)
}
// If we have configuration errors, we need to show them.
let c = ConfigurationErrorsController.sharedInstance
c.errors = 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()
}
updateAppIcon(from: config)
}
/// Sync the appearance of our app with the theme specified in the config.
private func syncAppearance(config: Ghostty.Config) {
NSApplication.shared.appearance = .init(ghosttyConfig: config)
}
private func updateAppIcon(from config: Ghostty.Config) {
Task.detached {
await self.appIconUpdater.update(icon: AppIcon(config: config))
}
}
// 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) {
guard ghostty.config.windowSaveState != "never" else { return }
// Encode our quick terminal state if we have it.
switch quickTerminalControllerState {
case .initialized(let controller) where controller.restorable:
let data = QuickTerminalRestorableState(from: controller)
data.encode(with: coder)
case .pendingRestore(let state):
state.encode(with: coder)
default:
break
}
}
func application(_ app: NSApplication, didDecodeRestorableState coder: NSCoder) {
Self.logger.debug("application will restore window state")
// Decode our quick terminal state.
if ghostty.config.windowSaveState != "never",
let state = QuickTerminalRestorableState(coder: coder) {
quickTerminalControllerState = .pendingRestore(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 TerminalController.all {
for view in c.surfaceTree where view.id == uuid {
return view
}
}
return nil
}
// 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.ghostty.set(input.global, forKey: "SecureInput")
}
// MARK: - IB Actions
@IBAction func openConfig(_ sender: Any?) {
ghostty.openConfig()
}
@IBAction func reloadConfig(_ sender: Any?) {
ghostty.reloadConfig()
}
@IBAction func checkForUpdates(_ sender: Any?) {
updateController.checkForUpdates()
// UpdateSimulator.happyPath.simulate(with: updateViewModel)
}
@IBAction func newWindow(_ sender: Any?) {
_ = TerminalController.newWindow(ghostty)
}
@IBAction func newTab(_ sender: Any?) {
_ = TerminalController.newTab(
ghostty,
from: TerminalController.preferredParent?.window
)
}
@IBAction func closeAllWindows(_ sender: Any?) {
TerminalController.closeAllWindows()
AboutController.shared.hide()
}
@IBAction func showAbout(_ sender: Any?) {
AboutController.shared.show()
}
@IBAction func showHelp(_ sender: Any) {
guard let url = URL(string: "https://ghostty.org/docs") else { return }
NSWorkspace.shared.open(url)
}
@IBAction func toggleSecureInput(_ sender: Any) {
setSecureInput(.toggle)
}
@IBAction func toggleQuickTerminal(_ sender: Any) {
quickController.toggle()
}
/// Toggles visibility of all Ghosty Terminal windows. When hidden, activates Ghostty as the frontmost application
@IBAction func toggleVisibility(_ sender: Any) {
// If we have focus, then we hide all windows.
if NSApp.isActive {
// Toggle visibility doesn't do anything if the focused window is native
// fullscreen. This is only relevant if Ghostty is active.
guard let keyWindow = NSApp.keyWindow,
!keyWindow.styleMask.contains(.fullScreen) else { return }
NSApp.hide(nil)
return
}
// If we're not active, we want to become active
NSApp.activate(ignoringOtherApps: true)
// Bring all windows to the front. Note: we don't use NSApp.unhide because
// that will unhide ALL hidden windows. We want to only bring forward the
// ones that we hid.
hiddenState?.restore()
hiddenState = nil
}
@IBAction func bringAllToFront(_ sender: Any) {
if !NSApp.isActive {
NSApp.activate(ignoringOtherApps: true)
}
NSApplication.shared.arrangeInFront(sender)
}
@IBAction func undo(_ sender: Any?) {
undoManager.undo()
}
@IBAction func redo(_ sender: Any?) {
undoManager.redo()
}
private struct DerivedConfig {
let initialWindow: Bool
let shouldQuitAfterLastWindowClosed: Bool
let quickTerminalPosition: QuickTerminalPosition
init() {
self.initialWindow = true
self.shouldQuitAfterLastWindowClosed = false
self.quickTerminalPosition = .top
}
init(_ config: Ghostty.Config) {
self.initialWindow = config.initialWindow
self.shouldQuitAfterLastWindowClosed = config.shouldQuitAfterLastWindowClosed
self.quickTerminalPosition = config.quickTerminalPosition
}
}
struct ToggleVisibilityState {
let hiddenWindows: [Weak<NSWindow>]
let keyWindow: Weak<NSWindow>?
fileprivate init() {
// We need to know the key window so that we can bring focus back to the
// right window if it was hidden.
self.keyWindow = if let keyWindow = NSApp.keyWindow {
.init(keyWindow)
} else {
nil
}
// We need to keep track of the windows that were visible because we only
// want to bring back these windows if we remove the toggle.
//
// We also ignore fullscreen windows because they don't hide anyways.
var visibleWindows = [Weak<NSWindow>]()
NSApp.windows.filter {
$0.isVisible &&
!$0.styleMask.contains(.fullScreen)
}.forEach { window in
// We only keep track of selectedWindow if it's in a tabGroup,
// so we can keep its selection state when restoring
let windowToHide = window.tabGroup?.selectedWindow ?? window
if !visibleWindows.contains(where: { $0.value === windowToHide }) {
visibleWindows.append(Weak(windowToHide))
}
}
self.hiddenWindows = visibleWindows
}
func restore() {
hiddenWindows.forEach { $0.value?.orderFrontRegardless() }
keyWindow?.value?.makeKey()
}
}
}
// MARK: Menu
extension AppDelegate {
/// This is called for the dock right-click menu.
func applicationDockMenu(_ sender: NSApplication) -> NSMenu? {
return dockMenu
}
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)
}
/// Setup all the images for our menu items.
private func setupMenuImages() {
// Note: This COULD Be done all in the xib file, but I find it easier to
// modify this stuff as code.
self.menuAbout?.setImageIfDesired(systemSymbolName: "info.circle")
self.menuCheckForUpdates?.setImageIfDesired(systemSymbolName: "square.and.arrow.down")
self.menuOpenConfig?.setImageIfDesired(systemSymbolName: "gear")
self.menuReloadConfig?.setImageIfDesired(systemSymbolName: "arrow.trianglehead.2.clockwise.rotate.90")
self.menuSecureInput?.setImageIfDesired(systemSymbolName: "lock.display")
self.menuNewWindow?.setImageIfDesired(systemSymbolName: "macwindow.badge.plus")
self.menuNewTab?.setImageIfDesired(systemSymbolName: "macwindow")
self.menuSplitRight?.setImageIfDesired(systemSymbolName: "rectangle.righthalf.inset.filled")
self.menuSplitLeft?.setImageIfDesired(systemSymbolName: "rectangle.leadinghalf.inset.filled")
self.menuSplitUp?.setImageIfDesired(systemSymbolName: "rectangle.tophalf.inset.filled")
self.menuSplitDown?.setImageIfDesired(systemSymbolName: "rectangle.bottomhalf.inset.filled")
self.menuClose?.setImageIfDesired(systemSymbolName: "xmark")
self.menuPasteSelection?.setImageIfDesired(systemSymbolName: "doc.on.clipboard.fill")
self.menuIncreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.larger")
self.menuResetFontSize?.setImageIfDesired(systemSymbolName: "textformat.size")
self.menuDecreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.smaller")
self.menuCommandPalette?.setImageIfDesired(systemSymbolName: "filemenu.and.selection")
self.menuQuickTerminal?.setImageIfDesired(systemSymbolName: "apple.terminal")
self.menuChangeTabTitle?.setImageIfDesired(systemSymbolName: "pencil.line")
self.menuTerminalInspector?.setImageIfDesired(systemSymbolName: "scope")
self.menuReadonly?.setImageIfDesired(systemSymbolName: "eye.fill")
self.menuSetAsDefaultTerminal?.setImageIfDesired(systemSymbolName: "star.fill")
self.menuToggleFullScreen?.setImageIfDesired(systemSymbolName: "square.arrowtriangle.4.outward")
self.menuToggleVisibility?.setImageIfDesired(systemSymbolName: "eye")
self.menuZoomSplit?.setImageIfDesired(systemSymbolName: "arrow.up.left.and.arrow.down.right")
self.menuPreviousSplit?.setImageIfDesired(systemSymbolName: "chevron.backward.2")
self.menuNextSplit?.setImageIfDesired(systemSymbolName: "chevron.forward.2")
self.menuEqualizeSplits?.setImageIfDesired(systemSymbolName: "inset.filled.topleft.topright.bottomleft.bottomright.rectangle")
self.menuSelectSplitLeft?.setImageIfDesired(systemSymbolName: "arrow.left")
self.menuSelectSplitRight?.setImageIfDesired(systemSymbolName: "arrow.right")
self.menuSelectSplitAbove?.setImageIfDesired(systemSymbolName: "arrow.up")
self.menuSelectSplitBelow?.setImageIfDesired(systemSymbolName: "arrow.down")
self.menuMoveSplitDividerUp?.setImageIfDesired(systemSymbolName: "arrow.up.to.line")
self.menuMoveSplitDividerDown?.setImageIfDesired(systemSymbolName: "arrow.down.to.line")
self.menuMoveSplitDividerLeft?.setImageIfDesired(systemSymbolName: "arrow.left.to.line")
self.menuMoveSplitDividerRight?.setImageIfDesired(systemSymbolName: "arrow.right.to.line")
self.menuFloatOnTop?.setImageIfDesired(systemSymbolName: "square.filled.on.square")
self.menuFindParent?.setImageIfDesired(systemSymbolName: "text.page.badge.magnifyingglass")
}
/// Sync all of our menu item keyboard shortcuts with the Ghostty configuration.
@MainActor private func syncMenuShortcuts(_ config: Ghostty.Config) {
guard ghostty.readiness == .ready else { return }
menuShortcutManager.reset()
syncMenuShortcut(config, action: "check_for_updates", menuItem: self.menuCheckForUpdates)
syncMenuShortcut(config, action: "open_config", menuItem: self.menuOpenConfig)
syncMenuShortcut(config, action: "reload_config", menuItem: self.menuReloadConfig)
syncMenuShortcut(config, action: "quit", menuItem: self.menuQuit)
syncMenuShortcut(config, action: "new_window", menuItem: self.menuNewWindow)
syncMenuShortcut(config, action: "new_tab", menuItem: self.menuNewTab)
syncMenuShortcut(config, action: "close_surface", menuItem: self.menuClose)
syncMenuShortcut(config, action: "close_tab", menuItem: self.menuCloseTab)
syncMenuShortcut(config, action: "close_window", menuItem: self.menuCloseWindow)
syncMenuShortcut(config, action: "close_all_windows", menuItem: self.menuCloseAllWindows)
syncMenuShortcut(config, action: "new_split:right", menuItem: self.menuSplitRight)
syncMenuShortcut(config, action: "new_split:left", menuItem: self.menuSplitLeft)
syncMenuShortcut(config, action: "new_split:down", menuItem: self.menuSplitDown)
syncMenuShortcut(config, action: "new_split:up", menuItem: self.menuSplitUp)
syncMenuShortcut(config, action: "undo", menuItem: self.menuUndo)
syncMenuShortcut(config, action: "redo", menuItem: self.menuRedo)
syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy)
syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste)
syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection)
syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll)
syncMenuShortcut(config, action: "start_search", menuItem: self.menuFind)
syncMenuShortcut(config, action: "end_search", menuItem: self.menuHideFindBar)
syncMenuShortcut(config, action: "search_selection", menuItem: self.menuSelectionForFind)
syncMenuShortcut(config, action: "scroll_to_selection", menuItem: self.menuScrollToSelection)
syncMenuShortcut(config, action: "navigate_search:next", menuItem: self.menuFindNext)
syncMenuShortcut(config, action: "navigate_search:previous", menuItem: self.menuFindPrevious)
syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit)
syncMenuShortcut(config, action: "goto_split:previous", menuItem: self.menuPreviousSplit)
syncMenuShortcut(config, action: "goto_split:next", menuItem: self.menuNextSplit)
syncMenuShortcut(config, action: "goto_split:up", menuItem: self.menuSelectSplitAbove)
syncMenuShortcut(config, action: "goto_split:down", menuItem: self.menuSelectSplitBelow)
syncMenuShortcut(config, action: "goto_split:left", menuItem: self.menuSelectSplitLeft)
syncMenuShortcut(config, action: "goto_split:right", menuItem: self.menuSelectSplitRight)
syncMenuShortcut(config, action: "resize_split:up,10", menuItem: self.menuMoveSplitDividerUp)
syncMenuShortcut(config, action: "resize_split:down,10", menuItem: self.menuMoveSplitDividerDown)
syncMenuShortcut(config, action: "resize_split:right,10", menuItem: self.menuMoveSplitDividerRight)
syncMenuShortcut(config, action: "resize_split:left,10", menuItem: self.menuMoveSplitDividerLeft)
syncMenuShortcut(config, action: "equalize_splits", menuItem: self.menuEqualizeSplits)
syncMenuShortcut(config, action: "reset_window_size", menuItem: self.menuReturnToDefaultSize)
syncMenuShortcut(config, action: "increase_font_size:1", menuItem: self.menuIncreaseFontSize)
syncMenuShortcut(config, action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize)
syncMenuShortcut(config, action: "reset_font_size", menuItem: self.menuResetFontSize)
syncMenuShortcut(config, action: "prompt_surface_title", menuItem: self.menuChangeTitle)
syncMenuShortcut(config, action: "prompt_tab_title", menuItem: self.menuChangeTabTitle)
syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal)
syncMenuShortcut(config, action: "toggle_visibility", menuItem: self.menuToggleVisibility)
syncMenuShortcut(config, action: "toggle_window_float_on_top", menuItem: self.menuFloatOnTop)
syncMenuShortcut(config, action: "inspector:toggle", menuItem: self.menuTerminalInspector)
syncMenuShortcut(config, action: "toggle_command_palette", menuItem: self.menuCommandPalette)
syncMenuShortcut(config, 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(config, action: "toggle_fullscreen", menuItem: self.menuToggleFullScreen)
// Dock menu
reloadDockMenu()
}
@MainActor private func syncMenuShortcut(_ config: Ghostty.Config, action: String, menuItem: NSMenuItem?) {
menuShortcutManager.syncMenuShortcut(config, action: action, menuItem: menuItem)
}
@MainActor func performGhosttyBindingMenuKeyEquivalent(with event: NSEvent) -> Bool {
menuShortcutManager.performGhosttyBindingMenuKeyEquivalent(with: event)
}
}
// MARK: Floating Windows
extension AppDelegate {
func syncFloatOnTopMenu(_ window: NSWindow?) {
guard let window = (window ?? NSApp.keyWindow) as? TerminalWindow else {
// If some other window became key we always turn this off
self.menuFloatOnTop?.state = .off
return
}
self.menuFloatOnTop?.state = window.level == .floating ? .on : .off
}
@IBAction func floatOnTop(_ menuItem: NSMenuItem) {
menuItem.state = menuItem.state == .on ? .off : .on
guard let window = NSApp.keyWindow else { return }
window.level = menuItem.state == .on ? .floating : .normal
}
@IBAction func useAsDefault(_ sender: NSMenuItem) {
let ud = UserDefaults.ghostty
let key = TerminalWindow.defaultLevelKey
if menuFloatOnTop?.state == .on {
ud.set(NSWindow.Level.floating, forKey: key)
} else {
ud.removeObject(forKey: key)
}
}
@IBAction func setAsDefaultTerminal(_ sender: NSMenuItem) {
NSWorkspace.shared.setDefaultApplication(at: Bundle.main.bundleURL, toOpen: .unixExecutable) { error in
guard let error else { return }
Task { @MainActor in
let alert = NSAlert()
alert.messageText = "Failed to Set Default Terminal"
alert.informativeText = """
Ghostty could not be set as the default terminal application.
Error: \(error.localizedDescription)
"""
alert.alertStyle = .warning
alert.runModal()
}
}
}
}
// MARK: NSMenuItemValidation
extension AppDelegate: NSMenuItemValidation {
func validateMenuItem(_ item: NSMenuItem) -> Bool {
switch item.action {
case #selector(setAsDefaultTerminal(_:)):
return NSWorkspace.shared.defaultTerminal != Bundle.main.bundleURL
case #selector(floatOnTop(_:)),
#selector(useAsDefault(_:)):
// Float on top items only active if the key window is a primary
// terminal window (not quick terminal).
return NSApp.keyWindow is TerminalWindow
case #selector(undo(_:)):
if undoManager.canUndo {
item.title = "Undo \(undoManager.undoActionName)"
} else {
item.title = "Undo"
}
return undoManager.canUndo
case #selector(redo(_:)):
if undoManager.canRedo {
item.title = "Redo \(undoManager.redoActionName)"
} else {
item.title = "Redo"
}
return undoManager.canRedo
default:
return true
}
}
}
// MARK: - Termination Flow
extension AppDelegate {
func terminate() -> NSApplication.TerminateReply {
let controllersNeedConfirmation = NSApplication.shared.windows
.compactMap { $0.windowController as? BaseTerminalController }
.filter { !$0.windowCanBeClosedWithoutConfirmation() }
guard !controllersNeedConfirmation.isEmpty else {
return .terminateNow
}
if controllersNeedConfirmation.count == 1 {
Task {
let response = await controllersNeedConfirmation[0].confirmCloseAsync(
messageText: "Quit Ghostty?",
informativeText: "The terminal still has a running process. If you quit, the process will be killed.",
confirmButtonTitle: "Terminate",
)
if [.OK, .alertFirstButtonReturn].contains(response) {
await NSApp.reply(toApplicationShouldTerminate: true)
} else {
await NSApp.reply(toApplicationShouldTerminate: false)
}
}
return .terminateLater
} else {
let alert = NSAlert()
alert.messageText = "You have \(controllersNeedConfirmation.count) windows with running processes. Do you want to review these windows before quitting?"
alert.informativeText = "If you don't review your windows, any running processes will be terminated"
alert.addButton(withTitle: "Review Windows...")
alert.addButton(withTitle: "Terminate Processes")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
switch alert.runModal() {
case .alertFirstButtonReturn:
reviewWindows(controllersNeedConfirmation)
return .terminateLater
case .alertSecondButtonReturn:
return .terminateNow
default:
return .terminateCancel
}
}
}
private func reviewWindows(_ controllers: [BaseTerminalController]) {
Task {
for controller in controllers {
let response = await controller.confirmCloseAsync(
messageText: "Quit Ghostty?",
informativeText: "The terminal still has a running process. If you quit, the process will be killed.",
confirmButtonTitle: "Terminate",
)
if [.OK, .alertFirstButtonReturn].contains(response) {
// Close this window and until next review is cancelled
await controller.window?.close()
continue
} else {
await NSApp.reply(toApplicationShouldTerminate: false)
// Cancel the review
return
}
}
await NSApp.reply(toApplicationShouldTerminate: true)
}
}
}
/// Represents the state of the quick terminal controller.
private enum QuickTerminalState {
/// Controller has not been initialized and has no pending restoration state.
case uninitialized
/// Restoration state is pending; controller will use this when first accessed.
case pendingRestore(QuickTerminalRestorableState)
/// Controller has been initialized.
case initialized(QuickTerminalController)
}