401ddd6f98
- ghostty-template/Ghostty.app: lokal gebautes Ghostty v1.3.1 mit DockTilePlugin-Patch (eigenes Dock-Icon ueber Contents/Resources/DockIcon.png) - make-ghostty-app.sh: erzeugt pro Projekt eine App; Slots fuer icon/workdir/command/theme/bg/fg/font/font-size - rebuild-template.sh: baut Template lokal neu (nur Xcode, kein Zig/VM), gepinnt auf vendor/libghostty v1.3.1 - ghostty-template/dock-icon.patch: der DockTilePlugin-Patch - icons/robotunits.png - enthaelt zudem die vorausgehende MiniTerm/libghostty-Integration
253 lines
9.8 KiB
Swift
253 lines
9.8 KiB
Swift
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).
|
||
// MTCloseOnExit Bool Close the window when the command exits (default
|
||
// false = drop back into an interactive login shell).
|
||
//
|
||
struct AppConfig {
|
||
let command: String
|
||
let workingDirectory: String?
|
||
let fontSize: CGFloat
|
||
let fontName: String
|
||
let windowTitle: String
|
||
let engine: String // "ghostty" (Default) oder "swiftterm"
|
||
let closeOnExit: Bool // true = Fenster schließt, wenn der Befehl endet
|
||
|
||
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"
|
||
|
||
let closeOnExit = (info["MTCloseOnExit"] as? NSNumber)?.boolValue ?? false
|
||
|
||
return AppConfig(command: cmd,
|
||
workingDirectory: workdir,
|
||
fontSize: CGFloat(size),
|
||
fontName: fontName.isEmpty ? "Menlo" : fontName,
|
||
windowTitle: title,
|
||
engine: engine == "swiftterm" ? "swiftterm" : "ghostty",
|
||
closeOnExit: closeOnExit)
|
||
}
|
||
}
|
||
|
||
// 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 if config.closeOnExit {
|
||
// Run the command and let the process end with it; the window
|
||
// closes via processTerminated().
|
||
args = ["-l", "-c", config.command]
|
||
} 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()
|