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
This commit is contained in:
ddidderr committed 2026-10-12 19:25:44 +02:00
1 parent fb79cc8d5c
commit 0137168c66
112 files changed
+18438 -2

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Generate crates/ghostty-macos/src/ghostty/capi/consts.rs from
crates/ghostty-lib/include/ghostty.h: every enumerator becomes a
`pub const NAME: c_int` (the header's enums are C `int`s)."""
import re, sys, pathlib
root = pathlib.Path(__file__).resolve().parent.parent
hdr = (root / "crates/ghostty-lib/include/ghostty.h").read_text()
hdr = re.sub(r"/\*.*?\*/", "", hdr, flags=re.S)
hdr = re.sub(r"//[^\n]*", "", hdr)
out = [
"//! GENERATED by scripts/gen-macos-capi-consts.py from include/ghostty.h; do not edit.",
"//!",
"//! The enumerators of every C enum of the header (all `int`).",
"#![allow(missing_docs)]",
"",
"use std::ffi::c_int;",
"",
]
for m in re.finditer(r"typedef\s+enum\s*(?:\w+\s*)?\{(.*?)\}\s*(\w+)\s*;", hdr, flags=re.S):
body, name = m.group(1), m.group(2)
out.append(f"// {name}")
nxt = 0
for item in [x.strip() for x in body.split(",") if x.strip()]:
if "=" in item:
n, v = [x.strip() for x in item.split("=", 1)]
v = v.replace("UL", "").replace("U", "")
nxt = eval(v)
else:
n = item
out.append(f"pub const {n}: c_int = {nxt};")
nxt += 1
out.append("")
sys.stdout.write("\n".join(out))
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""Generate the data tables of crates/ghostty-macos/src/app/menu_spec/generated.rs
from Sources/App/macOS/MainMenu.xib of the Swift app (no ibtool needed).
Usage: scripts/gen-macos-menu-spec.py [path/to/MainMenu.xib] > generated.rs"""
import sys, pathlib
import xml.etree.ElementTree as ET
root = pathlib.Path(__file__).resolve().parent.parent
default = root.parent / "ghostty/macos/Sources/App/macOS/MainMenu.xib"
xib = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else default
r = ET.parse(xib).getroot()
ids = {} # object id -> customObject class
for c in r.find("objects").findall("customObject"):
ids[c.get("id")] = c.get("customClass")
outlets = {} # menuItem id -> property
for c in r.find("objects").findall("customObject"):
for o in c.iter("outlet"):
if o.get("property", "").startswith("menu"):
outlets[o.get("destination")] = o.get("property")
used = []
def camel(p):
return p[0].upper() + p[1:]
def rs(s):
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
def mods(item):
m = item.find("modifierMask")
if m is None:
return "None" # IB default: command
parts = [n for n in ("shift", "control", "option", "command") if m.get(n) == "YES"]
if not parts:
return "Some(Mods::NONE)"
return "Some(Mods(" + " | ".join("Mods::" + p.upper() + ".0" for p in parts) + "))"
def target(t):
if t == "-1":
return "Target::FirstResponder"
if t == "bbz-4X-AYv":
return "Target::AppDelegate"
raise SystemExit("unknown target " + str(t))
def menu(m, ind):
pad = " " * ind
sysm = {"apple": "Some(SystemMenu::Apple)", "window": "Some(SystemMenu::Window)",
"help": "Some(SystemMenu::Help)", "main": "Some(SystemMenu::Main)"}.get(m.get("systemMenu"), "None")
mo = outlets.get(m.get("id"))
if mo:
used.append(mo)
mo = ("Some(Outlet::" + camel(mo) + ")") if mo else "None"
out = [f"MenuSpec {{ title: {rs(m.get('title'))}, system_menu: {sysm}, outlet: {mo}, items: &["]
items = m.find("items")
for i in (items.findall("menuItem") if items is not None else []):
if i.get("isSeparatorItem") == "YES":
out.append(f"{pad} ItemSpec::Separator,")
continue
act = i.find("connections/action")
action = "None"
if act is not None:
action = f"Some(Action {{ selector: {rs(act.get('selector'))}, target: {target(act.get('target'))} }})"
outlet = outlets.get(i.get("id"))
if outlet:
used.append(outlet)
sub = i.find("menu")
out.append(f"{pad} ItemSpec::Item(Item {{")
out.append(f"{pad} title: {rs(i.get('title'))},")
out.append(f"{pad} key_equivalent: {rs(i.get('keyEquivalent', ''))},")
out.append(f"{pad} modifiers: {mods(i)},")
out.append(f"{pad} action: {action},")
out.append(f"{pad} outlet: {('Some(Outlet::' + camel(outlet) + ')') if outlet else 'None'},")
if sub is not None:
out.append(f"{pad} submenu: Some(&" + menu(sub, ind + 2) + "),")
else:
out.append(f"{pad} submenu: None,")
out.append(f"{pad} }}),")
out.append(f"{pad}] }}")
return "\n".join(out)
top = r.find("objects").find("menu")
body = menu(top, 1)
print("//! GENERATED by scripts/gen-macos-menu-spec.py from MainMenu.xib; do not edit.")
print("//!")
print("//! Every menu item of the xib: title, key equivalent, modifier mask, action")
print("//! selector + target (`-1` first responder / the AppDelegate) and the AppDelegate")
print("//! outlet it is connected to.")
print()
print("use super::{Action, Item, ItemSpec, MenuSpec, Mods, Outlet, SystemMenu, Target};")
print()
print("/// The main menu (`systemMenu=\"main\"`).")
print("pub const MAIN_MENU: MenuSpec = " + body + ";")
print()
print("/// All outlets connected in the xib (the Swift names).")
print("pub const CONNECTED_OUTLETS: &[Outlet] = &[")
for o in sorted(set(used)):
print(f" Outlet::{camel(o)},")
print("];")
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env bash
# Assemble Ghostty.app from the Rust macOS app (crate ghostty-macos).
#
# Mirrors what Xcode does for the Swift app's `Ghostty` target
# (macos/Ghostty.xcodeproj): compile the executable, expand Ghostty-Info.plist
# (+ the INFOPLIST_KEY_* build settings), compile Assets.xcassets, copy
# Ghostty.sdef and the resources, sign with the entitlements. libghostty is NOT
# linked or embedded: the Rust app drives ghostty-lib in-process.
#
# Usage: scripts/macos-bundle.sh [--profile PROFILE] [--target TRIPLE]
# [--out DIR] [--debug] [--dock-tile-plugin PATH]
# [--sign IDENTITY] [--dry-run]
# PROFILE cargo profile (default: release; "dev" is the debug build)
# TRIPLE aarch64-apple-darwin or x86_64-apple-darwin (default: host)
# --out output directory (default: target/macos-app/<profile>)
# --debug use the debug identity: bundle id com.mitchellh.ghostty.debug,
# display name "Ghostty[DEBUG]", GhosttyDebug.entitlements
# --sign codesign identity (default "-", ad hoc)
#
# It only runs for real on macOS (PlistBuddy, iconutil, sips, codesign, cargo for
# an Apple target). On any other host it is a dry run: every step is printed
# and the Swift-side inputs are checked to exist. DRY_RUN=1 forces that on macOS.
set -euo pipefail
root=$(cd "$(dirname "$0")/.." && pwd)
mac="$root/../ghostty/macos"
profile=release
target=
out=
debug=0
sign=-
plugin=
dry=${DRY_RUN:-0}
while [ $# -gt 0 ]; do
case $1 in
--profile) profile=$2; shift 2 ;;
--target) target=$2; shift 2 ;;
--out) out=$2; shift 2 ;;
--debug) debug=1; shift ;;
--sign) sign=$2; shift 2 ;;
--dock-tile-plugin) plugin=$2; shift 2 ;;
--dry-run) dry=1; shift ;;
-h | --help) sed -n '2,/^set -e/p' "$0" | sed '$d'; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
if [ "$(uname -s)" != Darwin ]; then
dry=1
fi
run() {
if [ "$dry" = 1 ]; then
printf '+'
printf ' %q' "$@"
printf '\n'
else
"$@"
fi
}
# Inputs from the Swift app tree (Xcode project settings in comments).
version=$(sed -n 's/^app-version = "\(.*\)"/\1/p' "$root/Cargo.toml")
[ -n "$version" ] || version=1.3.1
if [ "$debug" = 1 ]; then
bundle_id=com.mitchellh.ghostty.debug
display_name='Ghostty[DEBUG]'
entitlements="$mac/GhosttyDebug.entitlements"
else
bundle_id=com.mitchellh.ghostty
display_name=Ghostty
entitlements="$mac/Ghostty.entitlements"
fi
min_os=13.0 # MACOSX_DEPLOYMENT_TARGET of the Ghostty target
case $(uname -m) in arm64 | aarch64) host=aarch64-apple-darwin ;; *) host=x86_64-apple-darwin ;; esac
target=${target:-$host}
pdir=$profile
[ "$profile" = dev ] && pdir=debug
tdir=${CARGO_TARGET_DIR:-$root/target}
exe="$tdir/$target/$pdir/ghostty-macos-app"
out=${out:-$root/target/macos-app/$pdir}
app="$out/Ghostty.app"
contents="$app/Contents"
res="$contents/Resources"
plist="$contents/Info.plist"
pb=/usr/libexec/PlistBuddy
for f in "$mac/Ghostty-Info.plist" "$mac/Ghostty.sdef" "$mac/Assets.xcassets" "$entitlements" \
"$root/resources/terminfo" "$root/resources/themes" "$root/resources/shell-integration"; do
[ -e "$f" ] || { echo "missing input: $f" >&2; exit 1; }
done
echo "== Ghostty.app ($version, $bundle_id, $target, profile $profile) -> $app"
[ "$dry" = 1 ] && echo "== dry run: nothing is built or written"
# 1. The executable (CFBundleExecutable = ghostty, like the Swift product name).
run cargo build --profile "$profile" -p ghostty-macos --bin ghostty-macos-app --target "$target"
run rm -rf "$app"
run mkdir -p "$contents/MacOS" "$res"
run cp "$exe" "$contents/MacOS/ghostty"
# 2. Info.plist: Ghostty-Info.plist plus the build settings Xcode folds in
# (GENERATE_INFOPLIST_FILE = YES, INFOPLIST_KEY_*). NSMainNibFile is not set:
# the Rust app builds MainMenu.xib in code (app/main_menu.rs).
run cp "$mac/Ghostty-Info.plist" "$plist"
plist_set() { # key type value
if [ "$dry" = 1 ]; then
printf '+ PlistBuddy -c %q %q\n' "Add :$1 $2 $3" "$plist"
else
"$pb" -c "Delete :$1" "$plist" 2>/dev/null || true
"$pb" -c "Add :$1 $2 $3" "$plist"
fi
}
plist_set CFBundleDevelopmentRegion string en
plist_set CFBundleExecutable string ghostty
plist_set CFBundleIdentifier string "$bundle_id"
plist_set CFBundleInfoDictionaryVersion string 6.0
plist_set CFBundleName string Ghostty
plist_set CFBundleDisplayName string "$display_name"
plist_set CFBundlePackageType string APPL
plist_set CFBundleShortVersionString string "$version"
plist_set CFBundleVersion string 1
plist_set CFBundleIconFile string AppIcon
plist_set CFBundleIconName string Ghostty
plist_set LSMinimumSystemVersion string "$min_os"
plist_set LSApplicationCategoryType string public.app-category.developer-tools
plist_set NSPrincipalClass string NSApplication
plist_set NSHumanReadableCopyright string ""
plist_set GhosttyBuild string "$version"
commit=$(git -C "$root" rev-parse --short HEAD 2>/dev/null || true)
plist_set GhosttyCommit string "$commit"
while IFS='|' read -r key text; do
plist_set "$key" string "$text"
done <<'KEYS'
NSAppleEventsUsageDescription|A program running within Ghostty would like to use AppleScript.
NSAudioCaptureUsageDescription|A program running within Ghostty would like to access your system's audio.
NSBluetoothAlwaysUsageDescription|A program running within Ghostty would like to use Bluetooth.
NSCalendarsUsageDescription|A program running within Ghostty would like to access your Calendar.
NSCameraUsageDescription|A program running within Ghostty would like to use the camera.
NSContactsUsageDescription|A program running within Ghostty would like to access your Contacts.
NSLocalNetworkUsageDescription|A program running within Ghostty would like to access the local network.
NSLocationUsageDescription|A program running within Ghostty would like to access your location information.
NSMicrophoneUsageDescription|A program running within Ghostty would like to use your microphone.
NSMotionUsageDescription|A program running within Ghostty would like to access motion data.
NSPhotoLibraryUsageDescription|A program running within Ghostty would like to access your Photo Library.
NSRemindersUsageDescription|A program running within Ghostty would like to access your reminders.
NSSpeechRecognitionUsageDescription|A program running within Ghostty would like to use speech recognition.
NSSystemAdministrationUsageDescription|A program running within Ghostty requires elevated privileges.
KEYS
# NSLocationTemporaryUsageDescriptionDictionary (a dict, not a string)
if [ "$dry" = 1 ]; then
printf '+ PlistBuddy -c %q %q\n' 'Add :NSLocationTemporaryUsageDescriptionDictionary dict' "$plist"
else
"$pb" -c 'Add :NSLocationTemporaryUsageDescriptionDictionary dict' "$plist" || true
fi
# NSServices menu item titles use $(INFOPLIST_KEY_CFBundleDisplayName).
if [ "$dry" = 1 ]; then
echo "+ substitute \$(INFOPLIST_KEY_CFBundleDisplayName) -> $display_name in $plist"
else
sed -i '' "s/\$(INFOPLIST_KEY_CFBundleDisplayName)/$display_name/g" "$plist"
fi
# 3. Dock tile plugin (a separate bundle target in Xcode; ported by MA4).
if [ -n "$plugin" ]; then
run mkdir -p "$contents/PlugIns"
run cp -R "$plugin" "$contents/PlugIns/DockTilePlugin.plugin"
else
echo "-- no --dock-tile-plugin: dropping NSDockTilePlugIn from Info.plist"
if [ "$dry" = 1 ]; then
printf '+ PlistBuddy -c %q %q\n' 'Delete :NSDockTilePlugIn' "$plist"
else
"$pb" -c 'Delete :NSDockTilePlugIn' "$plist" || true
fi
fi
# 4. AppleScript definition (OSAScriptingDefinition = Ghostty.sdef).
run cp "$mac/Ghostty.sdef" "$res/Ghostty.sdef"
# 5. Icons from Assets.xcassets (actool equivalent). AppIcon.icns for the app;
# the alternate/custom icon images are copied as plain resources so
# `Bundle.image(forResource:)` finds them.
iconset="$out/AppIcon.iconset"
src="$mac/Assets.xcassets/AppIconImage.imageset/macOS-AppIcon-1024px.png"
run rm -rf "$iconset"
run mkdir -p "$iconset"
for s in 16 32 128 256 512; do
run sips -z $s $s "$src" --out "$iconset/icon_${s}x${s}.png"
run sips -z $((s * 2)) $((s * 2)) "$src" --out "$iconset/icon_${s}x${s}@2x.png"
done
run iconutil -c icns "$iconset" -o "$res/AppIcon.icns"
run cp "$src" "$res/AppIconImage.png"
for img in "$mac"/Assets.xcassets/Alternate\ Icons/*.imageset; do
name=$(basename "$img" .imageset)
run cp "$img/macOS-AppIcon-1024px.png" "$res/$name.png"
done
for img in "$mac"/Assets.xcassets/Custom\ Icon/*.imageset; do
name=$(basename "$img" .imageset)
png=$(cd "$img" 2>/dev/null && ls ./*.png 2>/dev/null | head -n1 || true)
[ -n "$png" ] && run cp "$img/${png#./}" "$res/$name.png"
done
run cp "$mac/Assets.xcassets/ResetZoom.imageset/ResetZoom.pdf" "$res/ResetZoom.pdf"
# 6. Ghostty resources (shell integration, themes, terminfo). The lookup done by
# ghostty-os `resources_dir()` on macOS: Contents/Resources/terminfo/78/xterm-ghostty
# is the sentinel and the resources dir is Contents/Resources/ghostty.
run mkdir -p "$res/ghostty" "$res/terminfo/78" "$res/terminfo/67"
run cp -R "$root/resources/shell-integration" "$res/ghostty/shell-integration"
run cp -R "$root/resources/themes" "$res/ghostty/themes"
run cp "$root/resources/terminfo/x/xterm-ghostty" "$res/terminfo/78/xterm-ghostty"
run cp "$root/resources/terminfo/g/ghostty" "$res/terminfo/67/ghostty"
# 7. Sign (entitlements of the Xcode target).
run codesign --force --deep --sign "$sign" --entitlements "$entitlements" "$app"
echo "== done: $app"
+10 -1
View File
@@ -5,6 +5,8 @@
# Usage: scripts/macos-crosscheck.sh [check|clippy] [target]
# MACOS_CHECK_FEATURES="ghostty-font/font-backend-freetype" adds cargo features
# (for example the FreeType-only font backend on Apple).
# MACOS_CHECK_ONLY="ghostty-macos" restricts the check to the given crates
# (space separated), for quick iteration.
set -euo pipefail
cd "$(dirname "$0")/.."
MODE=${1:-clippy}
@@ -55,13 +57,20 @@ export GETTEXT_DIR=/nonexistent ZIG_GLOBAL_CACHE_DIR="$W/zig-cache" ZIG_LOCAL_CA
CRATES=(-p ghostty-vt -p ghostty-unicode -p ghostty-datastruct -p ghostty-os -p ghostty-input
-p ghostty-config -p ghostty-terminal -p ghostty-font -p ghostty-renderer -p ghostty-termio
-p ghostty-core -p ghostty-apprt -p ghostty-inspector -p ghostty-lib -p ghostty-crash
-p ghostty-bench -p ghostty-vt-capi)
-p ghostty-bench -p ghostty-vt-capi -p ghostty-macos)
if [ -n "${MACOS_CHECK_ONLY:-}" ]; then
CRATES=()
for c in $MACOS_CHECK_ONLY; do CRATES+=(-p "$c"); done
fi
FEATURES=()
if [ -n "${MACOS_CHECK_FEATURES:-}" ]; then
FEATURES=(--features "$MACOS_CHECK_FEATURES")
fi
if [ "$MODE" = clippy ]; then
cargo clippy --target "$TARGET" "${CRATES[@]}" "${FEATURES[@]}" --lib --tests -- -D warnings
# The macOS app executable (an Apple-only binary).
cargo clippy --target "$TARGET" -p ghostty-macos --bins -- -D warnings
else
cargo check --target "$TARGET" "${CRATES[@]}" "${FEATURES[@]}" --lib --tests
cargo check --target "$TARGET" -p ghostty-macos --bins
fi