macos: open URLs with NSWorkspace APIs instead of open

Fixes #5256

This updates the macOS apprt to implement the `OPEN_URL` apprt action to
use the NSWorkspace APIs instead of the `open` command line utility.

As part of this, we removed the `ghostty_config_open` libghostty API and
instead introduced a new `ghostty_config_open_path` API that returns the
path to open, and then we use the `NSWorkspace` APIs to open it (same
function as the `OPEN_URL` action).
This commit is contained in:
Mitchell Hashimoto
2025-07-06 20:23:20 -07:00
parent db45fab85e
commit b7ffbf933f
11 changed files with 212 additions and 29 deletions

View File

@ -350,6 +350,11 @@ typedef struct {
const char* message;
} ghostty_diagnostic_s;
typedef struct {
const char* ptr;
uintptr_t len;
} ghostty_string_s;
typedef struct {
double tl_px_x;
double tl_px_y;
@ -797,6 +802,7 @@ int ghostty_init(uintptr_t, char**);
void ghostty_cli_try_action(void);
ghostty_info_s ghostty_info(void);
const char* ghostty_translate(const char*);
void ghostty_string_free(ghostty_string_s);
ghostty_config_t ghostty_config_new();
void ghostty_config_free(ghostty_config_t);
@ -811,7 +817,7 @@ ghostty_input_trigger_s ghostty_config_trigger(ghostty_config_t,
uintptr_t);
uint32_t ghostty_config_diagnostics_count(ghostty_config_t);
ghostty_diagnostic_s ghostty_config_get_diagnostic(ghostty_config_t, uint32_t);
void ghostty_config_open();
ghostty_string_s ghostty_config_open_path(void);
ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s*,
ghostty_config_t);

View File

@ -14,6 +14,7 @@
9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; };
A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50297342DFA0F3300B4E924 /* Double+Extension.swift */; };
A505D21D2E1A2FA20018808F /* FileHandle+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A505D21C2E1A2F9E0018808F /* FileHandle+Extension.swift */; };
A505D21F2E1B6DE00018808F /* NSWorkspace+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A505D21E2E1B6DDC0018808F /* NSWorkspace+Extension.swift */; };
A511940F2E050595007258CC /* CloseTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A511940E2E050590007258CC /* CloseTerminalIntent.swift */; };
A51194112E05A483007258CC /* QuickTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194102E05A480007258CC /* QuickTerminalIntent.swift */; };
A51194132E05D006007258CC /* Optional+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194122E05D003007258CC /* Optional+Extension.swift */; };
@ -160,6 +161,7 @@
9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = "<group>"; };
A50297342DFA0F3300B4E924 /* Double+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = "<group>"; };
A505D21C2E1A2F9E0018808F /* FileHandle+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileHandle+Extension.swift"; sourceTree = "<group>"; };
A505D21E2E1B6DDC0018808F /* NSWorkspace+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWorkspace+Extension.swift"; sourceTree = "<group>"; };
A511940E2E050590007258CC /* CloseTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseTerminalIntent.swift; sourceTree = "<group>"; };
A51194102E05A480007258CC /* QuickTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalIntent.swift; sourceTree = "<group>"; };
A51194122E05D003007258CC /* Optional+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Extension.swift"; sourceTree = "<group>"; };
@ -531,6 +533,7 @@
AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */,
C1F26EA62B738B9900404083 /* NSView+Extension.swift */,
A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */,
A505D21E2E1B6DDC0018808F /* NSWorkspace+Extension.swift */,
A5985CD62C320C4500C57AD3 /* String+Extension.swift */,
A58636722DF4813000E04A10 /* UndoManager+Extension.swift */,
A5CC36142C9CDA03004D6760 /* View+Extension.swift */,
@ -819,6 +822,7 @@
A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */,
A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */,
A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */,
A505D21F2E1B6DE00018808F /* NSWorkspace+Extension.swift in Sources */,
A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */,
A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */,
A511940F2E050595007258CC /* CloseTerminalIntent.swift in Sources */,

View File

@ -932,7 +932,7 @@ class AppDelegate: NSObject,
//MARK: - IB Actions
@IBAction func openConfig(_ sender: Any?) {
ghostty.openConfig()
Ghostty.App.openConfig()
}
@IBAction func reloadConfig(_ sender: Any?) {

View File

@ -40,4 +40,34 @@ extension Ghostty.Action {
self.amount = c.amount
}
}
struct OpenURL {
enum Kind {
case unknown
case text
init(_ c: ghostty_action_open_url_kind_e) {
switch c {
case GHOSTTY_ACTION_OPEN_URL_KIND_TEXT:
self = .text
default:
self = .unknown
}
}
}
let kind: Kind
let url: String
init(c: ghostty_action_open_url_s) {
self.kind = Kind(c.kind)
if let urlCString = c.url {
let data = Data(bytes: urlCString, count: Int(c.len))
self.url = String(data: data, encoding: .utf8) ?? ""
} else {
self.url = ""
}
}
}
}

View File

@ -114,9 +114,21 @@ extension Ghostty {
ghostty_app_tick(app)
}
func openConfig() {
guard let app = self.app else { return }
ghostty_app_open_config(app)
static func openConfig() {
let str = Ghostty.AllocatedString(ghostty_config_open_path()).string
guard !str.isEmpty else { return }
#if os(macOS)
let fileURL = URL(fileURLWithPath: str).absoluteString
var action = ghostty_action_open_url_s()
action.kind = GHOSTTY_ACTION_OPEN_URL_KIND_TEXT
fileURL.withCString { cStr in
action.url = cStr
action.len = UInt(fileURL.count)
_ = openURL(action)
}
#else
fatalError("Unsupported platform for opening config file")
#endif
}
/// Reload the configuration.
@ -488,7 +500,7 @@ extension Ghostty {
pwdChanged(app, target: target, v: action.action.pwd)
case GHOSTTY_ACTION_OPEN_CONFIG:
ghostty_config_open()
openConfig()
case GHOSTTY_ACTION_FLOAT_WINDOW:
toggleFloatWindow(app, target: target, mode: action.action.float_window)
@ -547,6 +559,9 @@ extension Ghostty {
case GHOSTTY_ACTION_CHECK_FOR_UPDATES:
checkForUpdates(app)
case GHOSTTY_ACTION_OPEN_URL:
return openURL(action.action.open_url)
case GHOSTTY_ACTION_UNDO:
return undo(app, target: target)
@ -599,6 +614,34 @@ extension Ghostty {
}
}
private static func openURL(
_ v: ghostty_action_open_url_s
) -> Bool {
let action = Ghostty.Action.OpenURL(c: v)
// Convert the URL string to a URL object
guard let url = URL(string: action.url) else {
Ghostty.logger.warning("invalid URL for open URL action: \(action.url)")
return false
}
switch action.kind {
case .text:
// Open with the default text editor
if let textEditor = NSWorkspace.shared.defaultTextEditor {
NSWorkspace.shared.open([url], withApplicationAt: textEditor, configuration: NSWorkspace.OpenConfiguration())
return true
}
case .unknown:
break
}
// Open with the default application for the URL
NSWorkspace.shared.open(url)
return true
}
private static func undo(_ app: ghostty_app_t, target: ghostty_target_s) -> Bool {
let undoManager: UndoManager?
switch (target.tag) {

View File

@ -73,6 +73,26 @@ extension Ghostty {
// MARK: Swift Types for C Types
extension Ghostty {
class AllocatedString {
private let cString: ghostty_string_s
init(_ c: ghostty_string_s) {
self.cString = c
}
var string: String {
guard let ptr = cString.ptr else { return "" }
let data = Data(bytes: ptr, count: Int(cString.len))
return String(data: data, encoding: .utf8) ?? ""
}
deinit {
ghostty_string_free(cString)
}
}
}
extension Ghostty {
enum SetFloatWIndow {
case on

View File

@ -0,0 +1,29 @@
import AppKit
import UniformTypeIdentifiers
extension NSWorkspace {
/// Returns the URL of the default text editor application.
/// - Returns: The URL of the default text editor, or nil if no default text editor is found.
var defaultTextEditor: URL? {
defaultApplicationURL(forContentType: UTType.plainText.identifier)
}
/// Returns the URL of the default application for opening files with the specified content type.
/// - Parameter contentType: The content type identifier (UTI) to find the default application for.
/// - Returns: The URL of the default application, or nil if no default application is found.
func defaultApplicationURL(forContentType contentType: String) -> URL? {
return LSCopyDefaultApplicationURLForContentType(
contentType as CFString,
.all,
nil
)?.takeRetainedValue() as? URL
}
/// Returns the URL of the default application for opening files with the specified file extension.
/// - Parameter ext: The file extension to find the default application for.
/// - Returns: The URL of the default application, or nil if no default application is found.
func defaultApplicationURL(forExtension ext: String) -> URL? {
guard let uti = UTType(filenameExtension: ext) else { return nil}
return defaultApplicationURL(forContentType: uti.identifier)
}
}

View File

@ -496,7 +496,7 @@ pub fn performAction(
.resize_split => self.resizeSplit(target, value),
.equalize_splits => self.equalizeSplits(target),
.goto_split => return self.gotoSplit(target, value),
.open_config => try configpkg.edit.open(self.core_app.alloc),
.open_config => return self.openConfig(),
.config_change => self.configChange(target, value.config),
.reload_config => try self.reloadConfig(target, value),
.inspector => self.controlInspector(target, value),
@ -1759,7 +1759,22 @@ fn initActions(self: *App) void {
}
}
pub fn openUrl(
fn openConfig(self: *App) !bool {
// Get the config file path
const alloc = self.core_app.alloc;
const path = configpkg.edit.openPath(alloc) catch |err| {
log.warn("error getting config file path: {}", .{err});
return false;
};
defer alloc.free(path);
// Open it using openURL. "path" isn't actually a URL but
// at the time of writing that works just fine for GTK.
self.openUrl(.{ .kind = .text, .url = path });
return true;
}
fn openUrl(
app: *App,
value: apprt.action.OpenUrl,
) void {

View File

@ -1,7 +1,9 @@
const std = @import("std");
const assert = std.debug.assert;
const cli = @import("../cli.zig");
const inputpkg = @import("../input.zig");
const global = &@import("../global.zig").state;
const state = &@import("../global.zig").state;
const c = @import("../main_c.zig");
const Config = @import("Config.zig");
const c_get = @import("c_get.zig");
@ -12,14 +14,14 @@ const log = std.log.scoped(.config);
/// Create a new configuration filled with the initial default values.
export fn ghostty_config_new() ?*Config {
const result = global.alloc.create(Config) catch |err| {
const result = state.alloc.create(Config) catch |err| {
log.err("error allocating config err={}", .{err});
return null;
};
result.* = Config.default(global.alloc) catch |err| {
result.* = Config.default(state.alloc) catch |err| {
log.err("error creating config err={}", .{err});
global.alloc.destroy(result);
state.alloc.destroy(result);
return null;
};
@ -29,20 +31,20 @@ export fn ghostty_config_new() ?*Config {
export fn ghostty_config_free(ptr: ?*Config) void {
if (ptr) |v| {
v.deinit();
global.alloc.destroy(v);
state.alloc.destroy(v);
}
}
/// Deep clone the configuration.
export fn ghostty_config_clone(self: *Config) ?*Config {
const result = global.alloc.create(Config) catch |err| {
const result = state.alloc.create(Config) catch |err| {
log.err("error allocating config err={}", .{err});
return null;
};
result.* = self.clone(global.alloc) catch |err| {
result.* = self.clone(state.alloc) catch |err| {
log.err("error cloning config err={}", .{err});
global.alloc.destroy(result);
state.alloc.destroy(result);
return null;
};
@ -51,7 +53,7 @@ export fn ghostty_config_clone(self: *Config) ?*Config {
/// Load the configuration from the CLI args.
export fn ghostty_config_load_cli_args(self: *Config) void {
self.loadCliArgs(global.alloc) catch |err| {
self.loadCliArgs(state.alloc) catch |err| {
log.err("error loading config err={}", .{err});
};
}
@ -60,7 +62,7 @@ export fn ghostty_config_load_cli_args(self: *Config) void {
/// is usually done first. The default file locations are locations
/// such as the home directory.
export fn ghostty_config_load_default_files(self: *Config) void {
self.loadDefaultFiles(global.alloc) catch |err| {
self.loadDefaultFiles(state.alloc) catch |err| {
log.err("error loading config err={}", .{err});
};
}
@ -69,7 +71,7 @@ export fn ghostty_config_load_default_files(self: *Config) void {
/// file locations in the previously loaded configuration. This will
/// recursively continue to load up to a built-in limit.
export fn ghostty_config_load_recursive_files(self: *Config) void {
self.loadRecursiveFiles(global.alloc) catch |err| {
self.loadRecursiveFiles(state.alloc) catch |err| {
log.err("error loading config err={}", .{err});
};
}
@ -122,10 +124,13 @@ export fn ghostty_config_get_diagnostic(self: *Config, idx: u32) Diagnostic {
return .{ .message = message.ptr };
}
export fn ghostty_config_open() void {
edit.open(global.alloc) catch |err| {
export fn ghostty_config_open_path() c.String {
const path = edit.openPath(state.alloc) catch |err| {
log.err("error opening config in editor err={}", .{err});
return .empty;
};
return .fromSlice(path);
}
/// Sync with ghostty_diagnostic_s

View File

@ -5,18 +5,19 @@ const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const internal_os = @import("../os/main.zig");
/// Open the configuration in the OS default editor according to the default
/// paths the main config file could be in.
/// The path to the configuration that should be opened for editing.
///
/// On Linux, this will open the file at the XDG config path. This is the
/// On Linux, this will use the file at the XDG config path. This is the
/// only valid path for Linux so we don't need to check for other paths.
///
/// On macOS, both XDG and AppSupport paths are valid. Because Ghostty
/// prioritizes AppSupport over XDG, we will open AppSupport if it exists,
/// prioritizes AppSupport over XDG, we will use AppSupport if it exists,
/// followed by XDG if it exists, and finally AppSupport if neither exist.
/// For the existence check, we also prefer non-empty files over empty
/// files.
pub fn open(alloc_gpa: Allocator) !void {
///
/// The returned value is allocated using the provided allocator.
pub fn openPath(alloc_gpa: Allocator) ![:0]const u8 {
// Use an arena to make memory management easier in here.
var arena = ArenaAllocator.init(alloc_gpa);
defer arena.deinit();
@ -41,7 +42,7 @@ pub fn open(alloc_gpa: Allocator) !void {
}
};
try internal_os.open(alloc_gpa, .text, config_path);
return try alloc_gpa.dupeZ(u8, config_path);
}
/// Returns the config path to use for open for the current OS.

View File

@ -19,7 +19,12 @@ const internal_os = @import("os/main.zig");
// Some comptime assertions that our C API depends on.
comptime {
// We allow tests to reference this file because we unit test
// some of the C API. At runtime though we should never get these
// functions unless we are building libghostty.
if (!builtin.is_test) {
assert(apprt.runtime == apprt.embedded);
}
}
/// Global options so we can log. This is identical to main.
@ -29,7 +34,9 @@ comptime {
// These structs need to be referenced so the `export` functions
// are truly exported by the C API lib.
_ = @import("config.zig").CAPI;
if (@hasDecl(apprt.runtime, "CAPI")) {
_ = apprt.runtime.CAPI;
}
}
/// ghostty_info_s
@ -46,6 +53,24 @@ const Info = extern struct {
};
};
/// ghostty_string_s
pub const String = extern struct {
ptr: ?[*]const u8,
len: usize,
pub const empty: String = .{
.ptr = null,
.len = 0,
};
pub fn fromSlice(slice: []const u8) String {
return .{
.ptr = slice.ptr,
.len = slice.len,
};
}
};
/// Initialize ghostty global state.
export fn ghostty_init(argc: usize, argv: [*][*:0]u8) c_int {
assert(builtin.link_libc);
@ -95,3 +120,8 @@ export fn ghostty_info() Info {
export fn ghostty_translate(msgid: [*:0]const u8) [*:0]const u8 {
return internal_os.i18n._(msgid);
}
/// Free a string allocated by Ghostty.
export fn ghostty_string_free(str: String) void {
state.alloc.free(str.ptr.?[0..str.len]);
}