diff --git a/macos/AGENTS.md b/macos/AGENTS.md index 929b37498..1a0c84c32 100644 --- a/macos/AGENTS.md +++ b/macos/AGENTS.md @@ -1,11 +1,34 @@ # macOS Ghostty Application - Use `swiftlint` for formatting and linting Swift code. -- If code outside of this directory is modified, use +- If code outside of `macos/` directory is modified, use `zig build -Demit-macos-app=false` before building the macOS app to update the underlying Ghostty library. -- Use `build.nu` to build the macOS app, do not use `zig build` +- Use `macos/build.nu` to build the macOS app, do not use `zig build` (except to build the underlying library as mentioned above). - - Build: `build.nu [--scheme Ghostty] [--configuration Debug] [--action build]` - - Output: `build//Ghostty.app` (e.g. `build/Debug/Ghostty.app`) -- Run unit tests directly with `build.nu --action test` + - Build: `macos/build.nu [--scheme Ghostty] [--configuration Debug] [--action build]` + - Output: `macos/build//Ghostty.app` (e.g. `macos/build/Debug/Ghostty.app`) +- Run unit tests directly with `macos/build.nu --action test` + +## 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 + 3. Enums + 4. Commands +- Test AppleScript support: + (1) Build with `macos/build.nu` + (2) Launch and activate the app via osascript using the absolute path + to the built app bundle: + `osascript -e 'tell application "" to activate'` + (3) Wait a few seconds for the app to fully launch and open a terminal. + (4) Run test scripts with `osascript`, always targeting the app by + its absolute path (not by name) to avoid calling the wrong + application. + (5) When done, quit via: + `osascript -e 'tell application "" to quit'` diff --git a/macos/Ghostty-Info.plist b/macos/Ghostty-Info.plist index 4896681b9..01ccd7b11 100644 --- a/macos/Ghostty-Info.plist +++ b/macos/Ghostty-Info.plist @@ -55,8 +55,12 @@ MDItemKeywords Terminal + NSAppleScriptEnabled + NSHighResolutionCapable + OSAScriptingDefinition + Ghostty.sdef NSServices diff --git a/macos/Ghostty.sdef b/macos/Ghostty.sdef new file mode 100644 index 000000000..95497a04a --- /dev/null +++ b/macos/Ghostty.sdef @@ -0,0 +1,312 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 5a3e7a52e..3758c325d 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 552964E62B34A9B400030505 /* vim in Resources */ = {isa = PBXBuildFile; fileRef = 552964E52B34A9B400030505 /* vim */; }; 819324582F24E78800A9ED8F /* DockTilePlugin.plugin in Copy DockTilePlugin */ = {isa = PBXBuildFile; fileRef = 8193244D2F24E6C000A9ED8F /* DockTilePlugin.plugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 819324642F24FF2100A9ED8F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; + 8F3A9B4C2FA6B88000A18D13 /* Ghostty.sdef in Resources */ = {isa = PBXBuildFile; fileRef = 8F3A9B4B2FA6B88000A18D13 /* Ghostty.sdef */; }; 9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; }; A51BFC272B30F1B800E92F16 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A51BFC262B30F1B800E92F16 /* Sparkle */; }; A53D0C8E2B53B0EA00305CE6 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; }; @@ -74,6 +75,7 @@ 552964E52B34A9B400030505 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/vim"; sourceTree = ""; }; 810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GhosttyUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 8193244D2F24E6C000A9ED8F /* DockTilePlugin.plugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DockTilePlugin.plugin; sourceTree = BUILT_PRODUCTS_DIR; }; + 8F3A9B4B2FA6B88000A18D13 /* Ghostty.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = Ghostty.sdef; sourceTree = ""; }; 9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = ""; }; A51BFC282B30F26D00E92F16 /* GhosttyDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyDebug.entitlements; sourceTree = ""; }; A546F1132D7B68D7003B11A0 /* locale */ = {isa = PBXFileReference; lastKnownFileType = folder; name = locale; path = "../zig-out/share/locale"; sourceTree = ""; }; @@ -134,6 +136,18 @@ "Features/App Intents/KeybindIntent.swift", "Features/App Intents/NewTerminalIntent.swift", "Features/App Intents/QuickTerminalIntent.swift", + "Features/AppleScript/AppDelegate+AppleScript.swift", + "Features/AppleScript/Ghostty.Input.Mods+AppleScript.swift", + Features/AppleScript/ScriptInputTextCommand.swift, + Features/AppleScript/ScriptKeyEventCommand.swift, + Features/AppleScript/ScriptMouseButtonCommand.swift, + Features/AppleScript/ScriptMousePosCommand.swift, + Features/AppleScript/ScriptMouseScrollCommand.swift, + Features/AppleScript/ScriptRecord.swift, + Features/AppleScript/ScriptSurfaceConfiguration.swift, + Features/AppleScript/ScriptTab.swift, + Features/AppleScript/ScriptTerminal.swift, + Features/AppleScript/ScriptWindow.swift, Features/ClipboardConfirmation/ClipboardConfirmation.xib, Features/ClipboardConfirmation/ClipboardConfirmationController.swift, Features/ClipboardConfirmation/ClipboardConfirmationView.swift, @@ -322,6 +336,7 @@ isa = PBXGroup; children = ( A571AB1C2A206FC600248498 /* Ghostty-Info.plist */, + 8F3A9B4B2FA6B88000A18D13 /* Ghostty.sdef */, A5B30538299BEAAB0047F10C /* Assets.xcassets */, A553F4122E06EB1600257779 /* Ghostty.icon */, A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */, @@ -557,6 +572,7 @@ A553F4142E06EB1600257779 /* Ghostty.icon in Resources */, 29C15B1D2CDC3B2900520DD4 /* bat in Resources */, A586167C2B7703CC009BDB1D /* fish in Resources */, + 8F3A9B4C2FA6B88000A18D13 /* Ghostty.sdef in Resources */, 55154BE02B33911F001622DC /* ghostty in Resources */, A546F1142D7B68D7003B11A0 /* locale in Resources */, A5985CE62C33060F00C57AD3 /* man in Resources */, diff --git a/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift b/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift new file mode 100644 index 000000000..de7b4948c --- /dev/null +++ b/macos/Sources/Features/AppleScript/AppDelegate+AppleScript.swift @@ -0,0 +1,341 @@ +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) -> 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 + } +} diff --git a/macos/Sources/Features/AppleScript/Ghostty.Input.Mods+AppleScript.swift b/macos/Sources/Features/AppleScript/Ghostty.Input.Mods+AppleScript.swift new file mode 100644 index 000000000..72a274c08 --- /dev/null +++ b/macos/Sources/Features/AppleScript/Ghostty.Input.Mods+AppleScript.swift @@ -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 + } + } + } +} diff --git a/macos/Sources/Features/AppleScript/ScriptInputTextCommand.swift b/macos/Sources/Features/AppleScript/ScriptInputTextCommand.swift new file mode 100644 index 000000000..9662de343 --- /dev/null +++ b/macos/Sources/Features/AppleScript/ScriptInputTextCommand.swift @@ -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 `` 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 + } +} diff --git a/macos/Sources/Features/AppleScript/ScriptKeyEventCommand.swift b/macos/Sources/Features/AppleScript/ScriptKeyEventCommand.swift new file mode 100644 index 000000000..0091098c5 --- /dev/null +++ b/macos/Sources/Features/AppleScript/ScriptKeyEventCommand.swift @@ -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 `` 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 + } +} diff --git a/macos/Sources/Features/AppleScript/ScriptMouseButtonCommand.swift b/macos/Sources/Features/AppleScript/ScriptMouseButtonCommand.swift new file mode 100644 index 000000000..15fe0fbce --- /dev/null +++ b/macos/Sources/Features/AppleScript/ScriptMouseButtonCommand.swift @@ -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 `` 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 + } + } +} diff --git a/macos/Sources/Features/AppleScript/ScriptMousePosCommand.swift b/macos/Sources/Features/AppleScript/ScriptMousePosCommand.swift new file mode 100644 index 000000000..a044c3b2d --- /dev/null +++ b/macos/Sources/Features/AppleScript/ScriptMousePosCommand.swift @@ -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 `` 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 + } +} diff --git a/macos/Sources/Features/AppleScript/ScriptMouseScrollCommand.swift b/macos/Sources/Features/AppleScript/ScriptMouseScrollCommand.swift new file mode 100644 index 000000000..083937eaf --- /dev/null +++ b/macos/Sources/Features/AppleScript/ScriptMouseScrollCommand.swift @@ -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 `` 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 + } +} diff --git a/macos/Sources/Features/AppleScript/ScriptRecord.swift b/macos/Sources/Features/AppleScript/ScriptRecord.swift new file mode 100644 index 000000000..7c81b8e29 --- /dev/null +++ b/macos/Sources/Features/AppleScript/ScriptRecord.swift @@ -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)." + } + } +} diff --git a/macos/Sources/Features/AppleScript/ScriptSurfaceConfiguration.swift b/macos/Sources/Features/AppleScript/ScriptSurfaceConfiguration.swift new file mode 100644 index 000000000..dfa60da41 --- /dev/null +++ b/macos/Sources/Features/AppleScript/ScriptSurfaceConfiguration.swift @@ -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[.. 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 + } + + /// 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 `. + @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) + NSApp.activate(ignoringOtherApps: true) + return nil + } + + /// Handler for `close 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)" + } +} diff --git a/macos/Sources/Features/AppleScript/ScriptTerminal.swift b/macos/Sources/Features/AppleScript/ScriptTerminal.swift new file mode 100644 index 000000000..2cdde382e --- /dev/null +++ b/macos/Sources/Features/AppleScript/ScriptTerminal.swift @@ -0,0 +1,206 @@ +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 { + /// 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 ?? "" + } + + /// Used by command handling (`perform action ... on `). + 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 direction `. + @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 `. + @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 `. + @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.NewDirection { + switch self { + case .right: .right + case .left: .left + case .down: .down + case .up: .up + } + } +} diff --git a/macos/Sources/Features/AppleScript/ScriptWindow.swift b/macos/Sources/Features/AppleScript/ScriptWindow.swift new file mode 100644 index 000000000..c8e4bc8e6 --- /dev/null +++ b/macos/Sources/Features/AppleScript/ScriptWindow.swift @@ -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 `. + @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 `. + @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)" + } +} diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 87ae0511f..239f458e3 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -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 diff --git a/macos/Sources/Helpers/Extensions/ObjectIdentifier+Extension.swift b/macos/Sources/Helpers/Extensions/ObjectIdentifier+Extension.swift new file mode 100644 index 000000000..d2440f1d4 --- /dev/null +++ b/macos/Sources/Helpers/Extensions/ObjectIdentifier+Extension.swift @@ -0,0 +1,7 @@ +import Foundation + +extension ObjectIdentifier { + var hexString: String { + String(UInt(bitPattern: self), radix: 16) + } +} diff --git a/macos/Sources/Helpers/Extensions/String+Extension.swift b/macos/Sources/Helpers/Extensions/String+Extension.swift index e28877ca8..4fa61cd78 100644 --- a/macos/Sources/Helpers/Extensions/String+Extension.swift +++ b/macos/Sources/Helpers/Extensions/String+Extension.swift @@ -27,4 +27,13 @@ extension String { } #endif + /// Converts a four-character ASCII string to its `FourCharCode` (`UInt32`) value. + var fourCharCode: UInt32 { + assert(count <= 4, "FourCharCode string must be at most 4 characters") + var result: UInt32 = 0 + for byte in utf8.prefix(4) { + result = (result << 8) | UInt32(byte) + } + return result + } } diff --git a/src/config/Config.zig b/src/config/Config.zig index ca93c85d6..591c0b049 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -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 diff --git a/typos.toml b/typos.toml index 6cdf04dbb..408d58f73 100644 --- a/typos.toml +++ b/typos.toml @@ -46,6 +46,9 @@ extend-ignore-re = [ "\"hel\\\\x", # Ignore long hex-like IDs such as 815E26BA2EF1E00F005C67B1 "[0-9A-F]{12,}", + # Ignore Apple four char codes + 'code="[A-Za-z]{4,8}"', + '"[A-Za-z]{4}"\.fourCharCode', ] [default.extend-words]