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 `` in `sdef` maps to an ObjC collection accessor. /// - Unique-ID element lookup maps to `valueIn...WithUniqueID:`. /// - Some `` 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 = [] 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 } /// 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) -> Any? { 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) -> Any? { 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) -> Any? { 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 do { baseConfig = try Ghostty.SurfaceConfiguration( scriptRecord: command.evaluatedArguments?["configuration"] as? NSDictionary ) } catch { command.scriptErrorNumber = errAECoercionFail command.scriptErrorString = error.localizedDescription return 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 `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) -> Any? { 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 do { if let scriptRecord = command.evaluatedArguments?["configuration"] as? NSDictionary { baseConfig = try Ghostty.SurfaceConfiguration(scriptRecord: scriptRecord) } else { baseConfig = Ghostty.SurfaceConfiguration() } } catch { command.scriptErrorNumber = errAECoercionFail command.scriptErrorString = error.localizedDescription return 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 = errAEEventFailed 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 } }