diff --git a/include/ghostty.h b/include/ghostty.h index fbfe3ee2c..2870c711c 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -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; diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index a971df9ba..4d09340e2 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -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 diff --git a/macos/Sources/Features/IPC/IPCServer.swift b/macos/Sources/Features/IPC/IPCServer.swift new file mode 100644 index 000000000..4b7995e1d --- /dev/null +++ b/macos/Sources/Features/IPC/IPCServer.swift @@ -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: `/ghostty-ipc-.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.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.. 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)) + } +} diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index f6b30a7ad..b6b8ff595 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -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.. 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(); diff --git a/src/apprt/embedded/ipc/new_window.zig b/src/apprt/embedded/ipc/new_window.zig new file mode 100644 index 000000000..1e0bb481d --- /dev/null +++ b/src/apprt/embedded/ipc/new_window.zig @@ -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=", "-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); +}