Files
ghostty/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift
Mitchell Hashimoto 233fb12081 macos: add AppleScript front window and focused terminal properties
This adds two new propeties to make it easy to get the frontmost (main)
window and the focused terminal within a tab. We already had a property
to get the selected tab of a tab group.
2026-03-08 20:00:21 -07:00

352 lines
13 KiB
Swift

import AppKit
// Application-level Cocoa scripting hooks for the Ghostty AppleScript dictionary.
//
// Cocoa scripting is mostly convention-based: we do not register handlers in
// code, we expose Objective-C selectors with names Cocoa derives from
// `Ghostty.sdef`.
//
// In practical terms:
// - An `<element>` in `sdef` maps to an ObjC collection accessor.
// - Unique-ID element lookup maps to `valueIn...WithUniqueID:`.
// - Some `<command>` declarations map to `handle...ScriptCommand:`.
//
// This file implements the selectors Cocoa expects on `NSApplication`, which is
// the runtime object behind the `application` class in `Ghostty.sdef`.
// MARK: - Windows
@MainActor
extension NSApplication {
/// Backing collection for `application.windows`.
///
/// We expose one scripting window per native tab group so scripts see the
/// expected window/tab hierarchy instead of one AppKit window per tab.
///
/// Required selector name from the `sdef` element key: `scriptWindows`.
///
/// Cocoa scripting calls this whenever AppleScript evaluates a window list,
/// such as `windows`, `window 1`, or `every window whose ...`.
@objc(scriptWindows)
var scriptWindows: [ScriptWindow] {
guard isAppleScriptEnabled else { return [] }
// AppKit exposes one NSWindow per tab. AppleScript users expect one
// top-level window object containing multiple tabs, so we dedupe tab
// siblings into a single ScriptWindow.
var seen: Set<ObjectIdentifier> = []
var result: [ScriptWindow] = []
for controller in orderedTerminalControllers {
// Collapse each controller to one canonical representative for the
// whole tab group. Standalone windows map to themselves.
guard let primary = primaryTerminalController(for: controller) else {
continue
}
let primaryControllerID = ObjectIdentifier(primary)
guard seen.insert(primaryControllerID).inserted else {
// Another tab from this group already created the scripting
// window object.
continue
}
result.append(ScriptWindow(primaryController: primary))
}
return result
}
/// Exposed as the AppleScript `front window` property.
///
/// `scriptWindows` is already ordered front-to-back, so the first item is
/// the frontmost logical Ghostty window.
@objc(frontWindow)
var frontWindow: ScriptWindow? {
guard isAppleScriptEnabled else { return nil }
return scriptWindows.first
}
/// Enables AppleScript unique-ID lookup for window references.
///
/// Required selector name pattern for element key `scriptWindows`:
/// `valueInScriptWindowsWithUniqueID:`.
///
/// Cocoa calls this when a script resolves `window id "..."`.
/// Returning `nil` makes the object specifier fail naturally.
@objc(valueInScriptWindowsWithUniqueID:)
func valueInScriptWindows(uniqueID: String) -> ScriptWindow? {
guard isAppleScriptEnabled else { return nil }
return scriptWindows.first(where: { $0.stableID == uniqueID })
}
}
// MARK: - Terminals
@MainActor
extension NSApplication {
/// Backing collection for `application.terminals`.
///
/// Required selector name: `terminals`.
@objc(terminals)
var terminals: [ScriptTerminal] {
guard isAppleScriptEnabled else { return [] }
return allSurfaceViews.map(ScriptTerminal.init)
}
/// Enables AppleScript unique-ID lookup for terminal references.
///
/// Required selector name pattern for element `terminals`:
/// `valueInTerminalsWithUniqueID:`.
///
/// This is what lets scripts do stable references like
/// `terminal id "..."` even as windows/tabs change.
@objc(valueInTerminalsWithUniqueID:)
func valueInTerminals(uniqueID: String) -> ScriptTerminal? {
guard isAppleScriptEnabled else { return nil }
return allSurfaceViews
.first(where: { $0.id.uuidString == uniqueID })
.map(ScriptTerminal.init)
}
}
// MARK: - Commands
@MainActor
extension NSApplication {
/// Handler for the `perform action` AppleScript command.
///
/// Required selector name from the command in `sdef`:
/// `handlePerformActionScriptCommand:`.
///
/// Cocoa scripting parses script syntax and provides:
/// - `directParameter`: the command string (`perform action "..."`).
/// - `evaluatedArguments["on"]`: the target terminal (`... on terminal ...`).
///
/// We return a Bool to match the command's declared result type.
@objc(handlePerformActionScriptCommand:)
func handlePerformActionScriptCommand(_ command: NSScriptCommand) -> NSNumber? {
guard validateScript(command: command) else { return nil }
guard let action = command.directParameter as? String else {
command.scriptErrorNumber = errAEParamMissed
command.scriptErrorString = "Missing action string."
return nil
}
guard let terminal = command.evaluatedArguments?["on"] as? ScriptTerminal else {
command.scriptErrorNumber = errAEParamMissed
command.scriptErrorString = "Missing terminal target."
return nil
}
return NSNumber(value: terminal.perform(action: action))
}
/// Handler for creating a reusable AppleScript surface configuration object.
@objc(handleNewSurfaceConfigurationScriptCommand:)
func handleNewSurfaceConfigurationScriptCommand(_ command: NSScriptCommand) -> NSDictionary? {
guard validateScript(command: command) else { return nil }
do {
let configuration = try Ghostty.SurfaceConfiguration(
scriptRecord: command.evaluatedArguments?["configuration"] as? NSDictionary
)
return configuration.dictionaryRepresentation
} catch {
command.scriptErrorNumber = errAECoercionFail
command.scriptErrorString = error.localizedDescription
return nil
}
}
/// Handler for the `new window` AppleScript command.
///
/// Required selector name from the command in `sdef`:
/// `handleNewWindowScriptCommand:`.
///
/// Accepts an optional reusable surface configuration object.
///
/// Returns the newly created scripting window object.
@objc(handleNewWindowScriptCommand:)
func handleNewWindowScriptCommand(_ command: NSScriptCommand) -> ScriptWindow? {
guard validateScript(command: command) else { return nil }
guard let appDelegate = delegate as? AppDelegate else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Ghostty app delegate is unavailable."
return nil
}
let baseConfig: Ghostty.SurfaceConfiguration?
if let scriptRecord = command.evaluatedArguments?["configuration"] as? NSDictionary {
do {
baseConfig = try Ghostty.SurfaceConfiguration(scriptRecord: scriptRecord)
} catch {
command.scriptErrorNumber = errAECoercionFail
command.scriptErrorString = error.localizedDescription
return nil
}
} else {
baseConfig = nil
}
let controller = TerminalController.newWindow(
appDelegate.ghostty,
withBaseConfig: baseConfig
)
let createdWindowID = ScriptWindow.stableID(primaryController: controller)
if let scriptWindow = scriptWindows.first(where: { $0.stableID == createdWindowID }) {
return scriptWindow
}
// Fall back to wrapping the created controller if AppKit window ordering
// has not refreshed yet in the current run loop.
return ScriptWindow(primaryController: controller)
}
/// Handler for the `quit` AppleScript command.
///
/// Required selector name from the command in `sdef`:
/// `handleQuitScriptCommand:`.
@objc(handleQuitScriptCommand:)
func handleQuitScriptCommand(_ command: NSScriptCommand) {
guard validateScript(command: command) else { return }
terminate(nil)
}
/// Handler for the `new tab` AppleScript command.
///
/// Required selector name from the command in `sdef`:
/// `handleNewTabScriptCommand:`.
///
/// Accepts an optional target window and optional surface configuration.
/// If no window is provided, this mirrors App Intents and uses the
/// preferred parent window.
///
/// Returns the newly created scripting tab object.
@objc(handleNewTabScriptCommand:)
func handleNewTabScriptCommand(_ command: NSScriptCommand) -> ScriptTab? {
guard validateScript(command: command) else { return nil }
guard let appDelegate = delegate as? AppDelegate else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Ghostty app delegate is unavailable."
return nil
}
let baseConfig: Ghostty.SurfaceConfiguration?
if let scriptRecord = command.evaluatedArguments?["configuration"] as? NSDictionary {
do {
baseConfig = try Ghostty.SurfaceConfiguration(scriptRecord: scriptRecord)
} catch {
command.scriptErrorNumber = errAECoercionFail
command.scriptErrorString = error.localizedDescription
return nil
}
} else {
baseConfig = nil
}
let targetWindow = command.evaluatedArguments?["window"] as? ScriptWindow
let parentWindow: NSWindow?
if let targetWindow {
guard let resolvedWindow = targetWindow.preferredParentWindow else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Target window is no longer available."
return nil
}
parentWindow = resolvedWindow
} else {
parentWindow = TerminalController.preferredParent?.window
}
guard let createdController = TerminalController.newTab(
appDelegate.ghostty,
from: parentWindow,
withBaseConfig: baseConfig
) else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Failed to create tab."
return nil
}
let createdTabID = ScriptTab.stableID(controller: createdController)
if let targetWindow,
let scriptTab = targetWindow.valueInTabs(uniqueID: createdTabID) {
return scriptTab
}
for scriptWindow in scriptWindows {
if let scriptTab = scriptWindow.valueInTabs(uniqueID: createdTabID) {
return scriptTab
}
}
// Fall back to wrapping the created controller if AppKit tab-group
// bookkeeping has not fully refreshed in the current run loop.
let fallbackWindow = ScriptWindow(primaryController: createdController)
return ScriptTab(window: fallbackWindow, controller: createdController)
}
}
// MARK: - Private Helpers
@MainActor
extension NSApplication {
/// Whether Ghostty should currently accept AppleScript interactions.
var isAppleScriptEnabled: Bool {
guard let appDelegate = delegate as? AppDelegate else { return true }
return appDelegate.ghostty.config.macosAppleScript
}
/// Applies a consistent error when scripting is disabled by configuration.
@discardableResult
func validateScript(command: NSScriptCommand) -> Bool {
guard isAppleScriptEnabled else {
command.scriptErrorNumber = errAEEventNotPermitted
command.scriptErrorString = "AppleScript is disabled by the macos-applescript configuration."
return false
}
return true
}
/// Discovers all currently alive terminal surfaces across normal and quick
/// terminal windows. This powers both terminal enumeration and ID lookup.
fileprivate var allSurfaceViews: [Ghostty.SurfaceView] {
allTerminalControllers
.flatMap { $0.surfaceTree.root?.leaves() ?? [] }
}
/// All terminal controllers in undefined order.
fileprivate var allTerminalControllers: [BaseTerminalController] {
NSApp.windows.compactMap { $0.windowController as? BaseTerminalController }
}
/// All terminal controllers in front-to-back order.
fileprivate var orderedTerminalControllers: [BaseTerminalController] {
NSApp.orderedWindows.compactMap { $0.windowController as? BaseTerminalController }
}
/// Identifies the primary tab controller for a window's tab group.
///
/// This gives us one stable representative for all tabs in the same native
/// AppKit tab group.
///
/// For standalone windows this returns the window's controller directly.
/// For tabbed windows, "primary" is currently the first controller in the
/// tab group's ordered windows list.
fileprivate func primaryTerminalController(for controller: BaseTerminalController) -> BaseTerminalController? {
guard let window = controller.window else { return nil }
guard let tabGroup = window.tabGroup else { return controller }
return tabGroup.windows
.compactMap { $0.windowController as? BaseTerminalController }
.first
}
}