chore: import upstream snapshot with attribution
Test / build-libghostty-vt (x86_64-windows-gnu) (push) Has been cancelled
Test / build-libghostty-vt-macos (aarch64-ios) (push) Has been cancelled
Test / build-libghostty-vt-macos (aarch64-macos) (push) Has been cancelled
Test / build-libghostty-vt-macos (x86_64-macos) (push) Has been cancelled
Test / build-nix (namespace-profile-ghostty-md-arm64) (push) Has been cancelled
Test / build-dist (push) Has been cancelled
Test / GTK x11=true wayland=true (push) Has been cancelled
Test / Build -Dsimd=false (push) Has been cancelled
Test / Build -Dsimd=true (push) Has been cancelled
Test / Build -Dsentry=false (push) Has been cancelled
Test / Build -Dsentry=true (push) Has been cancelled
Test / zig-fmt (push) Has been cancelled
Test / GitHub Actions Pins (push) Has been cancelled
Test / prettier (push) Has been cancelled
Test / swiftlint (push) Has been cancelled
Test / alejandra (push) Has been cancelled
Test / typos (push) Has been cancelled
Nix / Required Checks: Nix (push) Has been cancelled
Nix / check-zig-cache-hash (push) Has been cancelled
Test / skip (push) Has been cancelled
Test / Required Checks: Test (push) Has been cancelled
Test / build-bench (push) Has been cancelled
Test / list-examples (push) Has been cancelled
Test / Example ${{ matrix.dir }} (Windows) (push) Has been cancelled
Test / build-cmake (push) Has been cancelled
Test / test-lib-vt-pkgconfig (push) Has been cancelled
Test / build-flatpak (push) Has been cancelled
Test / build-snap (push) Has been cancelled
Test / build-libghostty-vt (aarch64-linux) (push) Has been cancelled
Test / build-libghostty-vt (aarch64-macos) (push) Has been cancelled
Test / build-libghostty-vt (wasm32-freestanding) (push) Has been cancelled
Test / build-libghostty-vt (x86_64-linux) (push) Has been cancelled
Test / build-libghostty-vt (x86_64-linux-musl) (push) Has been cancelled
Test / build-libghostty-vt (x86_64-macos) (push) Has been cancelled
Test / build-libghostty-vt-android (aarch64-linux-android) (push) Has been cancelled
Test / build-libghostty-vt-android (arm-linux-androideabi) (push) Has been cancelled
Test / build-libghostty-vt-android (x86_64-linux-android) (push) Has been cancelled
Test / build-libghostty-vt-windows (push) Has been cancelled
Test / build-libghostty-windows-gnu (push) Has been cancelled
Test / build-linux (namespace-profile-ghostty-md) (push) Has been cancelled
Test / build-linux (namespace-profile-ghostty-md-arm64) (push) Has been cancelled
Test / build-linux-libghostty (push) Has been cancelled
Test / build-nix (namespace-profile-ghostty-md) (push) Has been cancelled
Test / build-dist-lib-vt (push) Has been cancelled
Test / trigger-snap (push) Has been cancelled
Test / trigger-flatpak (push) Has been cancelled
Test / build-macos (push) Has been cancelled
Test / build-macos-freetype (push) Has been cancelled
Test / test (push) Has been cancelled
Test / test-lib-vt (push) Has been cancelled
Test / GTK x11=false wayland=false (push) Has been cancelled
Test / GTK x11=true wayland=false (push) Has been cancelled
Test / GTK x11=false wayland=true (push) Has been cancelled
Test / test-macos (push) Has been cancelled
Test / test-windows (push) Has been cancelled
Test / Build -Di18n=false (push) Has been cancelled
Test / Build -Di18n=true (push) Has been cancelled
Test / Build test/fuzz-libghostty (push) Has been cancelled
Test / shellcheck (push) Has been cancelled
Test / translations (push) Has been cancelled
Test / blueprint-compiler (push) Has been cancelled
Test / Test pkg/wuffs (push) Has been cancelled
Test / Test build on Debian 13 (push) Has been cancelled
Test / valgrind (push) Has been cancelled
Test / Example ${{ matrix.dir }} (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:02:48 +08:00
commit bd44b5aa08
5770 changed files with 506778 additions and 0 deletions
@@ -0,0 +1,351 @@
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
}
}
@@ -0,0 +1,18 @@
extension Ghostty.Input.Mods {
/// Parses a comma-separated modifier string into `Ghostty.Input.Mods`.
///
/// Recognized names: `shift`, `control`, `option`, `command`.
/// Returns `nil` if any unrecognized modifier name is encountered.
init?(scriptModifiers string: String) {
self = []
for part in string.split(separator: ",") {
switch part.trimmingCharacters(in: .whitespaces).lowercased() {
case "shift": insert(.shift)
case "control": insert(.ctrl)
case "option": insert(.alt)
case "command": insert(.super)
default: return nil
}
}
}
}
@@ -0,0 +1,41 @@
import AppKit
/// Handler for the `input text` AppleScript command defined in `Ghostty.sdef`.
///
/// Cocoa scripting instantiates this class because the command's `<cocoa>` element
/// specifies `class="GhosttyScriptInputTextCommand"`. The runtime calls
/// `performDefaultImplementation()` to execute the command.
@MainActor
@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."
return nil
}
guard let terminal = evaluatedArguments?["terminal"] as? ScriptTerminal else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing terminal target."
return nil
}
guard let surfaceView = terminal.surfaceView else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Terminal surface is no longer available."
return nil
}
guard let surface = surfaceView.surfaceModel else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Terminal surface model is not available."
return nil
}
surface.sendText(text)
return nil
}
}
@@ -0,0 +1,76 @@
import AppKit
/// Handler for the `send key` AppleScript command defined in `Ghostty.sdef`.
///
/// Cocoa scripting instantiates this class because the command's `<cocoa>` element
/// specifies `class="GhosttyScriptKeyEventCommand"`. The runtime calls
/// `performDefaultImplementation()` to execute the command.
@MainActor
@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."
return nil
}
guard let terminal = evaluatedArguments?["terminal"] as? ScriptTerminal else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing terminal target."
return nil
}
guard let surfaceView = terminal.surfaceView else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Terminal surface is no longer available."
return nil
}
guard let surface = surfaceView.surfaceModel else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Terminal surface model is not available."
return nil
}
guard let key = Ghostty.Input.Key(rawValue: keyName) else {
scriptErrorNumber = errAECoercionFail
scriptErrorString = "Unknown key name: \(keyName)"
return nil
}
let action: Ghostty.Input.Action
if let actionCode = evaluatedArguments?["action"] as? UInt32 {
switch actionCode {
case "GIpr".fourCharCode: action = .press
case "GIrl".fourCharCode: action = .release
default: action = .press
}
} else {
action = .press
}
let mods: Ghostty.Input.Mods
if let modsString = evaluatedArguments?["modifiers"] as? String {
guard let parsed = Ghostty.Input.Mods(scriptModifiers: modsString) else {
scriptErrorNumber = errAECoercionFail
scriptErrorString = "Unknown modifier in: \(modsString)"
return nil
}
mods = parsed
} else {
mods = []
}
let keyEvent = Ghostty.Input.KeyEvent(
key: key,
action: action,
mods: mods
)
surface.sendKeyEvent(keyEvent)
return nil
}
}
@@ -0,0 +1,95 @@
import AppKit
/// Handler for the `send mouse button` AppleScript command defined in `Ghostty.sdef`.
///
/// Cocoa scripting instantiates this class because the command's `<cocoa>` element
/// specifies `class="GhosttyScriptMouseButtonCommand"`. The runtime calls
/// `performDefaultImplementation()` to execute the command.
@MainActor
@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
scriptErrorString = "Missing or unknown mouse button."
return nil
}
guard let terminal = evaluatedArguments?["terminal"] as? ScriptTerminal else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing terminal target."
return nil
}
guard let surfaceView = terminal.surfaceView else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Terminal surface is no longer available."
return nil
}
guard let surface = surfaceView.surfaceModel else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Terminal surface model is not available."
return nil
}
let action: Ghostty.Input.MouseState
if let actionCode = evaluatedArguments?["action"] as? UInt32 {
switch actionCode {
case "GIpr".fourCharCode: action = .press
case "GIrl".fourCharCode: action = .release
default: action = .press
}
} else {
action = .press
}
let mods: Ghostty.Input.Mods
if let modsString = evaluatedArguments?["modifiers"] as? String {
guard let parsed = Ghostty.Input.Mods(scriptModifiers: modsString) else {
scriptErrorNumber = errAECoercionFail
scriptErrorString = "Unknown modifier in: \(modsString)"
return nil
}
mods = parsed
} else {
mods = []
}
let mouseEvent = Ghostty.Input.MouseButtonEvent(
action: action,
button: button.ghosttyButton,
mods: mods
)
surface.sendMouseButton(mouseEvent)
return nil
}
}
/// Four-character codes matching the `mouse button` enumeration in `Ghostty.sdef`.
private enum ScriptMouseButtonValue {
case left
case right
case middle
init?(code: UInt32) {
switch code {
case "GMlf".fourCharCode: self = .left
case "GMrt".fourCharCode: self = .right
case "GMmd".fourCharCode: self = .middle
default: return nil
}
}
var ghosttyButton: Ghostty.Input.MouseButton {
switch self {
case .left: .left
case .right: .right
case .middle: .middle
}
}
}
@@ -0,0 +1,65 @@
import AppKit
/// Handler for the `send mouse position` AppleScript command defined in `Ghostty.sdef`.
///
/// Cocoa scripting instantiates this class because the command's `<cocoa>` element
/// specifies `class="GhosttyScriptMousePosCommand"`. The runtime calls
/// `performDefaultImplementation()` to execute the command.
@MainActor
@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."
return nil
}
guard let y = evaluatedArguments?["y"] as? Double else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing y position."
return nil
}
guard let terminal = evaluatedArguments?["terminal"] as? ScriptTerminal else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing terminal target."
return nil
}
guard let surfaceView = terminal.surfaceView else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Terminal surface is no longer available."
return nil
}
guard let surface = surfaceView.surfaceModel else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Terminal surface model is not available."
return nil
}
let mods: Ghostty.Input.Mods
if let modsString = evaluatedArguments?["modifiers"] as? String {
guard let parsed = Ghostty.Input.Mods(scriptModifiers: modsString) else {
scriptErrorNumber = errAECoercionFail
scriptErrorString = "Unknown modifier in: \(modsString)"
return nil
}
mods = parsed
} else {
mods = []
}
let mousePosEvent = Ghostty.Input.MousePosEvent(
x: x,
y: y,
mods: mods
)
surface.sendMousePos(mousePosEvent)
return nil
}
}
@@ -0,0 +1,71 @@
import AppKit
/// Handler for the `send mouse scroll` AppleScript command defined in `Ghostty.sdef`.
///
/// Cocoa scripting instantiates this class because the command's `<cocoa>` element
/// specifies `class="GhosttyScriptMouseScrollCommand"`. The runtime calls
/// `performDefaultImplementation()` to execute the command.
@MainActor
@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."
return nil
}
guard let y = evaluatedArguments?["y"] as? Double else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing y scroll delta."
return nil
}
guard let terminal = evaluatedArguments?["terminal"] as? ScriptTerminal else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = "Missing terminal target."
return nil
}
guard let surfaceView = terminal.surfaceView else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Terminal surface is no longer available."
return nil
}
guard let surface = surfaceView.surfaceModel else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = "Terminal surface model is not available."
return nil
}
let precision = evaluatedArguments?["precision"] as? Bool ?? false
let momentum: Ghostty.Input.Momentum
if let momentumCode = evaluatedArguments?["momentum"] as? UInt32 {
switch momentumCode {
case "SMno".fourCharCode: momentum = .none
case "SMbg".fourCharCode: momentum = .began
case "SMch".fourCharCode: momentum = .changed
case "SMen".fourCharCode: momentum = .ended
case "SMcn".fourCharCode: momentum = .cancelled
case "SMmb".fourCharCode: momentum = .mayBegin
case "SMst".fourCharCode: momentum = .stationary
default: momentum = .none
}
} else {
momentum = .none
}
let scrollEvent = Ghostty.Input.MouseScrollEvent(
x: x,
y: y,
mods: .init(precision: precision, momentum: momentum)
)
surface.sendMouseScroll(scrollEvent)
return nil
}
}
@@ -0,0 +1,29 @@
import Cocoa
/// Protocol to more easily implement AppleScript records in Swift.
protocol ScriptRecord {
/// Initialize a default record.
init()
/// Initialize a record from the raw value from AppleScript.
init(scriptRecord: NSDictionary?) throws
/// Encode into the dictionary form for AppleScript.
var dictionaryRepresentation: NSDictionary { get }
}
/// An error that can be thrown by `ScriptRecord.init(scriptRecord:)`. Any localized error
/// can be thrown but this is a common one.
enum RecordParseError: LocalizedError {
case invalidType(parameter: String, expected: String)
case invalidValue(parameter: String, message: String)
var errorDescription: String? {
switch self {
case .invalidType(let parameter, let expected):
return "\(parameter) must be \(expected)."
case .invalidValue(let parameter, let message):
return "\(parameter) \(message)."
}
}
}
@@ -0,0 +1,140 @@
import Foundation
/// AppleScript record support for `Ghostty.SurfaceConfiguration`.
///
/// This keeps scripting conversion at the data-structure boundary so AppleScript
/// can pass records by value (`new surface configuration`, assign, copy, mutate)
/// without introducing an additional wrapper type.
extension Ghostty.SurfaceConfiguration: ScriptRecord {
init(scriptRecord source: NSDictionary?) throws {
self.init()
guard let source else {
return
}
guard let raw = source as? [String: Any] else {
throw RecordParseError.invalidType(parameter: "configuration", expected: "a surface configuration record")
}
if let rawFontSize = raw["fontSize"] {
guard let number = rawFontSize as? NSNumber else {
throw RecordParseError.invalidType(parameter: "font size", expected: "a number")
}
let value = number.doubleValue
guard value.isFinite else {
throw RecordParseError.invalidValue(parameter: "font size", message: "must be a finite number")
}
if value < 0 {
throw RecordParseError.invalidValue(parameter: "font size", message: "must be a positive number")
}
if value > 0 {
fontSize = Float32(value)
}
}
if let rawWorkingDirectory = raw["workingDirectory"] {
guard let workingDirectory = rawWorkingDirectory as? String else {
throw RecordParseError.invalidType(parameter: "initial working directory", expected: "text")
}
if !workingDirectory.isEmpty {
self.workingDirectory = workingDirectory
}
}
if let rawCommand = raw["command"] {
guard let command = rawCommand as? String else {
throw RecordParseError.invalidType(parameter: "command", expected: "text")
}
if !command.isEmpty {
self.command = command
}
}
if let rawInitialInput = raw["initialInput"] {
guard let initialInput = rawInitialInput as? String else {
throw RecordParseError.invalidType(parameter: "initial input", expected: "text")
}
if !initialInput.isEmpty {
self.initialInput = initialInput
}
}
if let rawWaitAfterCommand = raw["waitAfterCommand"] {
if let boolValue = rawWaitAfterCommand as? Bool {
waitAfterCommand = boolValue
} else if let numericValue = rawWaitAfterCommand as? NSNumber {
waitAfterCommand = numericValue.boolValue
} else {
throw RecordParseError.invalidType(parameter: "wait after command", expected: "boolean")
}
}
if let assignments = raw["environmentVariables"] as? [String], !assignments.isEmpty {
environmentVariables = try Self.parseScriptEnvironmentAssignments(assignments)
}
}
var dictionaryRepresentation: NSDictionary {
var record: [String: Any] = [
"fontSize": 0,
"workingDirectory": "",
"command": "",
"initialInput": "",
"waitAfterCommand": false,
"environmentVariables": [String](),
]
if let fontSize {
record["fontSize"] = NSNumber(value: fontSize)
}
if let workingDirectory {
record["workingDirectory"] = workingDirectory
}
if let command {
record["command"] = command
}
if let initialInput {
record["initialInput"] = initialInput
}
if waitAfterCommand {
record["waitAfterCommand"] = true
}
if !environmentVariables.isEmpty {
record["environmentVariables"] = environmentVariables.map { "\($0.key)=\($0.value)" }
}
return record as NSDictionary
}
private static func parseScriptEnvironmentAssignments(_ assignments: [String]) throws -> [String: String] {
var result: [String: String] = [:]
for assignment in assignments {
guard let separator = assignment.firstIndex(of: "=") else {
throw RecordParseError.invalidValue(
parameter: "environment variables",
message: "expected KEY=VALUE, got \"\(assignment)\""
)
}
let key = String(assignment[..<separator])
let valueStart = assignment.index(after: separator)
let value = String(assignment[valueStart...])
result[key] = value
}
return result
}
}
@@ -0,0 +1,186 @@
import AppKit
/// AppleScript-facing wrapper around a single tab in a scripting window.
///
/// `ScriptWindow.tabs` vends these objects so AppleScript can traverse
/// `window -> tab` without knowing anything about AppKit controllers.
@MainActor
@objc(GhosttyScriptTab)
final class ScriptTab: NSObject {
/// Stable identifier used by AppleScript `tab id "..."` references.
private let stableID: String
/// Weak back-reference to the scripting window that owns this tab wrapper.
///
/// We only need this for dynamic properties (`index`, `selected`) and for
/// building an object specifier path.
private weak var window: ScriptWindow?
/// Live terminal controller for this tab.
///
/// This can become `nil` if the tab closes while a script is running.
private weak var controller: BaseTerminalController?
/// Called by `ScriptWindow.tabs` / `ScriptWindow.selectedTab`.
///
/// The ID is computed once so object specifiers built from this instance keep
/// a consistent tab identity.
init(window: ScriptWindow, controller: BaseTerminalController) {
self.stableID = Self.stableID(controller: controller)
self.window = window
self.controller = controller
}
/// Exposed as the AppleScript `id` property.
@objc(id)
var idValue: String {
guard NSApp.isAppleScriptEnabled else { return "" }
return stableID
}
/// Exposed as the AppleScript `title` property.
///
/// Returns the title of the tab's window.
@objc(title)
var title: String {
guard NSApp.isAppleScriptEnabled else { return "" }
return controller?.window?.title ?? ""
}
/// Exposed as the AppleScript `index` property.
///
/// 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
}
/// Exposed as the AppleScript `selected` property.
///
/// 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
}
/// Exposed as the AppleScript `focused terminal` property.
///
/// Uses the currently focused surface for this tab.
@objc(focusedTerminal)
var focusedTerminal: ScriptTerminal? {
guard NSApp.isAppleScriptEnabled else { return nil }
guard let controller else { return nil }
guard let surface = controller.focusedSurface,
controller.surfaceTree.contains(surface)
else { return nil }
return ScriptTerminal(surfaceView: surface)
}
/// Best-effort native window containing this tab.
var parentWindow: NSWindow? {
guard NSApp.isAppleScriptEnabled else { return nil }
return controller?.window
}
/// Live controller backing this tab wrapper.
var parentController: BaseTerminalController? {
guard NSApp.isAppleScriptEnabled else { return nil }
return controller
}
/// Exposed as the AppleScript `terminals` element on a tab.
///
/// 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)
}
/// 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 })
.map(ScriptTerminal.init)
}
/// Handler for `select tab <tab>`.
@objc(handleSelectTabCommand:)
func handleSelectTab(_ command: NSScriptCommand) -> Any? {
guard NSApp.validateScript(command: command) else { return nil }
guard let tabContainerWindow = parentWindow else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Tab is no longer available."
return nil
}
tabContainerWindow.makeKeyAndOrderFront(nil)
return nil
}
/// Handler for `close tab <tab>`.
@objc(handleCloseTabCommand:)
func handleCloseTab(_ command: NSScriptCommand) -> Any? {
guard NSApp.validateScript(command: command) else { return nil }
guard let tabController = parentController else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Tab is no longer available."
return nil
}
if let managedTerminalController = tabController as? TerminalController {
managedTerminalController.closeTabImmediately(registerRedo: false)
return nil
}
guard let tabContainerWindow = parentWindow else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Tab container window is no longer available."
return nil
}
tabContainerWindow.close()
return nil
}
/// 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
}
guard let windowSpecifier = window.objectSpecifier else { return nil }
// This tells Cocoa how to re-find this tab later:
// application -> scriptWindows[id] -> tabs[id].
return NSUniqueIDSpecifier(
containerClassDescription: windowClassDescription,
containerSpecifier: windowSpecifier,
key: "tabs",
uniqueID: stableID
)
}
}
extension ScriptTab {
/// Stable ID for one tab controller.
///
/// Tab identity belongs to `ScriptTab`, so both tab creation and tab ID
/// lookups in `ScriptWindow` call this helper.
static func stableID(controller: BaseTerminalController) -> String {
"tab-\(ObjectIdentifier(controller).hexString)"
}
}
@@ -0,0 +1,222 @@
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.
/// - `property pid` -> `@objc(pid)` getter below.
/// - `property tty` -> `@objc(tty)` 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 {
/// Weak reference to the underlying surface. Package-visible so that
/// other AppleScript command handlers (e.g. `ScriptSplitCommand`) can
/// access the live surface without exposing it to ObjC/AppleScript.
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 {
guard NSApp.isAppleScriptEnabled else { return "" }
return surfaceView?.id.uuidString ?? ""
}
/// Exposed as the AppleScript `title` property.
@objc(title)
var title: String {
guard NSApp.isAppleScriptEnabled else { return "" }
return 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 {
guard NSApp.isAppleScriptEnabled else { return "" }
return surfaceView?.pwd ?? ""
}
/// Exposed as the AppleScript `pid` property.
@objc(pid)
var pid: Int {
guard NSApp.isAppleScriptEnabled else { return 0 }
return surfaceView?.surfaceModel?.foregroundPID ?? 0
}
/// Exposed as the AppleScript `tty` property.
@objc(tty)
var tty: String {
guard NSApp.isAppleScriptEnabled else { return "" }
return surfaceView?.surfaceModel?.ttyName ?? ""
}
/// 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)
}
/// Handler for `split <terminal> direction <dir>`.
@objc(handleSplitCommand:)
func handleSplit(_ command: NSScriptCommand) -> Any? {
guard NSApp.validateScript(command: command) else { return nil }
guard let surfaceView else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Terminal surface is no longer available."
return nil
}
guard let directionCode = command.evaluatedArguments?["direction"] as? UInt32 else {
command.scriptErrorNumber = errAEParamMissed
command.scriptErrorString = "Missing or unknown split direction."
return nil
}
guard let direction = ScriptSplitDirection(code: directionCode)?.splitDirection else {
command.scriptErrorNumber = errAEParamMissed
command.scriptErrorString = "Missing or unknown split direction."
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
}
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Terminal is not in a splittable window."
return nil
}
guard let newView = controller.newSplit(
at: surfaceView,
direction: direction,
baseConfig: baseConfig
) else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Failed to create split."
return nil
}
return ScriptTerminal(surfaceView: newView)
}
/// Handler for `focus <terminal>`.
@objc(handleFocusCommand:)
func handleFocus(_ command: NSScriptCommand) -> Any? {
guard NSApp.validateScript(command: command) else { return nil }
guard let surfaceView else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Terminal surface is no longer available."
return nil
}
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Terminal is not in a window."
return nil
}
controller.focusSurface(surfaceView)
return nil
}
/// Handler for `close <terminal>`.
@objc(handleCloseCommand:)
func handleClose(_ command: NSScriptCommand) -> Any? {
guard NSApp.validateScript(command: command) else { return nil }
guard let surfaceView else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Terminal surface is no longer available."
return nil
}
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Terminal is not in a window."
return nil
}
controller.closeSurface(surfaceView, withConfirmation: false)
return nil
}
/// 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 NSApp.isAppleScriptEnabled else { return nil }
guard let appClassDescription = NSApplication.shared.classDescription as? NSScriptClassDescription else {
return nil
}
return NSUniqueIDSpecifier(
containerClassDescription: appClassDescription,
containerSpecifier: nil,
key: "terminals",
uniqueID: stableID
)
}
}
/// Converts four-character codes from the `split direction` enumeration in `Ghostty.sdef`
/// to `SplitTree.NewDirection` values.
enum ScriptSplitDirection {
case right
case left
case down
case up
init?(code: UInt32) {
switch code {
case "GSrt".fourCharCode: self = .right
case "GSlf".fourCharCode: self = .left
case "GSdn".fourCharCode: self = .down
case "GSup".fourCharCode: self = .up
default: return nil
}
}
var splitDirection: SplitTree<Ghostty.SurfaceView>.NewDirection {
switch self {
case .right: .right
case .left: .left
case .down: .down
case .up: .up
}
}
}
@@ -0,0 +1,260 @@
import AppKit
/// AppleScript-facing wrapper around a logical Ghostty window.
///
/// In AppKit, each tab is often its own `NSWindow`. AppleScript users, however,
/// expect a single window object containing a list of tabs.
///
/// `ScriptWindow` is that compatibility layer:
/// - It presents one object per tab group.
/// - It translates tab-group state into `tabs` and `selected tab`.
/// - It exposes stable IDs that Cocoa scripting can resolve later.
@MainActor
@objc(GhosttyScriptWindow)
final class ScriptWindow: NSObject {
/// Stable identifier used by AppleScript `window id "..."` references.
///
/// We precompute this once so the object keeps a consistent ID for its whole
/// lifetime, even if AppKit window bookkeeping changes after creation.
let stableID: String
/// Canonical representative for this scripting window's tab group.
///
/// We intentionally keep only one controller reference; full tab membership
/// is derived lazily from current AppKit state whenever needed.
private weak var primaryController: BaseTerminalController?
/// `scriptWindows` in `AppDelegate+AppleScript` constructs these objects.
///
/// `stableID` must match the same identity scheme used by
/// `valueInScriptWindowsWithUniqueID:` so Cocoa can re-resolve object
/// specifiers produced earlier in a script.
init(primaryController: BaseTerminalController) {
self.stableID = Self.stableID(primaryController: primaryController)
self.primaryController = primaryController
}
/// Exposed as the AppleScript `id` property.
///
/// This is what scripts read with `id of window ...`.
@objc(id)
var idValue: String {
guard NSApp.isAppleScriptEnabled else { return "" }
return stableID
}
/// Exposed as the AppleScript `title` property.
///
/// Returns the title of the window (from the selected/primary controller's NSWindow).
@objc(title)
var title: String {
guard NSApp.isAppleScriptEnabled else { return "" }
return selectedController?.window?.title ?? ""
}
/// Exposed as the AppleScript `tabs` element.
///
/// Cocoa asks for this collection when a script evaluates `tabs of window ...`
/// or any tab-filter expression. We build wrappers from live controller state
/// so tab additions/removals are reflected immediately.
@objc(tabs)
var tabs: [ScriptTab] {
guard NSApp.isAppleScriptEnabled else { return [] }
return controllers.map { ScriptTab(window: self, controller: $0) }
}
/// Exposed as the AppleScript `selected tab` property.
///
/// 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)
}
/// Enables unique-ID lookup for `tabs` references.
///
/// Required selector pattern for the `tabs` element key:
/// `valueInTabsWithUniqueID:`.
///
/// 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)
}
/// Exposed as the AppleScript `terminals` element on a window.
///
/// Returns all terminal surfaces across every tab in this window.
@objc(terminals)
var terminals: [ScriptTerminal] {
guard NSApp.isAppleScriptEnabled else { return [] }
return controllers
.flatMap { $0.surfaceTree.root?.leaves() ?? [] }
.map(ScriptTerminal.init)
}
/// Enables unique-ID lookup for `terminals` references on a window.
@objc(valueInTerminalsWithUniqueID:)
func valueInTerminals(uniqueID: String) -> ScriptTerminal? {
guard NSApp.isAppleScriptEnabled else { return nil }
return controllers
.flatMap { $0.surfaceTree.root?.leaves() ?? [] }
.first(where: { $0.id.uuidString == uniqueID })
.map(ScriptTerminal.init)
}
/// AppleScript tab indexes are 1-based, so we add one to Swift's 0-based
/// array index.
func tabIndex(for controller: BaseTerminalController) -> Int? {
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 {
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? {
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? {
guard NSApp.isAppleScriptEnabled else { return nil }
return selectedController ?? controllers.first
}
/// Resolves a previously generated tab ID back to a live controller.
private func controller(tabID: String) -> BaseTerminalController? {
controllers.first(where: { ScriptTab.stableID(controller: $0) == tabID })
}
/// Live controller list for this scripting window.
///
/// 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] }
if let tabGroup = window.tabGroup {
let groupControllers = tabGroup.windows.compactMap {
$0.windowController as? BaseTerminalController
}
if !groupControllers.isEmpty {
return groupControllers
}
}
return [primaryController]
}
/// Live selected controller for this scripting window.
///
/// AppKit tracks selected tab on `NSWindowTabGroup.selectedWindow`; for
/// non-tabbed windows we fall back to the primary controller.
private var selectedController: BaseTerminalController? {
guard let primaryController else { return nil }
guard let window = primaryController.window else { return primaryController }
if let tabGroup = window.tabGroup,
let selectedController = tabGroup.selectedWindow?.windowController as? BaseTerminalController {
return selectedController
}
return controllers.first
}
/// Handler for `activate window <window>`.
@objc(handleActivateWindowCommand:)
func handleActivateWindow(_ command: NSScriptCommand) -> Any? {
guard NSApp.validateScript(command: command) else { return nil }
guard let windowContainer = preferredParentWindow else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Window is no longer available."
return nil
}
windowContainer.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return nil
}
/// Handler for `close window <window>`.
@objc(handleCloseWindowCommand:)
func handleCloseWindow(_ command: NSScriptCommand) -> Any? {
guard NSApp.validateScript(command: command) else { return nil }
if let managedTerminalController = preferredController as? TerminalController {
managedTerminalController.closeWindowImmediately()
return nil
}
guard let windowContainer = preferredParentWindow else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Window is no longer available."
return nil
}
windowContainer.close()
return nil
}
/// Provides Cocoa scripting with a canonical "path" back to this object.
///
/// Without this, Cocoa can return data but cannot reliably build object
/// 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
}
return NSUniqueIDSpecifier(
containerClassDescription: appClassDescription,
containerSpecifier: nil,
key: "scriptWindows",
uniqueID: stableID
)
}
}
extension ScriptWindow {
/// Produces the window-level stable ID from the primary controller.
///
/// - Tabbed windows are keyed by tab-group identity.
/// - Standalone windows are keyed by window identity.
/// - Detached controllers fall back to controller identity.
static func stableID(primaryController: BaseTerminalController) -> String {
guard let window = primaryController.window else {
return "controller-\(ObjectIdentifier(primaryController).hexString)"
}
if let tabGroup = window.tabGroup {
return stableID(tabGroup: tabGroup)
}
return stableID(window: window)
}
/// Stable ID for a standalone native window.
static func stableID(window: NSWindow) -> String {
"window-\(ObjectIdentifier(window).hexString)"
}
/// Stable ID for a native AppKit tab group.
static func stableID(tabGroup: NSWindowTabGroup) -> String {
"tab-group-\(ObjectIdentifier(tabGroup).hexString)"
}
}