macos: implement +new-window IPC via Unix domain socket
Implements `ghostty +new-window` for macOS, mirroring the existing GTK implementation which uses D-Bus. The CLI command, IPC abstraction, and argument parsing (`--title`, `--working-directory`, `-e`) already existed; this adds the macOS transport. The running Ghostty.app listens on a per-user Unix domain socket at `<confstr(_CS_DARWIN_USER_TEMP_DIR)>/ghostty-ipc-<bundle-id>.sock`. The CLI process (embedded apprt) connects, sends a length-prefixed frame of the CLI arguments, and exits. The app parses the arguments and opens a new window via the existing `TerminalController.newWindow` path. The `--title` flag is wired through to `ghostty_surface_config_s.title` (new field), which causes the core to ignore subsequent OSC 0/2 title change requests from the running program — matching the behavior of the `title` config key. Both ends resolve the socket path via `confstr` rather than `$TMPDIR` so they agree regardless of environment.pull/12903/head
parent
c4eba3da38
commit
6b30f61fed
|
|
@ -472,6 +472,7 @@ typedef struct {
|
|||
float font_size;
|
||||
const char* working_directory;
|
||||
const char* command;
|
||||
const char* title;
|
||||
ghostty_env_var_s* env_vars;
|
||||
size_t env_var_count;
|
||||
const char* initial_input;
|
||||
|
|
|
|||
|
|
@ -153,6 +153,9 @@ class AppDelegate: NSObject,
|
|||
|
||||
private let appIconUpdater = AppIconUpdater()
|
||||
|
||||
/// Listens for IPC requests (e.g. `ghostty +new-window`) from CLI processes.
|
||||
private var ipcServer: IPCServer?
|
||||
|
||||
@MainActor private lazy var menuShortcutManager = Ghostty.MenuShortcutManager()
|
||||
|
||||
override init() {
|
||||
|
|
@ -218,6 +221,13 @@ class AppDelegate: NSObject,
|
|||
// Register our service provider. This must happen after everything is initialized.
|
||||
NSApp.servicesProvider = ServiceProvider()
|
||||
|
||||
// Start listening for IPC requests (e.g. `ghostty +new-window`) so that
|
||||
// CLI invocations open windows in this running instance.
|
||||
ipcServer = IPCServer { [weak self] config in
|
||||
guard let self else { return }
|
||||
_ = TerminalController.newWindow(self.ghostty, withBaseConfig: config)
|
||||
}
|
||||
|
||||
// This registers the Ghostty => Services menu to exist.
|
||||
NSApp.servicesMenu = menuServices
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,202 @@
|
|||
import Foundation
|
||||
import Darwin
|
||||
|
||||
/// Listens on a per-user Unix domain socket for requests from `ghostty` CLI
|
||||
/// processes (e.g. `ghostty +new-window`) and dispatches them to the running
|
||||
/// app. The sender lives in the Zig core (`apprt/embedded.zig`, `performIpc`).
|
||||
///
|
||||
/// The two ends agree on the socket path and wire format without any shared
|
||||
/// state:
|
||||
/// - Path: `<per-user temp dir>/ghostty-ipc-<bundle id>.sock`, where the temp
|
||||
/// dir is resolved via `confstr(_CS_DARWIN_USER_TEMP_DIR)` on both sides so
|
||||
/// they match regardless of `$TMPDIR`.
|
||||
/// - Frame: `[u8 action][u32 argc]` followed by `argc` times `[u32 len][bytes]`,
|
||||
/// all integers little-endian. The app replies with a single byte: 0 for
|
||||
/// success, non-zero for failure.
|
||||
final class IPCServer {
|
||||
/// Invoked on the main queue when a new-window request arrives.
|
||||
typealias NewWindowHandler = (Ghostty.SurfaceConfiguration) -> Void
|
||||
|
||||
/// Actions, matching `apprt.ipc.Action.Key` on the Zig side.
|
||||
private enum Action: UInt8 {
|
||||
case newWindow = 0
|
||||
case toggleQuickTerminal = 1
|
||||
}
|
||||
|
||||
private let socketPath: String
|
||||
private let onNewWindow: NewWindowHandler
|
||||
private var listenFD: Int32 = -1
|
||||
private var source: DispatchSourceRead?
|
||||
private let queue = DispatchQueue(label: "com.mitchellh.ghostty.ipc")
|
||||
|
||||
init?(onNewWindow: @escaping NewWindowHandler) {
|
||||
guard let path = IPCServer.socketPath() else { return nil }
|
||||
self.socketPath = path
|
||||
self.onNewWindow = onNewWindow
|
||||
guard listenOnSocket() else { return nil }
|
||||
}
|
||||
|
||||
deinit {
|
||||
if listenFD >= 0 { close(listenFD) }
|
||||
unlink(socketPath)
|
||||
}
|
||||
|
||||
private static func socketPath() -> String? {
|
||||
var buf = [CChar](repeating: 0, count: Int(PATH_MAX))
|
||||
let n = confstr(_CS_DARWIN_USER_TEMP_DIR, &buf, buf.count)
|
||||
guard n > 0, n <= buf.count else { return nil }
|
||||
let dir = String(cString: buf) // already ends in a path separator
|
||||
let bundleID = Bundle.main.bundleIdentifier ?? "com.mitchellh.ghostty"
|
||||
return "\(dir)ghostty-ipc-\(bundleID).sock"
|
||||
}
|
||||
|
||||
private func listenOnSocket() -> Bool {
|
||||
// Remove any stale socket left by a previous (crashed) run.
|
||||
unlink(socketPath)
|
||||
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else { return false }
|
||||
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
let pathBytes = Array(socketPath.utf8)
|
||||
// sun_path is fixed-size and must remain NUL-terminated.
|
||||
guard pathBytes.count < MemoryLayout.size(ofValue: addr.sun_path) else {
|
||||
close(fd)
|
||||
return false
|
||||
}
|
||||
withUnsafeMutableBytes(of: &addr.sun_path) { raw in
|
||||
raw.copyBytes(from: pathBytes)
|
||||
}
|
||||
|
||||
let size = socklen_t(MemoryLayout<sockaddr_un>.size)
|
||||
let bound = withUnsafePointer(to: &addr) { p in
|
||||
p.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(fd, $0, size) }
|
||||
}
|
||||
guard bound == 0, listen(fd, 8) == 0 else {
|
||||
close(fd)
|
||||
return false
|
||||
}
|
||||
|
||||
listenFD = fd
|
||||
let src = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue)
|
||||
src.setEventHandler { [weak self] in self?.acceptOne() }
|
||||
src.resume()
|
||||
source = src
|
||||
return true
|
||||
}
|
||||
|
||||
private func acceptOne() {
|
||||
let client = accept(listenFD, nil, nil)
|
||||
guard client >= 0 else { return }
|
||||
defer { close(client) }
|
||||
|
||||
guard let raw = readUInt8(client), let action = Action(rawValue: raw) else {
|
||||
writeAck(client, ok: false)
|
||||
return
|
||||
}
|
||||
|
||||
switch action {
|
||||
case .newWindow:
|
||||
guard let args = readArguments(client) else {
|
||||
writeAck(client, ok: false)
|
||||
return
|
||||
}
|
||||
let config = IPCServer.surfaceConfiguration(from: args)
|
||||
writeAck(client, ok: true)
|
||||
DispatchQueue.main.async { [onNewWindow] in onNewWindow(config) }
|
||||
|
||||
case .toggleQuickTerminal:
|
||||
// Not implemented yet.
|
||||
writeAck(client, ok: false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse CLI-style arguments into a surface configuration, mirroring the
|
||||
/// GTK receiver (`apprt/gtk/class/application.zig`, `actionNewWindow`).
|
||||
static func surfaceConfiguration(from args: [String]) -> Ghostty.SurfaceConfiguration {
|
||||
var config = Ghostty.SurfaceConfiguration()
|
||||
var directCommand: [String] = []
|
||||
var eSeen = false
|
||||
|
||||
for arg in args {
|
||||
if eSeen {
|
||||
directCommand.append(arg)
|
||||
} else if arg == "-e" {
|
||||
eSeen = true
|
||||
} else if let v = arg.ipcStripPrefix("--command=") {
|
||||
config.command = v
|
||||
} else if let v = arg.ipcStripPrefix("--working-directory=") {
|
||||
config.workingDirectory = v.trimmingCharacters(in: .whitespaces)
|
||||
} else if let v = arg.ipcStripPrefix("--title=") {
|
||||
config.title = v.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
|
||||
// `-e` is a direct command (argv). libghostty's surface command always
|
||||
// runs via `/bin/sh -c`, so shell-quote each argument to preserve word
|
||||
// boundaries (e.g. arguments that themselves contain spaces).
|
||||
if !directCommand.isEmpty {
|
||||
config.command = directCommand.map { Ghostty.Shell.quote($0) }.joined(separator: " ")
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// MARK: - Socket reading helpers
|
||||
|
||||
private func readUInt8(_ fd: Int32) -> UInt8? {
|
||||
var v: UInt8 = 0
|
||||
let ok = withUnsafeMutableBytes(of: &v) { readFull(fd, into: $0) }
|
||||
return ok ? v : nil
|
||||
}
|
||||
|
||||
private func readUInt32(_ fd: Int32) -> UInt32? {
|
||||
var v: UInt32 = 0
|
||||
let ok = withUnsafeMutableBytes(of: &v) { readFull(fd, into: $0) }
|
||||
return ok ? UInt32(littleEndian: v) : nil
|
||||
}
|
||||
|
||||
private func readArguments(_ fd: Int32) -> [String]? {
|
||||
guard let count = readUInt32(fd) else { return nil }
|
||||
var result: [String] = []
|
||||
result.reserveCapacity(Int(count))
|
||||
for _ in 0..<count {
|
||||
guard let len = readUInt32(fd) else { return nil }
|
||||
if len == 0 {
|
||||
result.append("")
|
||||
continue
|
||||
}
|
||||
var buf = [UInt8](repeating: 0, count: Int(len))
|
||||
let ok = buf.withUnsafeMutableBytes { readFull(fd, into: $0) }
|
||||
guard ok, let s = String(bytes: buf, encoding: .utf8) else { return nil }
|
||||
result.append(s)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Read exactly `buffer.count` bytes, looping over short reads.
|
||||
private func readFull(_ fd: Int32, into buffer: UnsafeMutableRawBufferPointer) -> Bool {
|
||||
guard let base = buffer.baseAddress else { return true }
|
||||
var total = 0
|
||||
while total < buffer.count {
|
||||
let n = read(fd, base.advanced(by: total), buffer.count - total)
|
||||
if n <= 0 { return false }
|
||||
total += n
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func writeAck(_ fd: Int32, ok: Bool) {
|
||||
var b: UInt8 = ok ? 0 : 1
|
||||
_ = withUnsafeBytes(of: &b) { write(fd, $0.baseAddress, 1) }
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
/// If the string starts with `prefix`, return the remainder, else nil.
|
||||
func ipcStripPrefix(_ prefix: String) -> String? {
|
||||
guard hasPrefix(prefix) else { return nil }
|
||||
return String(dropFirst(prefix.count))
|
||||
}
|
||||
}
|
||||
|
|
@ -641,6 +641,10 @@ extension Ghostty {
|
|||
/// Explicit command to set
|
||||
var command: String?
|
||||
|
||||
/// Forced title. When set, the surface ignores title change requests
|
||||
/// from the running program (e.g. OSC 0/2 escape sequences).
|
||||
var title: String?
|
||||
|
||||
/// Environment variables to set for the terminal
|
||||
var environmentVariables: [String: String] = [:]
|
||||
|
||||
|
|
@ -663,6 +667,9 @@ extension Ghostty {
|
|||
if let command = config.command {
|
||||
self.command = String.init(cString: command, encoding: .utf8)
|
||||
}
|
||||
if let title = config.title {
|
||||
self.title = String.init(cString: title, encoding: .utf8)
|
||||
}
|
||||
|
||||
// Convert the C env vars to Swift dictionary
|
||||
if config.env_var_count > 0, let envVars = config.env_vars {
|
||||
|
|
@ -718,30 +725,34 @@ extension Ghostty {
|
|||
return try command.withCString { cCommand in
|
||||
config.command = cCommand
|
||||
|
||||
return try initialInput.withCString { cInput in
|
||||
config.initial_input = cInput
|
||||
return try title.withCString { cTitle in
|
||||
config.title = cTitle
|
||||
|
||||
// Convert dictionary to arrays for easier processing
|
||||
let keys = Array(environmentVariables.keys)
|
||||
let values = Array(environmentVariables.values)
|
||||
return try initialInput.withCString { cInput in
|
||||
config.initial_input = cInput
|
||||
|
||||
// Create C strings for all keys and values
|
||||
return try keys.withCStrings { keyCStrings in
|
||||
return try values.withCStrings { valueCStrings in
|
||||
// Create array of ghostty_env_var_s
|
||||
var envVars = [ghostty_env_var_s]()
|
||||
envVars.reserveCapacity(environmentVariables.count)
|
||||
for i in 0..<environmentVariables.count {
|
||||
envVars.append(ghostty_env_var_s(
|
||||
key: keyCStrings[i],
|
||||
value: valueCStrings[i]
|
||||
))
|
||||
}
|
||||
// Convert dictionary to arrays for easier processing
|
||||
let keys = Array(environmentVariables.keys)
|
||||
let values = Array(environmentVariables.values)
|
||||
|
||||
return try envVars.withUnsafeMutableBufferPointer { buffer in
|
||||
config.env_vars = buffer.baseAddress
|
||||
config.env_var_count = environmentVariables.count
|
||||
return try body(&config)
|
||||
// Create C strings for all keys and values
|
||||
return try keys.withCStrings { keyCStrings in
|
||||
return try values.withCStrings { valueCStrings in
|
||||
// Create array of ghostty_env_var_s
|
||||
var envVars = [ghostty_env_var_s]()
|
||||
envVars.reserveCapacity(environmentVariables.count)
|
||||
for i in 0..<environmentVariables.count {
|
||||
envVars.append(ghostty_env_var_s(
|
||||
key: keyCStrings[i],
|
||||
value: valueCStrings[i]
|
||||
))
|
||||
}
|
||||
|
||||
return try envVars.withUnsafeMutableBufferPointer { buffer in
|
||||
config.env_vars = buffer.baseAddress
|
||||
config.env_var_count = environmentVariables.count
|
||||
return try body(&config)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
@testable import Ghostty
|
||||
import Testing
|
||||
|
||||
struct IPCServerTests {
|
||||
// MARK: - title
|
||||
|
||||
@Test func titleFlag() {
|
||||
let config = IPCServer.surfaceConfiguration(from: ["--title=My Window"])
|
||||
#expect(config.title == "My Window")
|
||||
}
|
||||
|
||||
@Test func titleFlagWithSpaces() {
|
||||
let config = IPCServer.surfaceConfiguration(from: ["--title=REMOTE: nutty-strawberry"])
|
||||
#expect(config.title == "REMOTE: nutty-strawberry")
|
||||
}
|
||||
|
||||
@Test func titleFlagTrimsWhitespace() {
|
||||
let config = IPCServer.surfaceConfiguration(from: ["--title= padded "])
|
||||
#expect(config.title == "padded")
|
||||
}
|
||||
|
||||
@Test func titleFlagEmpty() {
|
||||
let config = IPCServer.surfaceConfiguration(from: ["--title="])
|
||||
#expect(config.title == "")
|
||||
}
|
||||
|
||||
@Test func noTitleFlag() {
|
||||
let config = IPCServer.surfaceConfiguration(from: [])
|
||||
#expect(config.title == nil)
|
||||
}
|
||||
|
||||
// MARK: - working directory
|
||||
|
||||
@Test func workingDirectoryFlag() {
|
||||
let config = IPCServer.surfaceConfiguration(from: ["--working-directory=/tmp/foo"])
|
||||
#expect(config.workingDirectory == "/tmp/foo")
|
||||
}
|
||||
|
||||
@Test func workingDirectoryFlagTrimsWhitespace() {
|
||||
let config = IPCServer.surfaceConfiguration(from: ["--working-directory= /tmp/foo "])
|
||||
#expect(config.workingDirectory == "/tmp/foo")
|
||||
}
|
||||
|
||||
@Test func noWorkingDirectoryFlag() {
|
||||
let config = IPCServer.surfaceConfiguration(from: [])
|
||||
#expect(config.workingDirectory == nil)
|
||||
}
|
||||
|
||||
// MARK: - command
|
||||
|
||||
@Test func commandFlag() {
|
||||
let config = IPCServer.surfaceConfiguration(from: ["--command=vim"])
|
||||
#expect(config.command == "vim")
|
||||
}
|
||||
|
||||
@Test func noCommandFlag() {
|
||||
let config = IPCServer.surfaceConfiguration(from: [])
|
||||
#expect(config.command == nil)
|
||||
}
|
||||
|
||||
// MARK: - -e (direct command)
|
||||
|
||||
@Test func eFlagSingleArg() {
|
||||
let config = IPCServer.surfaceConfiguration(from: ["-e", "vim"])
|
||||
#expect(config.command == "vim")
|
||||
}
|
||||
|
||||
@Test func eFlagMultipleArgs() {
|
||||
let config = IPCServer.surfaceConfiguration(from: ["-e", "ssh", "myhost"])
|
||||
#expect(config.command == "ssh myhost")
|
||||
}
|
||||
|
||||
@Test func eFlagArgsWithSpacesAreQuoted() {
|
||||
let config = IPCServer.surfaceConfiguration(from: ["-e", "ssh", "my host"])
|
||||
// "my host" contains a space so Shell.quote wraps it in single quotes
|
||||
#expect(config.command == "ssh 'my host'")
|
||||
}
|
||||
|
||||
@Test func eFlagConsumeAllRemainingArgs() {
|
||||
let config = IPCServer.surfaceConfiguration(from: ["-e", "dtach", "-A", "/tmp/work.sock", "bash", "-l"])
|
||||
#expect(config.command == "dtach -A /tmp/work.sock bash -l")
|
||||
}
|
||||
|
||||
@Test func eFlagAfterOtherFlags() {
|
||||
let config = IPCServer.surfaceConfiguration(from: [
|
||||
"--title=My Window",
|
||||
"--working-directory=/tmp",
|
||||
"-e", "ssh", "myhost",
|
||||
])
|
||||
#expect(config.title == "My Window")
|
||||
#expect(config.workingDirectory == "/tmp")
|
||||
#expect(config.command == "ssh myhost")
|
||||
}
|
||||
|
||||
// MARK: - arg order / combinations
|
||||
|
||||
@Test func allFlagsTogether() {
|
||||
let config = IPCServer.surfaceConfiguration(from: [
|
||||
"--working-directory=/home/user",
|
||||
"--title=REMOTE: work",
|
||||
"-e", "ssh", "myhost", "-t", "bash",
|
||||
])
|
||||
#expect(config.workingDirectory == "/home/user")
|
||||
#expect(config.title == "REMOTE: work")
|
||||
#expect(config.command == "ssh myhost -t bash")
|
||||
}
|
||||
|
||||
@Test func emptyArgs() {
|
||||
let config = IPCServer.surfaceConfiguration(from: [])
|
||||
#expect(config.title == nil)
|
||||
#expect(config.workingDirectory == nil)
|
||||
#expect(config.command == nil)
|
||||
}
|
||||
|
||||
@Test func unknownFlagsAreIgnored() {
|
||||
let config = IPCServer.surfaceConfiguration(from: ["--unknown-flag=value"])
|
||||
#expect(config.title == nil)
|
||||
#expect(config.workingDirectory == nil)
|
||||
#expect(config.command == nil)
|
||||
}
|
||||
|
||||
@Test func eFlagWithNoArgs() {
|
||||
// -e with nothing after it should produce no command
|
||||
let config = IPCServer.surfaceConfiguration(from: ["-e"])
|
||||
#expect(config.command == nil)
|
||||
}
|
||||
|
||||
// MARK: - rssh real-world cases
|
||||
|
||||
@Test func rsshNuttyCase() {
|
||||
let config = IPCServer.surfaceConfiguration(from: [
|
||||
"--working-directory=/Users/andtung/mp",
|
||||
"--title=REMOTE: nutty-strawberry",
|
||||
"-e", "rdev", "ssh", "traffic-envoy/nutty-strawberry", "--non-tmux",
|
||||
])
|
||||
#expect(config.title == "REMOTE: nutty-strawberry")
|
||||
#expect(config.command == "rdev ssh traffic-envoy/nutty-strawberry --non-tmux")
|
||||
}
|
||||
|
||||
@Test func rsshWorkCase() {
|
||||
let config = IPCServer.surfaceConfiguration(from: [
|
||||
"--working-directory=/Users/andtung/mp",
|
||||
"--title=REMOTE: ld1 work",
|
||||
"-e", "ssh", "andtung-ld1.linkedin.biz", "-t", "dtach -A /tmp/work.sock bash -l",
|
||||
])
|
||||
#expect(config.title == "REMOTE: ld1 work")
|
||||
#expect(config.command != nil)
|
||||
#expect(config.command!.hasPrefix("ssh andtung-ld1.linkedin.biz"))
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ const CoreSurface = @import("../Surface.zig");
|
|||
const configpkg = @import("../config.zig");
|
||||
const Config = configpkg.Config;
|
||||
const String = @import("../main_c.zig").String;
|
||||
const ipc_new_window = @import("embedded/ipc/new_window.zig");
|
||||
|
||||
const log = std.log.scoped(.embedded_window);
|
||||
|
||||
|
|
@ -329,13 +330,17 @@ pub const App = struct {
|
|||
/// some other process that is not Ghostty) there is no full-featured apprt App
|
||||
/// to use.
|
||||
pub fn performIpc(
|
||||
_: Allocator,
|
||||
_: apprt.ipc.Target,
|
||||
alloc: Allocator,
|
||||
target: apprt.ipc.Target,
|
||||
comptime action: apprt.ipc.Action.Key,
|
||||
_: apprt.ipc.Action.Value(action),
|
||||
) (Allocator.Error || std.posix.WriteError || apprt.ipc.Errors)!bool {
|
||||
value: apprt.ipc.Action.Value(action),
|
||||
) !bool {
|
||||
// IPC is only implemented for macOS. Other embedded targets (e.g. iOS)
|
||||
// have no notion of a separate CLI process talking to a running app.
|
||||
if (comptime builtin.target.os.tag != .macos) return false;
|
||||
|
||||
switch (action) {
|
||||
.new_window => return false,
|
||||
.new_window => return ipc_new_window.newWindow(alloc, target, value),
|
||||
.toggle_quick_terminal => return false,
|
||||
}
|
||||
}
|
||||
|
|
@ -450,6 +455,12 @@ pub const Surface = struct {
|
|||
/// future once we have a concrete use case.
|
||||
command: ?[*:0]const u8 = null,
|
||||
|
||||
/// A title to force for the surface. If this is set then the surface
|
||||
/// will use this title and ignore any title change requests from the
|
||||
/// running program (e.g. OSC 0/2 escape sequences). This mirrors the
|
||||
/// `title` configuration option but on a per-surface basis.
|
||||
title: ?[*:0]const u8 = null,
|
||||
|
||||
/// Extra environment variables to set for the surface.
|
||||
env_vars: ?[*]EnvVar = null,
|
||||
env_var_count: usize = 0,
|
||||
|
|
@ -536,6 +547,16 @@ pub const Surface = struct {
|
|||
}
|
||||
}
|
||||
|
||||
// If we have a title from the options then we force it. Setting the
|
||||
// title via config causes Ghostty to ignore title change requests
|
||||
// from the running program (see Surface.handleMessage).
|
||||
if (opts.title) |c_title| {
|
||||
const t = std.mem.sliceTo(c_title, 0);
|
||||
if (t.len > 0) {
|
||||
config.title = try config.arenaAlloc().dupeZ(u8, t);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply any environment variables that were requested.
|
||||
if (opts.env_var_count > 0) {
|
||||
const alloc = config.arenaAlloc();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,152 @@
|
|||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const apprt = @import("../../../apprt.zig");
|
||||
const build_config = @import("../../../build_config.zig");
|
||||
|
||||
// Use a Unix domain socket to open a new window in a running Ghostty instance.
|
||||
//
|
||||
// `ghostty +new-window` is equivalent to connecting to the socket and sending
|
||||
// a frame with action=new_window and no arguments.
|
||||
//
|
||||
// `ghostty +new-window -e echo hello` sends the same frame with the arguments
|
||||
// ["--working-directory=<cwd>", "-e", "echo", "hello"].
|
||||
pub fn newWindow(
|
||||
alloc: Allocator,
|
||||
target: apprt.ipc.Target,
|
||||
value: apprt.ipc.Action.NewWindow,
|
||||
) (Allocator.Error || std.Io.Writer.Error || apprt.ipc.Errors)!bool {
|
||||
var buf: [256]u8 = undefined;
|
||||
var stderr_writer = std.fs.File.stderr().writer(&buf);
|
||||
const stderr = &stderr_writer.interface;
|
||||
|
||||
var path_buf: [std.posix.PATH_MAX]u8 = undefined;
|
||||
const path = socketPath(&path_buf, target) catch {
|
||||
try stderr.print("ghostty: failed to determine the Ghostty IPC socket path\n", .{});
|
||||
try stderr.flush();
|
||||
return error.IPCFailed;
|
||||
};
|
||||
|
||||
const stream = std.net.connectUnixSocket(path) catch |err| {
|
||||
try stderr.print(
|
||||
"ghostty: unable to reach a running Ghostty instance ({s}): {s}. Is Ghostty running?\n",
|
||||
.{ path, @errorName(err) },
|
||||
);
|
||||
try stderr.flush();
|
||||
return error.IPCFailed;
|
||||
};
|
||||
defer stream.close();
|
||||
|
||||
// Frame: [u8 action][u32 argc] then argc times [u32 len][bytes].
|
||||
// All integers little-endian.
|
||||
var frame: std.ArrayList(u8) = .empty;
|
||||
defer frame.deinit(alloc);
|
||||
try frame.append(alloc, @intCast(@intFromEnum(apprt.ipc.Action.Key.new_window)));
|
||||
|
||||
const arguments = value.arguments orelse &.{};
|
||||
try appendU32(&frame, alloc, @intCast(arguments.len));
|
||||
for (arguments) |arg| {
|
||||
try appendU32(&frame, alloc, @intCast(arg.len));
|
||||
try frame.appendSlice(alloc, arg);
|
||||
}
|
||||
|
||||
stream.writeAll(frame.items) catch |err| {
|
||||
try stderr.print("ghostty: failed to send the IPC request: {s}\n", .{@errorName(err)});
|
||||
try stderr.flush();
|
||||
return error.IPCFailed;
|
||||
};
|
||||
|
||||
// A non-zero acknowledgement byte means the instance rejected the
|
||||
// request. A missing ack is not treated as fatal: the request may have
|
||||
// been handled anyway and there's nothing useful to retry.
|
||||
var ack: [1]u8 = .{0};
|
||||
const n = stream.read(&ack) catch 0;
|
||||
if (n == 1 and ack[0] != 0) {
|
||||
try stderr.print("ghostty: the running Ghostty instance could not handle the request\n", .{});
|
||||
try stderr.flush();
|
||||
return error.IPCFailed;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Build the socket path. Both this and the Swift listener resolve the
|
||||
/// per-user temp dir via confstr so they agree without relying on $TMPDIR.
|
||||
fn socketPath(buf: []u8, target: apprt.ipc.Target) ![]const u8 {
|
||||
var dir_buf: [std.posix.PATH_MAX]u8 = undefined;
|
||||
const n = confstr(CS_DARWIN_USER_TEMP_DIR, &dir_buf, dir_buf.len);
|
||||
const dir: []const u8 = if (n > 0 and n <= dir_buf.len)
|
||||
std.mem.sliceTo(&dir_buf, 0)
|
||||
else
|
||||
"/tmp/";
|
||||
|
||||
return socketPathForDir(buf, target, dir);
|
||||
}
|
||||
|
||||
/// Build the socket path given an explicit directory. The directory is
|
||||
/// expected to end with a path separator. Separated from socketPath so
|
||||
/// it can be tested without a real confstr call.
|
||||
fn socketPathForDir(buf: []u8, target: apprt.ipc.Target, dir: []const u8) ![]const u8 {
|
||||
const instance: []const u8 = switch (target) {
|
||||
.class => |class| class,
|
||||
.detect => build_config.bundle_id,
|
||||
};
|
||||
|
||||
// The Darwin temp dir already ends in a path separator.
|
||||
return std.fmt.bufPrint(buf, "{s}ghostty-ipc-{s}.sock", .{ dir, instance });
|
||||
}
|
||||
|
||||
fn appendU32(frame: *std.ArrayList(u8), alloc: Allocator, v: u32) Allocator.Error!void {
|
||||
var tmp: [4]u8 = undefined;
|
||||
std.mem.writeInt(u32, &tmp, v, .little);
|
||||
try frame.appendSlice(alloc, &tmp);
|
||||
}
|
||||
|
||||
const CS_DARWIN_USER_TEMP_DIR: c_int = 65537;
|
||||
extern "c" fn confstr(name: c_int, buf: [*]u8, len: usize) usize;
|
||||
|
||||
test "socketPath: detect uses bundle id" {
|
||||
var buf: [std.posix.PATH_MAX]u8 = undefined;
|
||||
const path = try socketPathForDir(&buf, .detect, "/tmp/");
|
||||
try std.testing.expectEqualStrings(
|
||||
"/tmp/ghostty-ipc-" ++ build_config.bundle_id ++ ".sock",
|
||||
path,
|
||||
);
|
||||
}
|
||||
|
||||
test "socketPath: class uses provided name" {
|
||||
var buf: [std.posix.PATH_MAX]u8 = undefined;
|
||||
const path = try socketPathForDir(&buf, .{ .class = "com.example.ghostty-debug" }, "/tmp/");
|
||||
try std.testing.expectEqualStrings(
|
||||
"/tmp/ghostty-ipc-com.example.ghostty-debug.sock",
|
||||
path,
|
||||
);
|
||||
}
|
||||
|
||||
test "socketPath: dir already has trailing separator" {
|
||||
var buf: [std.posix.PATH_MAX]u8 = undefined;
|
||||
const path = try socketPathForDir(&buf, .detect, "/var/folders/xx/yyy/T/");
|
||||
try std.testing.expect(std.mem.startsWith(u8, path, "/var/folders/xx/yyy/T/ghostty-ipc-"));
|
||||
try std.testing.expect(std.mem.endsWith(u8, path, ".sock"));
|
||||
}
|
||||
|
||||
test "appendU32: little-endian encoding" {
|
||||
var frame: std.ArrayList(u8) = .empty;
|
||||
defer frame.deinit(std.testing.allocator);
|
||||
try appendU32(&frame, std.testing.allocator, 0x01020304);
|
||||
try std.testing.expectEqualSlices(u8, &.{ 0x04, 0x03, 0x02, 0x01 }, frame.items);
|
||||
}
|
||||
|
||||
test "appendU32: zero" {
|
||||
var frame: std.ArrayList(u8) = .empty;
|
||||
defer frame.deinit(std.testing.allocator);
|
||||
try appendU32(&frame, std.testing.allocator, 0);
|
||||
try std.testing.expectEqualSlices(u8, &.{ 0x00, 0x00, 0x00, 0x00 }, frame.items);
|
||||
}
|
||||
|
||||
test "appendU32: max value" {
|
||||
var frame: std.ArrayList(u8) = .empty;
|
||||
defer frame.deinit(std.testing.allocator);
|
||||
try appendU32(&frame, std.testing.allocator, std.math.maxInt(u32));
|
||||
try std.testing.expectEqualSlices(u8, &.{ 0xFF, 0xFF, 0xFF, 0xFF }, frame.items);
|
||||
}
|
||||
Loading…
Reference in New Issue