macos: add macos-applescript config

This commit is contained in:
Mitchell Hashimoto
2026-03-06 14:57:31 -08:00
parent 28b4e2495d
commit 25fa58143e
15 changed files with 122 additions and 19 deletions

View File

@@ -13,6 +13,9 @@
## AppleScript
- The AppleScript scripting definition is in `macos/Ghostty.sdef`.
- Guard AppleScript entry points and object accessors with the
`macos-applescript` configuration (use `NSApp.isAppleScriptEnabled`
and `NSApp.validateScript(command:)` where applicable).
- In `macos/Ghostty.sdef`, keep top-level definitions in this order:
1. Classes
2. Records

View File

@@ -29,6 +29,8 @@ extension NSApplication {
/// 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.
@@ -64,7 +66,8 @@ extension NSApplication {
/// Returning `nil` makes the object specifier fail naturally.
@objc(valueInScriptWindowsWithUniqueID:)
func valueInScriptWindows(uniqueID: String) -> ScriptWindow? {
scriptWindows.first(where: { $0.stableID == uniqueID })
guard isAppleScriptEnabled else { return nil }
return scriptWindows.first(where: { $0.stableID == uniqueID })
}
}
@@ -77,7 +80,8 @@ extension NSApplication {
/// Required selector name: `terminals`.
@objc(terminals)
var terminals: [ScriptTerminal] {
allSurfaceViews.map(ScriptTerminal.init)
guard isAppleScriptEnabled else { return [] }
return allSurfaceViews.map(ScriptTerminal.init)
}
/// Enables AppleScript unique-ID lookup for terminal references.
@@ -89,7 +93,8 @@ extension NSApplication {
/// `terminal id "..."` even as windows/tabs change.
@objc(valueInTerminalsWithUniqueID:)
func valueInTerminals(uniqueID: String) -> ScriptTerminal? {
allSurfaceViews
guard isAppleScriptEnabled else { return nil }
return allSurfaceViews
.first(where: { $0.id.uuidString == uniqueID })
.map(ScriptTerminal.init)
}
@@ -111,6 +116,8 @@ extension NSApplication {
/// 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."
@@ -129,6 +136,8 @@ extension NSApplication {
/// 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
@@ -151,6 +160,8 @@ extension NSApplication {
/// 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."
@@ -195,6 +206,8 @@ extension NSApplication {
/// 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."
@@ -262,6 +275,24 @@ extension NSApplication {
@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] {

View File

@@ -9,6 +9,8 @@ import AppKit
@objc(GhosttyScriptCloseCommand)
final class ScriptCloseCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
guard NSApp.validateScript(command: self) else { return nil }
guard let terminal = evaluatedArguments?["terminal"] as? ScriptTerminal else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing terminal target."
@@ -37,6 +39,8 @@ final class ScriptCloseCommand: NSScriptCommand {
@objc(GhosttyScriptCloseTabCommand)
final class ScriptCloseTabCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
guard NSApp.validateScript(command: self) else { return nil }
guard let tab = evaluatedArguments?["tab"] as? ScriptTab else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing tab target."
@@ -70,6 +74,8 @@ final class ScriptCloseTabCommand: NSScriptCommand {
@objc(GhosttyScriptCloseWindowCommand)
final class ScriptCloseWindowCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
guard NSApp.validateScript(command: self) else { return nil }
guard let window = evaluatedArguments?["window"] as? ScriptWindow else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing window target."

View File

@@ -9,6 +9,8 @@ import AppKit
@objc(GhosttyScriptFocusCommand)
final class ScriptFocusCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
guard NSApp.validateScript(command: self) else { return nil }
guard let terminal = evaluatedArguments?["terminal"] as? ScriptTerminal else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing terminal target."
@@ -37,6 +39,8 @@ final class ScriptFocusCommand: NSScriptCommand {
@objc(GhosttyScriptActivateWindowCommand)
final class ScriptActivateWindowCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
guard NSApp.validateScript(command: self) else { return nil }
guard let window = evaluatedArguments?["window"] as? ScriptWindow else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing window target."
@@ -60,6 +64,8 @@ final class ScriptActivateWindowCommand: NSScriptCommand {
@objc(GhosttyScriptSelectTabCommand)
final class ScriptSelectTabCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
guard NSApp.validateScript(command: self) else { return nil }
guard let tab = evaluatedArguments?["tab"] as? ScriptTab else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing tab target."

View File

@@ -9,6 +9,8 @@ import AppKit
@objc(GhosttyScriptInputTextCommand)
final class ScriptInputTextCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
guard NSApp.validateScript(command: self) else { return nil }
guard let text = directParameter as? String else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing text to input."

View File

@@ -9,6 +9,8 @@ import AppKit
@objc(GhosttyScriptKeyEventCommand)
final class ScriptKeyEventCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
guard NSApp.validateScript(command: self) else { return nil }
guard let keyName = directParameter as? String else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing key name."

View File

@@ -9,6 +9,8 @@ import AppKit
@objc(GhosttyScriptMouseButtonCommand)
final class ScriptMouseButtonCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
guard NSApp.validateScript(command: self) else { return nil }
guard let buttonCode = directParameter as? UInt32,
let button = ScriptMouseButtonValue(code: buttonCode) else {
scriptErrorNumber = errAEParamMissed

View File

@@ -9,6 +9,8 @@ import AppKit
@objc(GhosttyScriptMousePosCommand)
final class ScriptMousePosCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
guard NSApp.validateScript(command: self) else { return nil }
guard let x = evaluatedArguments?["x"] as? Double else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing x position."

View File

@@ -9,6 +9,8 @@ import AppKit
@objc(GhosttyScriptMouseScrollCommand)
final class ScriptMouseScrollCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
guard NSApp.validateScript(command: self) else { return nil }
guard let x = evaluatedArguments?["x"] as? Double else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing x scroll delta."

View File

@@ -9,6 +9,8 @@ import AppKit
@objc(GhosttyScriptSplitCommand)
final class ScriptSplitCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
guard NSApp.validateScript(command: self) else { return nil }
guard let terminal = evaluatedArguments?["terminal"] as? ScriptTerminal else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing terminal target."

View File

@@ -34,7 +34,8 @@ final class ScriptTab: NSObject {
/// Exposed as the AppleScript `id` property.
@objc(id)
var idValue: String {
stableID
guard NSApp.isAppleScriptEnabled else { return "" }
return stableID
}
/// Exposed as the AppleScript `title` property.
@@ -42,7 +43,8 @@ final class ScriptTab: NSObject {
/// Returns the title of the tab's window.
@objc(title)
var title: String {
controller?.window?.title ?? ""
guard NSApp.isAppleScriptEnabled else { return "" }
return controller?.window?.title ?? ""
}
/// Exposed as the AppleScript `index` property.
@@ -50,6 +52,7 @@ final class ScriptTab: NSObject {
/// Cocoa scripting expects this to be 1-based for user-facing collections.
@objc(index)
var index: Int {
guard NSApp.isAppleScriptEnabled else { return 0 }
guard let controller else { return 0 }
return window?.tabIndex(for: controller) ?? 0
}
@@ -59,18 +62,21 @@ final class ScriptTab: NSObject {
/// Powers script conditions such as `if selected of tab 1 then ...`.
@objc(selected)
var selected: Bool {
guard NSApp.isAppleScriptEnabled else { return false }
guard let controller else { return false }
return window?.tabIsSelected(controller) ?? false
}
/// Best-effort native window containing this tab.
var parentWindow: NSWindow? {
controller?.window
guard NSApp.isAppleScriptEnabled else { return nil }
return controller?.window
}
/// Live controller backing this tab wrapper.
var parentController: BaseTerminalController? {
controller
guard NSApp.isAppleScriptEnabled else { return nil }
return controller
}
/// Exposed as the AppleScript `terminals` element on a tab.
@@ -78,6 +84,7 @@ final class ScriptTab: NSObject {
/// Returns all terminal surfaces (split panes) within this tab.
@objc(terminals)
var terminals: [ScriptTerminal] {
guard NSApp.isAppleScriptEnabled else { return [] }
guard let controller else { return [] }
return (controller.surfaceTree.root?.leaves() ?? [])
.map(ScriptTerminal.init)
@@ -86,6 +93,7 @@ final class ScriptTab: NSObject {
/// Enables unique-ID lookup for `terminals` references on a tab.
@objc(valueInTerminalsWithUniqueID:)
func valueInTerminals(uniqueID: String) -> ScriptTerminal? {
guard NSApp.isAppleScriptEnabled else { return nil }
guard let controller else { return nil }
return (controller.surfaceTree.root?.leaves() ?? [])
.first(where: { $0.id.uuidString == uniqueID })
@@ -94,6 +102,7 @@ final class ScriptTab: NSObject {
/// Provides Cocoa scripting with a canonical "path" back to this object.
override var objectSpecifier: NSScriptObjectSpecifier? {
guard NSApp.isAppleScriptEnabled else { return nil }
guard let window else { return nil }
guard let windowClassDescription = window.classDescription as? NSScriptClassDescription else {
return nil

View File

@@ -32,13 +32,15 @@ final class ScriptTerminal: NSObject {
/// by `NSUniqueIDSpecifier` to re-identify a terminal object in scripts.
@objc(id)
var stableID: String {
surfaceView?.id.uuidString ?? ""
guard NSApp.isAppleScriptEnabled else { return "" }
return surfaceView?.id.uuidString ?? ""
}
/// Exposed as the AppleScript `title` property.
@objc(title)
var title: String {
surfaceView?.title ?? ""
guard NSApp.isAppleScriptEnabled else { return "" }
return surfaceView?.title ?? ""
}
/// Exposed as the AppleScript `working directory` property.
@@ -47,11 +49,13 @@ final class ScriptTerminal: NSObject {
/// camel-cased selector name `workingDirectory`.
@objc(workingDirectory)
var workingDirectory: String {
surfaceView?.pwd ?? ""
guard NSApp.isAppleScriptEnabled else { return "" }
return surfaceView?.pwd ?? ""
}
/// Used by command handling (`perform action ... on <terminal>`).
func perform(action: String) -> Bool {
guard NSApp.isAppleScriptEnabled else { return false }
guard let surfaceModel = surfaceView?.surfaceModel else { return false }
return surfaceModel.perform(action: action)
}
@@ -62,6 +66,7 @@ final class ScriptTerminal: NSObject {
/// referenced in follow-up script statements because AppleScript cannot
/// express where the object came from (`application.terminals[id]`).
override var objectSpecifier: NSScriptObjectSpecifier? {
guard NSApp.isAppleScriptEnabled else { return nil }
guard let appClassDescription = NSApplication.shared.classDescription as? NSScriptClassDescription else {
return nil
}

View File

@@ -39,7 +39,8 @@ final class ScriptWindow: NSObject {
/// This is what scripts read with `id of window ...`.
@objc(id)
var idValue: String {
stableID
guard NSApp.isAppleScriptEnabled else { return "" }
return stableID
}
/// Exposed as the AppleScript `title` property.
@@ -47,7 +48,8 @@ final class ScriptWindow: NSObject {
/// Returns the title of the window (from the selected/primary controller's NSWindow).
@objc(title)
var title: String {
selectedController?.window?.title ?? ""
guard NSApp.isAppleScriptEnabled else { return "" }
return selectedController?.window?.title ?? ""
}
/// Exposed as the AppleScript `tabs` element.
@@ -57,7 +59,8 @@ final class ScriptWindow: NSObject {
/// so tab additions/removals are reflected immediately.
@objc(tabs)
var tabs: [ScriptTab] {
controllers.map { ScriptTab(window: self, controller: $0) }
guard NSApp.isAppleScriptEnabled else { return [] }
return controllers.map { ScriptTab(window: self, controller: $0) }
}
/// Exposed as the AppleScript `selected tab` property.
@@ -65,6 +68,7 @@ final class ScriptWindow: NSObject {
/// This powers expressions like `selected tab of window 1`.
@objc(selectedTab)
var selectedTab: ScriptTab? {
guard NSApp.isAppleScriptEnabled else { return nil }
guard let selectedController else { return nil }
return ScriptTab(window: self, controller: selectedController)
}
@@ -77,6 +81,7 @@ final class ScriptWindow: NSObject {
/// Cocoa uses this when a script resolves `tab id "..." of window ...`.
@objc(valueInTabsWithUniqueID:)
func valueInTabs(uniqueID: String) -> ScriptTab? {
guard NSApp.isAppleScriptEnabled else { return nil }
guard let controller = controller(tabID: uniqueID) else { return nil }
return ScriptTab(window: self, controller: controller)
}
@@ -86,7 +91,8 @@ final class ScriptWindow: NSObject {
/// Returns all terminal surfaces across every tab in this window.
@objc(terminals)
var terminals: [ScriptTerminal] {
controllers
guard NSApp.isAppleScriptEnabled else { return [] }
return controllers
.flatMap { $0.surfaceTree.root?.leaves() ?? [] }
.map(ScriptTerminal.init)
}
@@ -94,7 +100,8 @@ final class ScriptWindow: NSObject {
/// Enables unique-ID lookup for `terminals` references on a window.
@objc(valueInTerminalsWithUniqueID:)
func valueInTerminals(uniqueID: String) -> ScriptTerminal? {
controllers
guard NSApp.isAppleScriptEnabled else { return nil }
return controllers
.flatMap { $0.surfaceTree.root?.leaves() ?? [] }
.first(where: { $0.id.uuidString == uniqueID })
.map(ScriptTerminal.init)
@@ -103,22 +110,26 @@ final class ScriptWindow: NSObject {
/// AppleScript tab indexes are 1-based, so we add one to Swift's 0-based
/// array index.
func tabIndex(for controller: BaseTerminalController) -> Int? {
controllers.firstIndex(where: { $0 === controller }).map { $0 + 1 }
guard NSApp.isAppleScriptEnabled else { return nil }
return controllers.firstIndex(where: { $0 === controller }).map { $0 + 1 }
}
/// Reports whether a given controller maps to this window's selected tab.
func tabIsSelected(_ controller: BaseTerminalController) -> Bool {
selectedController === controller
guard NSApp.isAppleScriptEnabled else { return false }
return selectedController === controller
}
/// Best-effort native window to use as a tab parent for AppleScript commands.
var preferredParentWindow: NSWindow? {
selectedController?.window ?? controllers.first?.window
guard NSApp.isAppleScriptEnabled else { return nil }
return selectedController?.window ?? controllers.first?.window
}
/// Best-effort controller to use for window-scoped AppleScript commands.
var preferredController: BaseTerminalController? {
selectedController ?? controllers.first
guard NSApp.isAppleScriptEnabled else { return nil }
return selectedController ?? controllers.first
}
/// Resolves a previously generated tab ID back to a live controller.
@@ -131,6 +142,7 @@ final class ScriptWindow: NSObject {
/// We recalculate on every access so AppleScript immediately sees tab-group
/// changes (new tabs, closed tabs, tab moves) without rebuilding all objects.
private var controllers: [BaseTerminalController] {
guard NSApp.isAppleScriptEnabled else { return [] }
guard let primaryController else { return [] }
guard let window = primaryController.window else { return [primaryController] }
@@ -168,6 +180,7 @@ final class ScriptWindow: NSObject {
/// references for later script statements. This specifier encodes:
/// `application -> scriptWindows[id]`.
override var objectSpecifier: NSScriptObjectSpecifier? {
guard NSApp.isAppleScriptEnabled else { return nil }
guard let appClassDescription = NSApplication.shared.classDescription as? NSScriptClassDescription else {
return nil
}

View File

@@ -678,6 +678,14 @@ extension Ghostty {
return v
}
var macosAppleScript: Bool {
guard let config = self.config else { return true }
var v = false
let key = "macos-applescript"
_ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8)))
return v
}
var maximize: Bool {
guard let config = self.config else { return true }
var v = false

View File

@@ -3347,6 +3347,16 @@ keybind: Keybinds = .{},
/// you may want to disable it.
@"macos-secure-input-indication": bool = true,
/// If true, Ghostty exposes and handles the built-in AppleScript dictionary
/// on macOS.
///
/// If false, all AppleScript interactions are disabled. This includes
/// AppleScript commands and AppleScript object lookup for windows, tabs,
/// and terminals.
///
/// The default is true.
@"macos-applescript": bool = true,
/// Customize the macOS app icon.
///
/// This only affects the icon that appears in the dock, application