macos: implement basic read-only applescript stuff

This commit is contained in:
Mitchell Hashimoto
2026-03-05 14:55:47 -08:00
parent 291fbf55cb
commit c90a782e59
5 changed files with 173 additions and 2 deletions

View File

@@ -0,0 +1,67 @@
import AppKit
/// Application-level Cocoa scripting hooks for the Ghostty AppleScript dictionary.
///
/// Cocoa scripting looks for specifically named Objective-C selectors derived
/// from the `sdef` file. This extension implements those required entry points
/// on `NSApplication`, which is the object behind the `application` class in
/// `Ghostty.sdef`.
@MainActor
extension NSApplication {
/// Backing collection for `application.terminals`.
///
/// Required selector name: `terminals`.
@objc(terminals)
var terminals: [ScriptTerminal] {
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? {
allSurfaceViews
.first(where: { $0.id.uuidString == uniqueID })
.map(ScriptTerminal.init)
}
/// 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 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 terminal.perform(action: action)
}
/// Discovers all currently alive terminal surfaces across normal and quick
/// terminal windows. This powers both terminal enumeration and ID lookup.
private var allSurfaceViews: [Ghostty.SurfaceView] {
NSApp.windows
.compactMap { $0.windowController as? BaseTerminalController }
.flatMap { $0.surfaceTree.root?.leaves() ?? [] }
}
}

View File

@@ -0,0 +1,73 @@
import AppKit
/// AppleScript-facing wrapper around a live Ghostty terminal surface.
///
/// This class is intentionally ObjC-visible because Cocoa scripting resolves
/// AppleScript objects through Objective-C runtime names/selectors, not Swift
/// protocol conformance.
///
/// Mapping from `Ghostty.sdef`:
/// - `class terminal` -> this class (`@objc(GhosttyAppleScriptTerminal)`).
/// - `property id` -> `@objc(id)` getter below.
/// - `property title` -> `@objc(title)` getter below.
/// - `property working directory` -> `@objc(workingDirectory)` getter below.
///
/// We keep only a weak reference to the underlying `SurfaceView` so this
/// wrapper never extends the terminal's lifetime.
@MainActor
@objc(GhosttyScriptTerminal)
final class ScriptTerminal: NSObject {
private weak var surfaceView: Ghostty.SurfaceView?
init(surfaceView: Ghostty.SurfaceView) {
self.surfaceView = surfaceView
}
/// Exposed as the AppleScript `id` property.
///
/// This is a stable UUID string for the life of a surface and is also used
/// by `NSUniqueIDSpecifier` to re-identify a terminal object in scripts.
@objc(id)
var stableID: String {
surfaceView?.id.uuidString ?? ""
}
/// Exposed as the AppleScript `title` property.
@objc(title)
var title: String {
surfaceView?.title ?? ""
}
/// Exposed as the AppleScript `working directory` property.
///
/// The `sdef` uses a spaced name, but Cocoa scripting maps that to the
/// camel-cased selector name `workingDirectory`.
@objc(workingDirectory)
var workingDirectory: String {
surfaceView?.pwd ?? ""
}
/// Used by command handling (`perform action ... on <terminal>`).
func perform(action: String) -> Bool {
guard let surfaceModel = surfaceView?.surfaceModel else { return false }
return surfaceModel.perform(action: action)
}
/// Provides Cocoa scripting with a canonical "path" back to this object.
///
/// Without an object specifier, returned terminal objects can't be reliably
/// referenced in follow-up script statements because AppleScript cannot
/// express where the object came from (`application.terminals[id]`).
override var objectSpecifier: NSScriptObjectSpecifier? {
guard let appClassDescription = NSApplication.shared.classDescription as? NSScriptClassDescription else {
return nil
}
return NSUniqueIDSpecifier(
containerClassDescription: appClassDescription,
containerSpecifier: nil,
key: "terminals",
uniqueID: stableID
)
}
}