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:
1 parent
fb79cc8d5c
commit
0137168c66
112 files changed
+18438
-2
No files matched your search
@@ -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))
|
||||
@@ -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("];")
|
||||
Executable
+217
@@ -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"
|
||||
@@ -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
|
||||
Reference in new issue
Block a user