Ghostty-per-Projekt-Builder: Template + make/rebuild-Skripte

- 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
This commit is contained in:
marcus.hinz
2026-06-24 18:50:53 +02:00
parent 7b361d3bbf
commit 401ddd6f98
8 changed files with 353 additions and 14 deletions
+2
View File
@@ -1,2 +1,4 @@
.build/
*.app/
.ghostty-build/
.DS_Store
+115 -12
View File
@@ -41,15 +41,46 @@ final class GhosttyApp {
// libghostty signalisiert Arbeit -> Tick auf dem Main-Thread.
DispatchQueue.main.async { me.tick() }
},
action_cb: { _, _, _ in
// Minimal: keine App-Aktionen (neue Fenster, Tabs, ) behandeln.
return false
action_cb: { _, _, action in
// Minimal: die meisten App-Aktionen (neue Fenster, Tabs, ) nicht
// behandeln. Endet der Befehl (closeOnExit) bzw. die Shell, meldet
// libghostty das über diese Aktionen dann die App beenden, sonst
// bliebe das Fenster mit toter Surface offen.
switch action.tag {
case GHOSTTY_ACTION_CLOSE_WINDOW,
GHOSTTY_ACTION_QUIT,
GHOSTTY_ACTION_SHOW_CHILD_EXITED:
DispatchQueue.main.async { NSApp.terminate(nil) }
return true
default:
return false
}
},
read_clipboard_cb: { _, _, _ in
return false
read_clipboard_cb: { userdata, _, state in
// libghostty will den Zwischenablage-Inhalt (Paste). Das userdata
// ist die SURFACE-userdata (= GhosttySurfaceView, in createSurface
// gesetzt). Aus dem System-Pasteboard lesen und die Anfrage
// abschließen. confirmed: true = direkt einfügen (kein
// Unsafe-Paste-Bestätigungsdialog).
guard let userdata = userdata else { return false }
let view = Unmanaged<GhosttySurfaceView>.fromOpaque(userdata).takeUnretainedValue()
guard let surface = view.surface else { return false }
let s = NSPasteboard.general.string(forType: .string) ?? ""
s.withCString {
ghostty_surface_complete_clipboard_request(surface, $0, state, true)
}
return true
},
confirm_read_clipboard_cb: { _, _, _, _ in },
write_clipboard_cb: { _, _, _, _, _ in },
write_clipboard_cb: { _, _, content, len, _ in
// libghostty schreibt in die Zwischenablage (C-Keybind, OSC 52).
// Ersten Eintrag mit Textinhalt ins System-Pasteboard übernehmen.
guard let content = content, len > 0,
let data = content.pointee.data else { return }
let s = String(cString: data)
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(s, forType: .string)
},
close_surface_cb: { _, _ in
DispatchQueue.main.async { NSApp.terminate(nil) }
}
@@ -87,7 +118,9 @@ final class GhosttyApp {
final class GhosttySurfaceView: NSView {
private let ghostty: GhosttyApp
private let appConfig: AppConfig
private var surface: ghostty_surface_t!
// fileprivate, damit die Clipboard-Callbacks in GhosttyApp (gleiche Datei)
// die Surface dieser View erreichen.
fileprivate var surface: ghostty_surface_t!
init(ghostty: GhosttyApp, appConfig: AppConfig, frame: NSRect) {
self.ghostty = ghostty
@@ -129,12 +162,16 @@ final class GhosttySurfaceView: NSView {
/// Login-Shell-Verhalten wie in der SwiftTerm-Variante: leeres MTCommand =>
/// nil (libghostty startet selbst eine Login-Shell). Sonst Befehl ausführen
/// und danach in eine interaktive Login-Shell übergehen, damit das Fenster
/// offen bleibt.
/// offen bleibt. Bei closeOnExit endet der Prozess mit dem Befehl; libghostty
/// schließt die Surface (close_surface_cb -> NSApp.terminate).
private func surfaceCommand() -> String? {
let cmd = appConfig.command.trimmingCharacters(in: .whitespacesAndNewlines)
if cmd.isEmpty { return nil }
let shell = ProcessInfo.processInfo.environment["SHELL"].flatMap { $0.isEmpty ? nil : $0 }
?? "/bin/zsh"
if appConfig.closeOnExit {
return "\(shell) -lc '\(cmd)'"
}
return "\(shell) -lc '\(cmd); exec \(shell) -l'"
}
@@ -218,9 +255,22 @@ final class GhosttySurfaceView: NSView {
k.action = action
k.mods = Self.translateMods(event.modifierFlags)
k.keycode = UInt32(event.keyCode)
k.unshifted_codepoint = (event.charactersIgnoringModifiers?.unicodeScalars.first?.value) ?? 0
let text = event.characters ?? ""
// Funktionstasten (Pfeile, F-Tasten, Home/End/Page, ) liefern unter
// AppKit Codepoints aus der Private Use Area (0xF7000xF8FF). Diese NICHT
// als Text/Codepoint übergeben sonst sendet libghostty diese Zeichen
// wörtlich statt der passenden Escape-Sequenz aus dem keycode.
func isPUAFunctionKey(_ s: String?) -> Bool {
guard let v = s?.unicodeScalars.first?.value else { return false }
return (0xF700...0xF8FF).contains(v)
}
let unshifted = event.charactersIgnoringModifiers
k.unshifted_codepoint = isPUAFunctionKey(unshifted)
? 0 : (unshifted?.unicodeScalars.first?.value ?? 0)
let chars = event.characters ?? ""
let text = isPUAFunctionKey(chars) ? "" : chars
text.withCString { ptr in
k.text = ptr
_ = ghostty_surface_key(surface, k)
@@ -246,11 +296,24 @@ final class GhosttySurfaceView: NSView {
override func mouseDown(with event: NSEvent) { mouseButton(event, .press, GHOSTTY_MOUSE_LEFT) }
override func mouseUp(with event: NSEvent) { mouseButton(event, .release, GHOSTTY_MOUSE_LEFT) }
override func rightMouseDown(with event: NSEvent) { mouseButton(event, .press, GHOSTTY_MOUSE_RIGHT) }
override func rightMouseUp(with event: NSEvent) { mouseButton(event, .release, GHOSTTY_MOUSE_RIGHT) }
override func otherMouseDown(with event: NSEvent) { mouseButton(event, .press, GHOSTTY_MOUSE_MIDDLE) }
override func otherMouseUp(with event: NSEvent) { mouseButton(event, .release, GHOSTTY_MOUSE_MIDDLE) }
// Rechte Maustaste: Shift -> Kontextmenü; Selektion vorhanden -> kopieren;
// sonst -> einfügen (PuTTY/urxvt-Verhalten). Der Klick geht nicht an die
// Surface, das Maus-Reporting per Rechtsklick entfällt damit bewusst.
override func rightMouseDown(with event: NSEvent) {
let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
if mods.contains(.shift) {
showContextMenu(event)
} else if let surface = surface, ghostty_surface_has_selection(surface) {
copy(nil)
} else {
paste(nil)
}
}
override func rightMouseUp(with event: NSEvent) {}
private enum MouseAction { case press, release }
private func mouseButton(_ event: NSEvent, _ a: MouseAction, _ button: ghostty_input_mouse_button_e) {
guard let surface = surface else { return }
@@ -289,6 +352,46 @@ final class GhosttySurfaceView: NSView {
addTrackingArea(area)
}
// MARK: Zwischenablage
// Markierten Text der Surface in die System-Zwischenablage kopieren. Der
// Selektor heißt `copy(_:)`, damit der Edit-Menü-Eintrag (C) greift.
@objc func copy(_ sender: Any?) {
guard let surface = surface, ghostty_surface_has_selection(surface) else { return }
var t = ghostty_text_s()
guard ghostty_surface_read_selection(surface, &t), let cstr = t.text else { return }
let s = String(cString: cstr)
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(s, forType: .string)
ghostty_surface_free_text(surface, &t)
}
// Zwischenablage einfügen. Über die Binding-Aktion, damit Bracketed-Paste
// erhalten bleibt (mehrzeilige Eingaben). Selektor `paste(_:)` für V.
@objc func paste(_ sender: Any?) {
guard let surface = surface else { return }
let action = "paste_from_clipboard"
_ = action.withCString {
ghostty_surface_binding_action(surface, $0, UInt(strlen($0)))
}
}
private func showContextMenu(_ event: NSEvent) {
let menu = NSMenu()
let hasSelection = surface.map { ghostty_surface_has_selection($0) } ?? false
let copyItem = NSMenuItem(title: "Kopieren", action: #selector(copy(_:)), keyEquivalent: "")
copyItem.target = self
copyItem.isEnabled = hasSelection
menu.addItem(copyItem)
let pasteItem = NSMenuItem(title: "Einfügen", action: #selector(paste(_:)), keyEquivalent: "")
pasteItem.target = self
menu.addItem(pasteItem)
NSMenu.popUpContextMenu(menu, with: event, for: self)
}
deinit {
if let surface = surface { ghostty_surface_free(surface) }
}
+11 -1
View File
@@ -13,6 +13,8 @@ import SwiftTerm
// 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
@@ -21,6 +23,7 @@ struct AppConfig {
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 ?? [:]
@@ -46,12 +49,15 @@ struct AppConfig {
?? (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")
engine: engine == "swiftterm" ? "swiftterm" : "ghostty",
closeOnExit: closeOnExit)
}
}
@@ -120,6 +126,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, LocalProcessTerminalVi
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.
+20
View File
@@ -0,0 +1,20 @@
diff --git a/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift b/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift
index 990cd8b..6cf7031 100644
--- a/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift
+++ b/macos/Sources/Features/Custom App Icon/DockTilePlugin.swift
@@ -51,6 +51,15 @@ class DockTilePlugin: NSObject, NSDockTilePlugIn {
return
}
+ // Custom per-project dock icon: if the enclosing app bundle ships a
+ // Resources/DockIcon.png, use it verbatim and skip the ghost logic.
+ if let appURL = ghosttyAppURL,
+ let img = NSImage(contentsOf: appURL.appendingPathComponent("Contents/Resources/DockIcon.png")) {
+ dockTile.setIcon(img)
+ iconChangeObserver = nil
+ return
+ }
+
// Try to restore the previous icon on launch.
iconDidChange(ghosttyUserDefaults.appIcon, dockTile: dockTile)
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+5 -1
View File
@@ -15,7 +15,8 @@
# [--font-size 13] \
# [--font "JetBrains Mono"] \
# [--icon ./icons/projekta.png] \ # .png/.jpg (auto-converted) or .icns
# [--out ~/Applications]
# [--out ~/Applications] \
# [--close-on-exit] # close the window when the command exits
#
# Build the binary fresh? The script runs `swift build -c release` automatically
# the first time and reuses the result for further apps.
@@ -31,6 +32,7 @@ FONT_SIZE="13"
FONT_NAME="Menlo"
ICON=""
OUTDIR="$HOME/Applications"
CLOSE_ON_EXIT="false"
# ---- parse args -----------------------------------------------------------
while [[ $# -gt 0 ]]; do
@@ -43,6 +45,7 @@ while [[ $# -gt 0 ]]; do
--font) FONT_NAME="$2"; shift 2 ;;
--icon) ICON="$2"; shift 2 ;;
--out) OUTDIR="$2"; shift 2 ;;
--close-on-exit) CLOSE_ON_EXIT="true"; shift ;;
-h|--help)
grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
@@ -139,6 +142,7 @@ cat > "$APP/Contents/Info.plist" <<PLIST
<key>MTWindowTitle</key><string>${NAME}</string>
<key>MTFontSize</key><integer>${FONT_SIZE}</integer>
<key>MTFontName</key><string>${FONT_NAME}</string>
<key>MTCloseOnExit</key><${CLOSE_ON_EXIT}/>
</dict>
</plist>
PLIST
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
# Erzeugt aus dem gepatchten Ghostty-Template eine eigenständige, pro-Projekt
# konfigurierte macOS-App: eigene Bundle-ID (eigenes Dock-Icon + ⌘-Tab),
# eigenes Icon, eigene Ghostty-Config (working-directory, command, Farben, Font).
#
# Beispiel:
# ./make-ghostty-app.sh misc \
# --icon ~/claude-projects/misc/misc-icon.png \
# --workdir ~/claude-projects/misc \
# --command ~/.local/bin/claude \
# --bg 2b0a3d --fg ffd000 --font "Courier New" --font-size 18
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TEMPLATE="$SCRIPT_DIR/ghostty-template/Ghostty.app"
PB=/usr/libexec/PlistBuddy
# --- Defaults ---
NAME=""
BUNDLE_ID=""
ICON=""
WORKDIR=""
COMMAND_LINE=""
THEME=""
BG=""
FG=""
FONT=""
FONT_SIZE=""
OUT_DIR="$HOME/Applications"
CFG_BASE="$HOME/.config-ghostty"
usage() { grep '^#' "$0" | sed 's/^# \?//'; exit 1; }
[[ $# -ge 1 ]] || usage
NAME="$1"; shift
while [[ $# -gt 0 ]]; do
case "$1" in
--id) BUNDLE_ID="$2"; shift 2;;
--icon) ICON="$2"; shift 2;;
--workdir) WORKDIR="$2"; shift 2;;
--command) COMMAND_LINE="$2"; shift 2;;
--theme) THEME="$2"; shift 2;;
--bg) BG="$2"; shift 2;;
--fg) FG="$2"; shift 2;;
--font) FONT="$2"; shift 2;;
--font-size) FONT_SIZE="$2"; shift 2;;
--out) OUT_DIR="$2"; shift 2;;
*) echo "Unbekannte Option: $1" >&2; usage;;
esac
done
[[ -d "$TEMPLATE" ]] || { echo "Template fehlt: $TEMPLATE (erst rebuild-template.sh laufen lassen)" >&2; exit 1; }
: "${BUNDLE_ID:=com.mitchellh.ghostty.$NAME}"
APP="$OUT_DIR/Ghostty-$NAME.app"
CFG="$CFG_BASE/$NAME"
# Pfade expandieren (~)
WORKDIR="${WORKDIR/#\~/$HOME}"
ICON="${ICON/#\~/$HOME}"
COMMAND_LINE="${COMMAND_LINE/#\~/$HOME}"
echo ">> Baue $APP (id=$BUNDLE_ID)"
# --- 1. Template kopieren ---
mkdir -p "$OUT_DIR"
pkill -9 -f "$APP/Contents/MacOS" 2>/dev/null || true
sleep 1
rm -rf "$APP"
cp -R "$TEMPLATE" "$APP"
# --- 2. Icon (PNG -> .icns für statisch + PNG fürs Dock-Tile) ---
if [[ -n "$ICON" ]]; then
[[ -f "$ICON" ]] || { echo "Icon nicht gefunden: $ICON" >&2; exit 1; }
TMP_ISET="$(mktemp -d)/icon.iconset"; mkdir -p "$TMP_ISET"
for s in 16 32 128 256 512; do
sips -z $s $s "$ICON" --out "$TMP_ISET/icon_${s}x${s}.png" >/dev/null
sips -z $((s*2)) $((s*2)) "$ICON" --out "$TMP_ISET/icon_${s}x${s}@2x.png" >/dev/null
done
iconutil -c icns "$TMP_ISET" -o "$APP/Contents/Resources/Ghostty.icns"
cp "$ICON" "$APP/Contents/Resources/DockIcon.png" # laufendes Dock-Tile (Plugin-Patch)
rm -rf "$(dirname "$TMP_ISET")"
fi
# --- 3. Info.plist: Identität + Config-Pfad ---
PL="$APP/Contents/Info.plist"
$PB -c "Set :CFBundleIdentifier $BUNDLE_ID" "$PL"
$PB -c "Set :CFBundleName Ghostty-$NAME" "$PL" 2>/dev/null || $PB -c "Add :CFBundleName string Ghostty-$NAME" "$PL"
$PB -c "Delete :CFBundleIconName" "$PL" 2>/dev/null || true # sonst gewinnt Assets.car (Geist)
$PB -c "Delete :LSEnvironment" "$PL" 2>/dev/null || true
$PB -c "Add :LSEnvironment dict" "$PL"
$PB -c "Add :LSEnvironment:XDG_CONFIG_HOME string $CFG" "$PL"
# --- 4. Ghostty-Config schreiben ---
mkdir -p "$CFG/ghostty"
{
[[ -n "$WORKDIR" ]] && echo "working-directory = $WORKDIR"
[[ -n "$COMMAND_LINE" ]] && echo "command = $COMMAND_LINE"
[[ -n "$COMMAND_LINE" ]] && echo "wait-after-command = true"
[[ -n "$THEME" ]] && echo "theme = $THEME"
[[ -n "$BG" ]] && echo "background = $BG"
[[ -n "$FG" ]] && echo "foreground = $FG"
[[ -n "$FONT" ]] && echo "font-family = $FONT"
[[ -n "$FONT_SIZE" ]] && echo "font-size = $FONT_SIZE"
} > "$CFG/ghostty/config"
# --- 5. ad-hoc signieren ---
codesign --force --deep --sign - "$APP" >/dev/null
echo ">> Fertig: $APP"
echo " Config: $CFG/ghostty/config"
echo " Starten: open -n \"$APP\""
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# Baut das gepatchte Ghostty-Template (ghostty-template/Ghostty.app) lokal neu —
# OHNE VM, OHNE Zig. Der kritische Zig-Kern liegt bereits als
# vendor/libghostty/lib/libghostty.a vor; hier laeuft nur noch reines Xcode.
#
# Voraussetzungen:
# - Volles Xcode (CommandLineTools reichen NICHT). Per `xcodes` installierbar.
# - vendor/libghostty/lib/libghostty.a (arm64) + vendor/libghostty/include
# - Installierte /Applications/Ghostty.app (liefert die Shell-Ressourcen)
#
# WICHTIG: libghostty.a ist auf Ghostty v1.3.1 gepinnt. Ein Versionssprung
# erfordert einen NEUEN libghostty.a-Build (Zig, nur in macOS-15-VM moeglich —
# siehe vendor/libghostty/README.md). Dieses Skript baut nur das App-Bundle neu.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GHOSTTY_VERSION="${1:-v1.3.1}"
VENDOR="$SCRIPT_DIR/vendor/libghostty"
BUILD="$SCRIPT_DIR/.ghostty-build"
SRC="$BUILD/ghostty-src"
INSTALLED="/Applications/Ghostty.app"
# --- Xcode (volles) finden ---
find_xcode() {
local p
p="$(xcode-select -p 2>/dev/null || true)"
[[ "$p" == *Xcode*/Contents/Developer ]] && { echo "$p"; return; }
for c in /Applications/Xcode*.app \
"$HOME/Library/Application Support/com.robotsandpencils.xcodes/Xcode.app"; do
[[ -d "$c/Contents/Developer" ]] && { echo "$c/Contents/Developer"; return; }
done
return 1
}
DEVELOPER_DIR="$(find_xcode)" || { echo "Volles Xcode nicht gefunden." >&2; exit 1; }
export DEVELOPER_DIR
echo ">> Xcode: $(xcodebuild -version | head -1)"
# --- Vorbedingungen ---
[[ -f "$VENDOR/lib/libghostty.a" ]] || { echo "fehlt: $VENDOR/lib/libghostty.a" >&2; exit 1; }
[[ -d "$INSTALLED/Contents/Resources" ]] || { echo "fehlt: $INSTALLED (Shell-Ressourcen)" >&2; exit 1; }
# --- 1. Source holen ---
mkdir -p "$BUILD"
if [[ ! -d "$SRC/.git" ]]; then
rm -rf "$SRC"
git clone --depth 1 --branch "$GHOSTTY_VERSION" https://github.com/ghostty-org/ghostty.git "$SRC"
else
git -C "$SRC" fetch --depth 1 origin "$GHOSTTY_VERSION"
git -C "$SRC" checkout -f FETCH_HEAD
fi
# --- 2. Patch anwenden (idempotent) ---
cd "$SRC"
git checkout -- "macos/Sources/Features/Custom App Icon/DockTilePlugin.swift" 2>/dev/null || true
git apply "$SCRIPT_DIR/ghostty-template/dock-icon.patch"
echo ">> Patch angewandt"
# --- 3. xcframework aus libghostty.a (Header MIT module.modulemap aus src/include) ---
OUT_XCF="$SRC/macos/GhosttyKit.xcframework"
rm -rf "$OUT_XCF"
xcodebuild -create-xcframework \
-library "$VENDOR/lib/libghostty.a" \
-headers "$SRC/include" \
-output "$OUT_XCF" >/dev/null
echo ">> GhosttyKit.xcframework verpackt"
# --- 4. Shell-Ressourcen einspeisen (sonst CpResource-Fehler) ---
SHARE="$SRC/zig-out/share"
mkdir -p "$SHARE"
for n in zsh vim terminfo man nvim locale fish ghostty bat bash-completion; do
rm -rf "$SHARE/$n"
cp -R "$INSTALLED/Contents/Resources/$n" "$SHARE/$n"
done
echo ">> Shell-Ressourcen eingespeist"
# --- 5. App bauen (arm64, ReleaseLocal, ohne Signing) ---
cd "$SRC/macos"
xcodebuild -project Ghostty.xcodeproj -target Ghostty -configuration ReleaseLocal \
SYMROOT="$BUILD/out" ARCHS=arm64 ONLY_ACTIVE_ARCH=YES \
CLANG_MODULE_CACHE_PATH="$BUILD/modulecache" \
CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" \
build >/dev/null
echo ">> App gebaut"
# --- 6. Template ersetzen ---
DST="$SCRIPT_DIR/ghostty-template/Ghostty.app"
rm -rf "$DST"
cp -R "$BUILD/out/ReleaseLocal/Ghostty.app" "$DST"
echo ">> Template aktualisiert: $DST"