mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-14 07:46:12 +03:00
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:
@ -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);
|
||||
|
@ -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 */,
|
||||
|
@ -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?) {
|
||||
|
@ -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 = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
|
29
macos/Sources/Helpers/Extensions/NSWorkspace+Extension.swift
Normal file
29
macos/Sources/Helpers/Extensions/NSWorkspace+Extension.swift
Normal 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)
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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]);
|
||||
}
|
||||
|
Reference in New Issue
Block a user