Files
ghostty-rs/crates/ghostty-macos/src/ghostty/package.rs
T
ddidderr 0137168c66 feat(macos): foundation of the macOS app ported from Swift to Rust
Phase 9, MA1: crates/ghostty-macos (AppKit via objc2; empty stub on
non-Apple targets) ports the foundation of Ghostty 1.3.1's Swift macOS
app (ghostty/macos): main, AppDelegate (+Ghostty) with lifecycle, dock
menu, open-file, notifications, shortcut sync and quit/reopen paths;
MainMenu.xib as generated menu tables built into NSMenus; the
Ghostty.App/Config/Surface wrappers driving libghostty in-process via
ghostty-lib; the generic helpers; scripts/macos-bundle.sh to assemble
Ghostty.app (dry-run on Linux). Feature areas are stubbed for MA2-MA5.
Design and type mapping: docs/rewrite/notes/macos-app-design.md
(App Intents is a Swift-only framework and cannot be ported).

Compile-checked only (no macOS here): just check-macos passes for
aarch64/x86_64-apple-darwin with -D warnings; 125 platform-independent
tests run on Linux.

Test Plan:
- just check-macos
- cargo test -p ghostty-macos
2026-10-12 19:25:44 +02:00

393 lines
12 KiB
Rust

//! `GhosttyPackage.swift` (minus the notification names, which are in
//! [`super::notification`]) and `GhosttyPackageMeta.swift`.
#![allow(unsafe_code, clippy::wildcard_imports)]
#![cfg_attr(test, allow(clippy::assert_is_empty))]
use std::ffi::{CStr, c_int};
use super::capi::{
consts::*,
ghostty_clipboard_content_s,
ghostty_info,
ghostty_string_free,
ghostty_string_s,
};
// MARK: C Extensions
/// The user notification category identifier
pub const USER_NOTIFICATION_CATEGORY: &str = "com.mitchellh.ghostty.userNotification";
/// The user notification "Show" action
pub const USER_NOTIFICATION_ACTION_SHOW: &str = "com.mitchellh.ghostty.userNotification.Show";
// MARK: Build Info
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Info {
/// `ghostty_build_mode_e`
pub mode: c_int,
pub version: String,
}
/// `Ghostty.info`.
#[must_use]
pub fn info() -> Info {
let raw = ghostty_info();
let version = if raw.version.is_null() {
// NSString(bytes:...) of nothing falls back to "unknown".
"unknown".to_string()
} else {
// SAFETY: `version` points to `version_len` bytes that live for the process.
let bytes =
unsafe { std::slice::from_raw_parts(raw.version.cast::<u8>(), raw.version_len) };
std::str::from_utf8(bytes).map_or_else(|_| "unknown".to_string(), str::to_string)
};
Info {
mode: raw.build_mode,
version,
}
}
// MARK: General Helpers
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LaunchSource {
Cli,
App,
ZigRun,
}
impl LaunchSource {
/// `init?(rawValue:)`.
#[must_use]
pub fn from_raw_value(raw: &str) -> Option<Self> {
match raw {
"cli" => Some(Self::Cli),
"app" => Some(Self::App),
"zig_run" => Some(Self::ZigRun),
_ => None,
}
}
/// Pure form of [`launch_source`]: `env` is the value of
/// `GHOSTTY_MAC_LAUNCH_SOURCE`.
#[must_use]
pub fn from_env_value(env: Option<&str>) -> Self {
// We default to the CLI because the app bundle always sets the
// source. If its unset we assume we're in a CLI environment.
let Some(value) = env else {
return Self::Cli;
};
// If the env var is set but its unknown then we default back to the app.
Self::from_raw_value(value).unwrap_or(Self::App)
}
}
/// Returns the mechanism that launched the app. This is based on an env var so
/// its up to the env var being set in the correct circumstance.
#[must_use]
pub fn launch_source() -> LaunchSource {
LaunchSource::from_env_value(std::env::var("GHOSTTY_MAC_LAUNCH_SOURCE").ok().as_deref())
}
// MARK: Swift Types for C Types
/// A `ghostty_string_s` that is freed with `ghostty_string_free` when dropped.
pub struct AllocatedString {
c_string: ghostty_string_s,
}
impl AllocatedString {
#[must_use]
pub fn new(c: ghostty_string_s) -> Self {
Self { c_string: c }
}
/// The contents; empty if null or not valid UTF-8.
#[must_use]
pub fn string(&self) -> String {
if self.c_string.ptr.is_null() {
return String::new();
}
// SAFETY: `ptr`/`len` describe the allocation owned by this value.
let bytes = unsafe {
std::slice::from_raw_parts(self.c_string.ptr.cast::<u8>(), self.c_string.len)
};
String::from_utf8(bytes.to_vec()).unwrap_or_default()
}
}
impl Drop for AllocatedString {
fn drop(&mut self) {
ghostty_string_free(self.c_string);
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SetFloatWindow {
On,
Off,
Toggle,
}
impl SetFloatWindow {
/// `from(_ c: ghostty_action_float_window_e)`.
#[must_use]
pub fn from_c(c: c_int) -> Option<Self> {
match c {
GHOSTTY_FLOAT_WINDOW_ON => Some(Self::On),
GHOSTTY_FLOAT_WINDOW_OFF => Some(Self::Off),
GHOSTTY_FLOAT_WINDOW_TOGGLE => Some(Self::Toggle),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SetSecureInput {
On,
Off,
Toggle,
}
impl SetSecureInput {
/// `from(_ c: ghostty_action_secure_input_e)`.
#[must_use]
pub fn from_c(c: c_int) -> Option<Self> {
match c {
GHOSTTY_SECURE_INPUT_ON => Some(Self::On),
GHOSTTY_SECURE_INPUT_OFF => Some(Self::Off),
GHOSTTY_SECURE_INPUT_TOGGLE => Some(Self::Toggle),
_ => None,
}
}
}
/// An enum that is used for the directions that a split focus event can change.
///
/// TODO(MA3): `toSplitTreeFocusDirection` (`SplitTree<ViewType>.FocusDirection`)
/// lives with the SplitTree port in `features::splits`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SplitFocusDirection {
Previous,
Next,
Up,
Down,
Left,
Right,
}
impl SplitFocusDirection {
/// Initialize from a Ghostty API enum (`ghostty_action_goto_split_e`).
#[must_use]
pub fn from_direction(direction: c_int) -> Option<Self> {
match direction {
GHOSTTY_GOTO_SPLIT_PREVIOUS => Some(Self::Previous),
GHOSTTY_GOTO_SPLIT_NEXT => Some(Self::Next),
GHOSTTY_GOTO_SPLIT_UP => Some(Self::Up),
GHOSTTY_GOTO_SPLIT_DOWN => Some(Self::Down),
GHOSTTY_GOTO_SPLIT_LEFT => Some(Self::Left),
GHOSTTY_GOTO_SPLIT_RIGHT => Some(Self::Right),
_ => None,
}
}
#[must_use]
pub const fn to_native(self) -> c_int {
match self {
Self::Previous => GHOSTTY_GOTO_SPLIT_PREVIOUS,
Self::Next => GHOSTTY_GOTO_SPLIT_NEXT,
Self::Up => GHOSTTY_GOTO_SPLIT_UP,
Self::Down => GHOSTTY_GOTO_SPLIT_DOWN,
Self::Left => GHOSTTY_GOTO_SPLIT_LEFT,
Self::Right => GHOSTTY_GOTO_SPLIT_RIGHT,
}
}
}
/// Enum used for resizing splits. This is the direction the split divider will move.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SplitResizeDirection {
Up,
Down,
Left,
Right,
}
impl SplitResizeDirection {
/// `from(direction: ghostty_action_resize_split_direction_e)`.
#[must_use]
pub fn from_direction(direction: c_int) -> Option<Self> {
match direction {
GHOSTTY_RESIZE_SPLIT_UP => Some(Self::Up),
GHOSTTY_RESIZE_SPLIT_DOWN => Some(Self::Down),
GHOSTTY_RESIZE_SPLIT_LEFT => Some(Self::Left),
GHOSTTY_RESIZE_SPLIT_RIGHT => Some(Self::Right),
_ => None,
}
}
#[must_use]
pub const fn to_native(self) -> c_int {
match self {
Self::Up => GHOSTTY_RESIZE_SPLIT_UP,
Self::Down => GHOSTTY_RESIZE_SPLIT_DOWN,
Self::Left => GHOSTTY_RESIZE_SPLIT_LEFT,
Self::Right => GHOSTTY_RESIZE_SPLIT_RIGHT,
}
}
}
/// Identity of an `NSPasteboard` (its address). Swift's
/// `osc_52_write(OSPasteboard?)` holds the pasteboard object; Ghostty only ever
/// uses the two process-lifetime pasteboards (`NSPasteboard.general` and
/// `NSPasteboard.ghosttySelection`), so the identity is enough here and the
/// AppKit side turns it back into the object. Not dereferenced in this module.
pub type PasteboardHandle = usize;
/// The type of a clipboard request
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ClipboardRequest {
/// A direct paste of clipboard contents
Paste,
/// An application is attempting to read from the clipboard using OSC 52
Osc52Read,
/// An application is attempting to write to the clipboard using OSC 52
Osc52Write(Option<PasteboardHandle>),
}
impl ClipboardRequest {
/// The text to show in the clipboard confirmation prompt for a given request type
#[must_use]
pub const fn text(&self) -> &'static str {
match self {
Self::Paste => {
"Pasting this text to the terminal may be dangerous as it looks like some commands may be executed."
}
Self::Osc52Read => {
"An application is attempting to read from the clipboard.\nThe current clipboard contents are shown below."
}
Self::Osc52Write(_) => {
"An application is attempting to write to the clipboard.\nThe content to write is shown below."
}
}
}
/// `from(request: ghostty_clipboard_request_e)`.
#[must_use]
pub fn from_request(request: c_int) -> Option<Self> {
match request {
GHOSTTY_CLIPBOARD_REQUEST_PASTE => Some(Self::Paste),
GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ => Some(Self::Osc52Read),
GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE => Some(Self::Osc52Write(None)),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ClipboardContent {
pub mime: String,
pub data: String,
}
impl ClipboardContent {
/// `from(content: ghostty_clipboard_content_s)`.
///
/// # Safety
/// Non-null `mime` / `data` must be NUL terminated strings.
#[must_use]
pub unsafe fn from_c(content: &ghostty_clipboard_content_s) -> Option<Self> {
if content.mime.is_null() || content.data.is_null() {
return None;
}
// SAFETY: non-null and NUL terminated per the contract.
let (mime, data) = unsafe { (CStr::from_ptr(content.mime), CStr::from_ptr(content.data)) };
Some(Self {
mime: mime.to_string_lossy().into_owned(),
data: data.to_string_lossy().into_owned(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn launch_source_defaults() {
assert_eq!(LaunchSource::from_env_value(None), LaunchSource::Cli);
assert_eq!(LaunchSource::from_env_value(Some("app")), LaunchSource::App);
assert_eq!(
LaunchSource::from_env_value(Some("zig_run")),
LaunchSource::ZigRun
);
assert_eq!(LaunchSource::from_env_value(Some("???")), LaunchSource::App);
}
#[test]
fn split_directions_round_trip() {
for d in [
SplitFocusDirection::Previous,
SplitFocusDirection::Next,
SplitFocusDirection::Up,
SplitFocusDirection::Down,
SplitFocusDirection::Left,
SplitFocusDirection::Right,
] {
assert_eq!(SplitFocusDirection::from_direction(d.to_native()), Some(d));
}
assert_eq!(SplitFocusDirection::from_direction(99), None);
for d in [
SplitResizeDirection::Up,
SplitResizeDirection::Down,
SplitResizeDirection::Left,
SplitResizeDirection::Right,
] {
assert_eq!(SplitResizeDirection::from_direction(d.to_native()), Some(d));
}
}
#[test]
fn clipboard_request_from_c() {
assert_eq!(
ClipboardRequest::from_request(GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE),
Some(ClipboardRequest::Osc52Write(None))
);
assert_eq!(ClipboardRequest::from_request(42), None);
assert!(ClipboardRequest::Paste.text().contains("dangerous"));
}
#[test]
fn clipboard_content() {
let c = ghostty_clipboard_content_s {
mime: c"text/plain".as_ptr(),
data: c"hi".as_ptr(),
};
// SAFETY: static C strings.
let content = unsafe { ClipboardContent::from_c(&c) };
assert_eq!(
content,
Some(ClipboardContent {
mime: "text/plain".into(),
data: "hi".into()
})
);
let n = ghostty_clipboard_content_s {
mime: std::ptr::null(),
data: c"hi".as_ptr(),
};
// SAFETY: null is handled.
assert!(unsafe { ClipboardContent::from_c(&n) }.is_none());
}
#[test]
fn info_has_version() {
assert!(!info().version.is_empty());
}
}