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
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:
@@ -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)"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user