mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-06 07:38:21 +00:00
330 lines
12 KiB
Swift
330 lines
12 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
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|