Files
claude-app-builder-4-mac/Sources/MiniTerm/main.swift
T
marcus.hinz c8f5714b1e libghostty als zweite Terminal-Engine integriert (Default ghostty, SwiftTerm Fallback)
- vendor/libghostty: vorgebaute macOS-libghostty.a (arm64) + Header + Build-Patches + README
- Sources/CGhostty: C-Modul der libghostty-C-API
- Sources/MiniTerm/Ghostty.swift: GhosttyApp + GhosttySurfaceView (Surface, Input, Größe, Fokus)
- main.swift: Engine-Wahl per Info.plist MTEngine
- Package.swift: CGhostty-Target + Link gegen libghostty + Frameworks
- STATUS.md: Stand, Build-Weg (macOS-15-VM), offene Punkte
2026-06-21 21:40:00 +02:00

243 lines
9.2 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import SwiftTerm
// MARK: - Configuration
//
// Every value is read from the app bundle's Info.plist, so a single compiled
// binary can power many different .app bundles (each with its own command,
// working directory, title and icon). See make-app.sh.
//
// MTCommand String Command to run, e.g. "claude". Empty = plain shell.
// MTWorkingDirectory String Directory to start in. "~" is expanded. Empty = home.
// MTFontSize Number Font point size (default 13).
// MTFontName String Font family name (default "Menlo"). Falls back to
// the system monospaced font if the name is missing.
// MTWindowTitle String Window title (default = CFBundleName).
//
struct AppConfig {
let command: String
let workingDirectory: String?
let fontSize: CGFloat
let fontName: String
let windowTitle: String
let engine: String // "ghostty" (Default) oder "swiftterm"
static func load() -> AppConfig {
let info = Bundle.main.infoDictionary ?? [:]
let cmd = ((info["MTCommand"] as? String) ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
let engine = ((info["MTEngine"] as? String) ?? "ghostty")
.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
var workdir: String? = nil
if let raw = info["MTWorkingDirectory"] as? String,
!raw.trimmingCharacters(in: .whitespaces).isEmpty {
workdir = (raw as NSString).expandingTildeInPath
}
let size = (info["MTFontSize"] as? NSNumber)?.doubleValue ?? 13.0
let fontName = ((info["MTFontName"] as? String) ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
let title = (info["MTWindowTitle"] as? String)
?? (info["CFBundleName"] as? String)
?? "Terminal"
return AppConfig(command: cmd,
workingDirectory: workdir,
fontSize: CGFloat(size),
fontName: fontName.isEmpty ? "Menlo" : fontName,
windowTitle: title,
engine: engine == "swiftterm" ? "swiftterm" : "ghostty")
}
}
// MARK: - App delegate
final class AppDelegate: NSObject, NSApplicationDelegate, LocalProcessTerminalViewDelegate {
private var window: NSWindow!
private var terminalView: LocalProcessTerminalView!
private var keyMonitor: Any?
private var ghosttyApp: GhosttyApp?
private var ghosttyView: GhosttySurfaceView?
private let config = AppConfig.load()
func applicationDidFinishLaunching(_ notification: Notification) {
let frame = NSRect(x: 0, y: 0, width: 900, height: 560)
window = NSWindow(
contentRect: frame,
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false)
window.title = config.windowTitle
window.setFrameAutosaveName("MiniTermMain")
window.center()
// Engine wählen. libghostty (GPU-Renderer) ist Default; SwiftTerm bleibt
// als Fallback (auch wenn die libghostty-Init scheitert).
if config.engine == "ghostty", let contentView = setupGhostty(frame: frame) {
window.contentView = contentView
window.makeKeyAndOrderFront(nil)
window.makeFirstResponder(contentView)
} else {
setupSwiftTerm(frame: frame)
}
setupMenu()
}
private func setupGhostty(frame: NSRect) -> NSView? {
guard let gapp = GhosttyApp() else {
NSLog("libghostty-Init fehlgeschlagen Fallback auf SwiftTerm")
return nil
}
ghosttyApp = gapp
let view = GhosttySurfaceView(ghostty: gapp, appConfig: config, frame: frame)
ghosttyView = view
return view
}
private func setupSwiftTerm(frame: NSRect) {
terminalView = LocalProcessTerminalView(frame: frame)
terminalView.processDelegate = self
terminalView.font = resolvedFont()
installKeyMonitor()
window.contentView = terminalView
window.makeKeyAndOrderFront(nil)
window.makeFirstResponder(terminalView)
launchProcess()
}
private func launchProcess() {
let shell = userShell()
// We always go through a LOGIN shell (-l) so PATH is set up exactly like
// in a normal terminal (Homebrew, ~/.local/bin, etc.). Without this,
// tools like `claude` are often "command not found".
let args: [String]
if config.command.isEmpty {
args = ["-l"]
} else {
// Run the requested command, then hand control to an interactive
// login shell so the window stays open after the command exits.
args = ["-l", "-c", "\(config.command); exec \(shell) -l"]
}
let env = Terminal.getEnvironmentVariables(termName: "xterm-256color", trueColor: true)
terminalView.startProcess(
executable: shell,
args: args,
environment: env,
execName: nil,
currentDirectory: config.workingDirectory)
}
// Intercept Shift+Return (and Shift+Enter on the numpad) before it reaches
// the terminal and send a line feed (\n) instead of the normal carriage
// return (\r). This lets CLIs like Claude Code insert a newline in
// multi-line input while plain Enter still submits. We use a local event
// monitor because SwiftTerm's keyDown(with:) is `public`, not `open`, so it
// can't be overridden from outside the module.
private func installKeyMonitor() {
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self = self else { return event }
// Only act when our window is focused and the keyboard focus is in
// the terminal (the view itself or one of its subviews).
guard self.window.isKeyWindow else { return event }
if let responder = self.window.firstResponder as? NSView,
!(responder === self.terminalView || responder.isDescendant(of: self.terminalView)) {
return event
}
let isReturn = (event.keyCode == 36 || event.keyCode == 76)
let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
if isReturn && mods == .shift {
self.terminalView.send(txt: "\n")
return nil // consume the event
}
return event
}
}
// Resolve the configured font, falling back gracefully so the app never
// launches with a broken/empty font if the name doesn't exist on the system.
private func resolvedFont() -> NSFont {
if let f = NSFont(name: config.fontName, size: config.fontSize) {
return f
}
if let menlo = NSFont(name: "Menlo", size: config.fontSize) {
return menlo
}
return NSFont.monospacedSystemFont(ofSize: config.fontSize, weight: .regular)
}
private func userShell() -> String {
if let shell = ProcessInfo.processInfo.environment["SHELL"], !shell.isEmpty {
return shell
}
return "/bin/zsh"
}
private func setupMenu() {
let mainMenu = NSMenu()
// Application menu
let appItem = NSMenuItem()
mainMenu.addItem(appItem)
let appMenu = NSMenu()
let name = config.windowTitle
appMenu.addItem(withTitle: "Hide \(name)",
action: #selector(NSApplication.hide(_:)),
keyEquivalent: "h")
appMenu.addItem(NSMenuItem.separator())
appMenu.addItem(withTitle: "Quit \(name)",
action: #selector(NSApplication.terminate(_:)),
keyEquivalent: "q")
appItem.submenu = appMenu
// Edit menu (enables C / V in the terminal)
let editItem = NSMenuItem()
mainMenu.addItem(editItem)
let editMenu = NSMenu(title: "Edit")
editMenu.addItem(withTitle: "Copy",
action: #selector(NSText.copy(_:)),
keyEquivalent: "c")
editMenu.addItem(withTitle: "Paste",
action: #selector(NSText.paste(_:)),
keyEquivalent: "v")
editItem.submenu = editMenu
NSApp.mainMenu = mainMenu
}
// MARK: LocalProcessTerminalViewDelegate
func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) {}
func setTerminalTitle(source: LocalProcessTerminalView, title: String) {
// Keep a stable window title. Uncomment to follow the shell's title:
// window.title = title.isEmpty ? config.windowTitle : title
}
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {}
func processTerminated(source: TerminalView, exitCode: Int32?) {
NSApp.terminate(nil)
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
}
// MARK: - Entry point
let app = NSApplication.shared
app.setActivationPolicy(.regular) // a real Dock app, not a background process
let delegate = AppDelegate()
app.delegate = delegate
app.activate(ignoringOtherApps: true)
app.run()