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

+45
View File
@@ -0,0 +1,45 @@
# macOS app, MA1: foundation
Design and work split: `macos-app-design.md`. Crate `crates/ghostty-macos` (lib `ghostty_macos`, bin `ghostty-macos-app`;
empty stub off Apple). Compile-checked only (`just check-macos`, aarch64 + x86_64, clippy `-D warnings`, lib + tests + bin);
never run on a Mac. Platform independent logic is unit-tested on Linux (125+ tests).
## Ported (Swift -> Rust)
* `App/macOS/main.swift` -> `app/entry.rs`; `AppDelegate.swift` -> `app/app_delegate.rs` (all delegate methods, lifecycle,
dock menu, open-file with execute confirmation, quit confirmation incl. shutdown/restart AppleEvent check, SIGUSR2 reload,
local key monitor, bell/dock badge, user notifications via objc2-user-notifications, appearance KVO, config-change handling,
menu shortcut sync + `performGhosttyBindingMenuKeyEquivalent`, float-on-top, set-as-default-terminal, `validateMenuItem`,
toggle visibility); `AppDelegate+Ghostty.swift` -> `app/app_delegate_ghostty.rs`.
* `MainMenu.xib` -> `app/menu_spec/` (tables generated by `scripts/gen-macos-menu-spec.py`, plus `Outlet`, `SYNCED_SHORTCUTS`,
`MENU_IMAGES`, `MenuShortcutKey`, dock menu; tested) and `app/main_menu.rs` (builds the NSMenus, system menus, NSFontManager).
* `Ghostty.App/Config/ConfigTypes/Command/Action/Error/Event/Shell/Surface`, `GhosttyPackage`, `NSEvent+Extension`, top of
`Ghostty.Input`, `SurfaceConfiguration`, a model-only `SurfaceView` -> `ghostty/*` (by forks F1-F3 of MA1; see design doc).
* `Helpers/*` generic ones -> `helpers/*`; unportable SwiftUI-only helpers listed in the design doc.
* `scripts/macos-bundle.sh` (Ghostty.app assembly, dry-run off macOS), `scripts/gen-macos-capi-consts.py`,
`scripts/gen-macos-menu-spec.py`, `scripts/macos-crosscheck.sh` now includes `ghostty-macos` (+ its bin).
* Stubs with the surface `AppDelegate`/`Ghostty.App` use (owners replace them): `features/terminal` (MA3), `quick_terminal::controller`,
`settings`, `about`, `secure_input`, `global_keybinds`, `services`, `custom_app_icon` (MA4), `update` (MA5).
## Deviations
* Menu outlets: the xib's `menuSelectionForSearch` outlet does not exist in Swift (and "Jump to Selection" is unconnected), so
those two items never get a config shortcut -- kept as upstream (module docs of `menu_spec`).
* `NSApplicationMain` + nib -> explicit NSApplication/delegate/menu setup in `entry::run` (no `NSMainNibFile`).
* `application(_:willEncode/didDecodeRestorableState:)`: quick terminal state not encoded yet (TODO(MA4)).
* `updateAppIcon`: AppIcon persistence is MA4's; only the distributed `iconDidChange` notification is posted.
* Fork-reported deviations: `Ghostty.Config.backgroundColor` falls back to a fixed light gray (Swift: dynamic
`windowBackgroundColor`); `window_fullscreen*` getters return raw strings until MA3 (typed `FullscreenMode`);
`open_config` passes the byte length of the URL (Swift `String.count` truncates non-ASCII paths); desktop notifications hop to the
main queue before touching the view; `CodableBridge` archives are not byte-compatible with Swift's; `ExpiringTarget` drop only
invalidates the timer; MIME->UTI lookups use CoreServices C functions; tab-add error is `ObjCException{name,reason}`;
`PasteboardHandle` is an identity `usize`; user-visible duration format "1 hr, 2 min, 3 sec" matches Foundation en_US
abbreviated style but the `.hide` fractional rounding is unverified off macOS.
## Known gaps / TODO(integration)
* Nothing here has run on a Mac; objc2 method signatures were only type-checked.
* `TODO(MA2)` in `ghostty/surface_view.rs`/`surface.rs`, `TODO(MA3)` terminal stubs and `fullscreen::set_is_tab_bar_hook`,
`TODO(MA4)` feature stubs, `TODO(MA5)` update.
* `SurfaceView::set_password_input` does not yet drive `SecureInput` (MA4).
* The bundle script does not embed Sparkle/the DockTilePlugin (MA5/MA4); no `.lproj` localizations exist in the Swift tree either.
+147
View File
@@ -0,0 +1,147 @@
# macOS app (Rust/AppKit port of `macos/Sources`): design
Crate `crates/ghostty-macos` (lib `ghostty_macos` + bin `ghostty-macos-app`). Written by MA1 (foundation); the
feature areas are ported by MA2..MA5 on top of it (split at the end). Nothing here has ever run on a Mac: it is
compile-checked with `scripts/macos-crosscheck.sh` (`just check-macos`, both Apple targets, `-D warnings`).
## Principles
* **Same API surface as the Swift app.** The Swift app is a client of `libghostty` through `ghostty.h`. This app calls the
*same entry points* in-process: `ghostty-lib` is a normal dependency (renamed `libghostty`) and `ghostty::capi`
re-exports its `extern "C"` functions (`ghostty_app_new`, `ghostty_surface_key`, ...), `#[repr(C)]` types and the header's
enumerators (`capi::consts`, generated from `ghostty.h` by `scripts/gen-macos-capi-consts.py`). The runtime config
(`wakeup`, `action`, `read/confirm_read/write_clipboard`, `close_surface` callbacks, userdata) is built exactly like
`Ghostty.App.init`. libghostty is not embedded or linked as a separate library: no XCFramework needed.
* **Programmatic AppKit.** No `ibtool`/Xcode on Linux, so xibs/SwiftUI become code (below). The crate compiles to an empty
stub off Apple; platform independent logic is *not* gated, so it is unit-tested on Linux.
* **Faithful port.** Swift logic is ported 1:1 (same names in snake_case, doc comments kept). Deviations: `DEVIANT_FIXES.md`
("Other deliberate deviations") and `macos-app-MA1.md`.
## Module tree (mirrors `Sources/`)
| Swift | Rust (`crates/ghostty-macos/src`) |
|---|---|
| `App/macOS/main.swift` | `app/entry.rs` (`run()`, called by `main.rs`) |
| `App/macOS/AppDelegate.swift`, `AppDelegate+Ghostty.swift` | `app/app_delegate.rs`, `app/app_delegate_ghostty.rs` |
| `App/macOS/MainMenu.xib` | `app/menu_spec/` (generated tables + pure logic) and `app/main_menu.rs` (builds the `NSMenu`s) |
| `Ghostty/Ghostty.App.swift` | `ghostty/app.rs` (`App`) |
| `Ghostty/Ghostty.Config*.swift`, `Ghostty.Command/Action/Error/Event/Shell`, `GhosttyPackage*.swift` | `ghostty/{config,config_types,command,action,error,event,shell,package,notification}.rs` |
| `Ghostty/Ghostty.Surface.swift`, `Surface View/*` | `ghostty/{surface,surface_view,surface_configuration}.rs` (+ MA2) |
| `Ghostty/Ghostty.Input.swift`, `NSEvent+Extension.swift` | `ghostty/input/`, `ghostty/ns_event.rs` |
| `Helpers/**` | `helpers/**` (extensions: `helpers/extensions/<ns_type>.rs`, one extension trait `XExt` per Swift extension) |
| `Features/<Area>/**` | `features/<area>/**` |
Platform independent modules (tested on Linux): `ghostty::{capi, config, config_types, command, action, shell, input,
notification, package, error, surface_configuration}`, `helpers::{keyboard_shortcut, color, notification_center, published,
...}`, `app::menu_spec`, `features::quick_terminal::{position,size,screen,space_behavior}`.
## Swift type -> Rust type
| Swift | Rust |
|---|---|
| `class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate` | `define_class!` `AppDelegate` (`#[thread_kind = MainThreadOnly]`); Swift stored properties live in the `Ivars` struct (`RefCell`/`Cell`); `@IBAction`/delegate methods are `#[unsafe(method(sel:))]`s with the same selectors |
| `@IBOutlet menuXxx` | `menu_spec::Outlet` enum + `HashMap<Outlet, Retained<NSMenuItem>>` filled by `main_menu::install` |
| `Ghostty.App` (`ObservableObject`) | `Rc<ghostty::app::App>`; `@Published` -> `helpers::published::Published<T>` |
| `Ghostty.Config` (owns `ghostty_config_t`) | `ghostty::config::Config` (`Drop` frees; shared as `Rc<Config>`) |
| `Ghostty.SurfaceView: NSView` | `define_class!` `SurfaceView` (`ghostty/surface_view.rs`; MA2) |
| `Ghostty.Surface` | `ghostty::surface::Surface` |
| `BaseTerminalController/TerminalController: NSWindowController` | `define_class!` classes in `features/terminal` (MA3) |
| `Weak<T>` | `objc2::rc::Weak<T>` (ObjC objects) / `std::rc::Weak` |
| Swift enums with raw strings (`init?(rawValue:)`) | Rust enums with `from_raw_value(&str)` |
| `OptionSet` | `bitflags!` |
| `SwiftUI.Color`, `KeyboardShortcut`, `KeyEquivalent`, `EventModifiers` | `helpers::{color::Color, keyboard_shortcut::*}` (same raw values, so menu key equivalents are identical) |
| `Duration` | `std::time::Duration` |
| `UserDefaults.ghostty` | `helpers::extensions::user_defaults::ghostty()` |
| `Logger` (OSLog) | `log` crate (`ghostty_init` installs the os_log logger like Zig's lib mode) |
## Threading
Everything that touches AppKit/Rust-side state runs on the **main thread** (the Swift code is `@MainActor`/main queue):
objects are `!Send` (`Rc`, `Retained<..>` of main-thread classes), `MainThreadMarker`s come from `helpers::main_thread()`.
The one cross-thread entry from libghostty is the `wakeup` callback: it only forwards the address of the `App` allocation to
`DispatchQueue.main.async`, where it is resolved through a main-thread registry of live apps and `ghostty_app_tick` runs
(`ghostty/app.rs`). UNUserNotificationCenter callbacks arrive on arbitrary queues: they only capture `Send` data and hop to
the main queue (`main_async_local` for closures with main-only captures; `AppDelegate::shared(mtm)` re-resolves the delegate).
`DispatchQueue.main.async` -> `dispatch2::DispatchQueue::main().exec_async`, `asyncAfter` -> `exec_after`, signal sources
(`SIGUSR2` config reload) -> `dispatch2::DispatchSource` with `_dispatch_source_type_signal`.
## Notifications, observers, Combine
* AppKit/Foundation notifications (`NSWindow.didBecomeKeyNotification`, ...): block based observers
(`helpers::extensions::ns_notification::observe`) returning an RAII token (dropping it = `removeObserver`).
* Ghostty's own notifications (`Notification.Name.ghosttyConfigDidChange`, `Ghostty.Notification.ghosttyNewWindow`, ...):
`helpers::notification_center::NotificationCenter` (names/keys with the same strings in `ghostty::notification`). Payloads stay
typed: `UserInfo` is a string-keyed map of `Rc<dyn Any>`, the sender (`object`) is a `Sender` (identity + downcastable handle,
e.g. `Retained<SurfaceView>`). Delivery is synchronous on the main thread, observer semantics follow Foundation (filter by
sender, tokens, snapshot on post). `Ghostty.App` posts these exactly where the Swift code does.
* Combine `$property.sink` / `@Published` / `ObservableObject`: `Published<T>` (`get/set/update/sink`, `Subscription` = cancellable).
* KVO (`NSApplication.observe(\.effectiveAppearance)`): the delegate registers itself via `addObserver:forKeyPath:` and
implements `observeValueForKeyPath:ofObject:change:context:`.
* Distributed notifications (icon change): `NSDistributedNotificationCenter` directly.
## SwiftUI views and xibs
* **xib** (`MainMenu.xib`, `About.xib`, `ConfigurationErrors.xib`, `ClipboardConfirmation.xib`, `QuickTerminal.xib`,
`Terminal*.xib`): reproduced in code. For the main menu the xib is *parsed by a script*
(`scripts/gen-macos-menu-spec.py` -> `app/menu_spec/generated.rs`: titles, key equivalents, modifier masks, selectors, targets,
outlets, system menus), and `main_menu.rs` instantiates the objects; a unit test pins the structure. Window xibs are small
(a window, style mask, content rect, connections): port them by hand as `NSWindowController::init` code with the same style
mask/frame/flags (read the XML; `scripts/gen-macos-menu-spec.py` shows how to parse it).
* **SwiftUI views** (`AboutView`, `ConfigurationErrorsView`, `ClipboardConfirmationView`, update pill/popover, `TerminalView`,
split views, command palette, `SurfaceView` overlays): rebuilt with AppKit views (`NSStackView`/Auto Layout, `NSTextField`,
`NSVisualEffectView`, `NSScrollView`, `NSPopover`, `NSHostingView` is *not* available). `@State`/`@Published` become
`Published<T>` subscriptions that update the views; `EnvironmentObject` becomes explicit references. Visual fidelity target:
same layout, text, spacing and behaviour as far as AppKit allows; record gaps in the owner's notes.
* **Unportable SwiftUI-only helpers** (no AppKit equivalent, not ported): `Backport` (view modifiers), `HostingWindow`,
`NonDraggableHostingView`, `MetalView`/`MTKView` wrapper, `View+Extension`, `Transferable+Extension`/drag-and-drop via
`Transferable` (the AppKit `NSDraggingSource/Destination` equivalent is implemented by MA2/MA3 where needed).
## Menu bar
`menu_spec` holds `MAIN_MENU` (generated from the xib), `Outlet`, `SYNCED_SHORTCUTS` (the `syncMenuShortcut(config, action:,
menuItem:)` list), `MENU_IMAGES` (SF Symbols), `DOCK_MENU` and `MenuShortcutKey` (the shortcut lookup key). `AppDelegate` ports
`syncMenuShortcuts`, `performGhosttyBindingMenuKeyEquivalent`, `validateMenuItem`, the dock menu and float-on-top handling.
Upstream quirk kept: the xib connects "Use Selection for Find" to a non-existent outlet `menuSelectionForSearch` and "Jump to
Selection" to none, so those two items never get a config shortcut (see the `menu_spec` module docs).
## Bundle (`scripts/macos-bundle.sh`)
Builds `ghostty-macos-app`, creates `Ghostty.app` (executable `ghostty`), expands `Ghostty-Info.plist` plus the Xcode
`INFOPLIST_KEY_*` settings with PlistBuddy, copies `Ghostty.sdef`, builds `AppIcon.icns` from `Assets.xcassets` with
`sips`/`iconutil` and copies the alternate/custom icon PNGs and `ResetZoom.pdf`, installs shell-integration/themes/terminfo where
`ghostty_os::resources_dir()` looks on macOS (`Contents/Resources/ghostty`, `Contents/Resources/terminfo/78/xterm-ghostty`),
signs with `Ghostty.entitlements` (`GhosttyDebug.entitlements` with `--debug`). Runs for real only on macOS; elsewhere it is a dry
run that prints every step. `NSMainNibFile` is not set (menu built in code); the DockTilePlugin is a separate bundle (MA4,
`--dock-tile-plugin`); Sparkle.framework would be copied to `Contents/Frameworks` by MA5.
## Assessments
* **App Intents** (`Features/App/Intents`, `Ghostty.Input.*: AppEnum`): the AppIntents framework is Swift-only. Intents,
entities and enums are Swift types conforming to generic Swift protocols (`AppIntent`, `AppEntity`, `AppEnum`,
`EntityQuery`, `@Parameter` property wrappers); the system discovers them from `Metadata.appintents` that the *Swift compiler
toolchain* extracts (`appintentsmetadataprocessor`) from the module. There is no Objective-C surface and no runtime registration
API, so a Rust binary cannot declare intents. Verdict: **unportable from Rust** (would need a Swift shim, i.e. not a Rust port);
MA5 documents it, ports the non-framework logic that is useful elsewhere (e.g. the `Ghostty.Input.Action/MouseButton/...`
enums, already needed by AppleScript) and records the Shortcuts/Siri loss in DEVIANT_FIXES.
* **Sparkle** (`Features/Update`): `Sparkle.framework` is Objective-C; load it at runtime (`NSBundle(path:).load()` from
`Contents/Frameworks`), look classes/protocols up with `AnyClass::get`/`AnyProtocol::get`, create `SPUStandardUpdaterController`-style
objects with `msg_send!`, and declare the `SPUUserDriver` / `SPUUpdaterDelegate` implementations with
`objc2::runtime::ClassBuilder` + `add_protocol` (a `define_class!` cannot name a protocol that is not linked at build time).
Weak linking = the lookup returns `None` when the framework is absent. `UpdateController`'s surface used by `AppDelegate` is
already in place (`start_updater`, `check_for_updates`, `is_installing`, the two automatic-update flags).
* **AppleScript** (`Features/AppleScript`): `Ghostty.sdef` + `OSAScriptingDefinition` in Info.plist (handled by the bundle
script); Cocoa scripting needs `NSObject` subclasses whose KVC keys match the sdef (`define_class!` with property getters
as methods named after the keys), `NSScriptCommand` subclasses (`performDefaultImplementation`), and the `NSApplication`
scripting hooks (`application:delegateHandlesKey:` etc.) on the delegate.
## Work split
| Agent | Area (Swift) | Notes |
|---|---|---|
| **MA2** | `Ghostty/Surface View/*` (SurfaceView AppKit: rendering host, input, IME, mouse, drag source, scroll view, progress bar, grab handle, search overlay, inspector view), rest of `Ghostty.Input.swift` (KeyEvent, Mouse*, Mods, Key enum), `Ghostty.Inspector`, `GhosttyPackage` remaining pieces | fills the `TODO(MA2)` in `ghostty/surface_view.rs`; the model fields App.swift needs already exist |
| **MA3** | `Features/Terminal/*` (BaseTerminalController, TerminalController, TerminalView, windows + `Styles/*.xib` variants, restorable state, tab color), `Features/Splits/*` (SplitTree, split views), Helpers `TabTitleEditor`, `TabGroupCloseCoordinator` | replaces the stubs in `features/terminal/mod.rs` (API already used by `AppDelegate`/`Ghostty.App`) |
| **MA4** | `Features/QuickTerminal`, `Command Palette`, `Settings` (ConfigurationErrors window + SettingsView), `About`, `ClipboardConfirmation`, `Global Keybinds` (event tap, Helpers `PermissionRequest`), `Secure Input` (+overlay), `Services` (ServiceProvider), `Custom App Icon` (AppIcon, ColorizedGhosttyIcon, DockTilePlugin, UserDefaults+AppIcon) | replace the surface stubs in each `features/<area>/mod.rs`; pure value types of quick terminal are already ported |
| **MA5** | `Features/AppleScript` (sdef + scripting classes), `Features/Update` (Sparkle via the ObjC runtime, weak-linked), `Features/App` App Intents (assess: unportable, see above) | |
Each owner: port tests of `macos/Tests/*` for the area (pure logic portable and tested on Linux, AppKit logic compile-checked),
write `docs/rewrite/notes/macos-app-<MAn>.md`, keep `just check-macos` green, list deviations.