9 Min. Lesezeit

Ein Terminal in Swift bauen – und woran ich fast gescheitert wäre

Wie ich mit Swift und SwiftUI eine native macOS-Workspace-App gebaut habe – ein Terminal, das Editor, Browser, DevTools und KI in einem Fenster vereint. Und die Widrigkeiten, an denen ich unterwegs hängengeblieben bin: fork() gegen die App-Store-Sandbox, ZDOTDIR-Kämpfe mit oh-my-zsh, ⌘-Tasten, die im PTY verschwinden, und ein deutsches Tastaturlayout, das Shortcuts sabotiert.

Es fing harmlos an. Ich wollte “nur mal schauen”, ob man mit SwiftUI ein anständiges Terminal bauen kann. Ein paar Monate später hatte ich Relay – eine native macOS-App, die ein echtes PTY-Terminal, einen Code-Editor, einen Browser mit vollständigen DevTools, einen KI-Assistenten und einen CI/CD-Monitor in ein Fenster packt. Die Idee dahinter: ein einziges Fenster für die gesamte Entwickler-Schleife. Shell, Code, Browser, Pipelines, Todos – alles verbunden über den Workspace.

Klingt nach viel. War es auch. Und der interessante Teil dieses Artikels ist nicht die Feature-Liste, sondern die Stellen, an denen ich hängengeblieben bin. Denn eine Workspace-App in Swift zu schreiben heißt, ständig gegen Dinge zu laufen, die auf dem Papier trivial aussehen und in der Praxis einen halben Tag kosten.

Hier sind die Widrigkeiten, die mich am meisten gelehrt haben.


Widrigkeit 1: Ein Terminal ist kein View – es ist ein Prozess

Der erste Denkfehler: Ich dachte, ein Terminal sei im Kern ein Textfeld, das man mit ANSI-Farben anmalt. Falsch. Ein Terminal ist ein Pseudo-Terminal (PTY) – ein Kernel-Konstrukt, das einer echten Shell vorgaukelt, an einem physischen Terminal zu hängen. Man fork()t einen Kindprozess, hängt ihn an das PTY, und ab dann fließen Bytes hin und her, die man selbst parsen muss: Cursor-Bewegungen, Farb-Codes, Löschbefehle, das ganze VT100-Erbe aus den 70ern.

Einen VT100-Parser selbst zu schreiben ist ein Projekt für sich. Ich habe früh die Entscheidung getroffen, das nicht zu tun, und stattdessen SwiftTerm als Rendering-Backend zu nehmen. SwiftTerm macht fork/exec, die VT100-Parserei und das Zeichnen der Zellen. Ich wrappe das Ganze in eine Subklasse:

final class RelayTerminalView: LocalProcessTerminalView {
    var oscHandler: ((OSCToken) -> Void)?

    override func dataReceived(slice: ArraySlice<UInt8>) {
        // OSC-Sequenzen abfangen, BEVOR SwiftTerm sie schluckt
        scanOSCsAndBell(slice)
        super.dataReceived(slice: slice)
    }
}

Diese eine überschriebene Methode ist das Herzstück der ganzen App. Ich lasse SwiftTerm rendern, aber ich lese den Byte-Strom vorher ab, um daraus Bedeutung zu ziehen: Wo hat ein Befehl angefangen? Wann war er fertig? Wie lautet der Exit-Code? In welchem Verzeichnis stehen wir gerade?

Und genau da kam die zweite Widrigkeit.


Widrigkeit 2: Woher weiß die App, was in der Shell passiert?

Ein Terminal-Fenster ist von außen eine Blackbox. Die Shell schreibt Bytes, das war’s. Es gibt keine API, die sagt “der Nutzer hat gerade git push gestartet und es ist mit Exit-Code 0 fertig geworden”. Diese Information muss man sich aus der Shell heraus besorgen.

Das Zauberwort heißt Shell Integration über OSC-Sequenzen – spezielle Escape-Codes, die die Shell absichtlich in den Output schreibt, damit das Terminal sie auffangen kann. Der Quasi-Standard dafür ist OSC 133:

  • OSC 133;A – hier fängt ein Prompt an
  • OSC 133;C – hier fängt die Befehlsausgabe an
  • OSC 133;D;<exitcode> – Befehl fertig, mit Exit-Code
  • OSC 7;file://host/path – aktuelles Arbeitsverzeichnis

Damit die Shell das emittiert, muss man ihr Hooks unterschieben. Für zsh sieht das ungefähr so aus:

_relay_preexec() {
    printf '\033]133;C\007'          # Befehl startet
    printf '\033]133;Command;%s\007' "$1"
}
_relay_precmd() {
    printf '\033]133;D;%s\007' "$?"   # letzter Exit-Code
    printf '\033]133;A\007'          # neuer Prompt
    printf '\033]7;file://%s%s\007' "$HOST" "$PWD"
}
add-zsh-hook preexec _relay_preexec
add-zsh-hook precmd  _relay_precmd

Auf der Swift-Seite empfängt der oscHandler diese Tokens und füttert damit das Session-Modell:

func process(osc: Int, params: [String]) {
    switch osc {
    case 133 where params.first == "D":
        tab.commandFinished(exitCode: Int(params[1]))
    case 7:
        tab.updateWorkingDirectory(from: params.first)
    // ...
    }
}

Ab diesem Moment “versteht” die App, was in der Shell vor sich geht. Command-Blöcke, Verzeichnis-Historie, Exit-Code-Badges in der Sidebar, der KI-Assistent, der den letzten fehlgeschlagenen Befehl erklärt – alles hängt an diesem einen OSC-Datenstrom. Ohne Shell Integration wäre Relay nur ein hübsches xterm.


Widrigkeit 3: oh-my-zsh, Powerlevel10k und der ZDOTDIR-Krieg

Jetzt wurde es unangenehm. Meine Hooks funktionierten wunderbar – in einer nackten Shell. Sobald ein Nutzer aber sein echtes Setup mitbrachte (oh-my-zsh, Powerlevel10k, zsh-autosuggestions, syntax-highlighting), brach die Hölle los. Prompts, die doppelt gerendert wurden. Ein p10k-“Instant Prompt”, der meine Hooks überschrieb. Plugins, die mir das ZDOTDIR unter dem Hintern wegzogen.

Der Trick, um überhaupt Hooks injizieren zu können, ist ZDOTDIR: Ich setze es beim Launch auf ~/.config/relay, lege dort eine .zshrc ab, und zsh lädt meine Config statt der des Nutzers. Nur will der Nutzer natürlich seine Config. Also die zweite Stufe: meine Bridge-.zshenv setzt ZDOTDIR sofort wieder auf $HOME zurück, damit die echte Startup-Kette des Nutzers unangetastet lädt, und hängt danach nur noch meine Hooks an.

# ~/.config/relay/.zshenv  (Bridge im "Dein-zshrc"-Modus)
export POWERLEVEL9K_INSTANT_PROMPT=off   # bevor p10k es lesen kann
export _RELAY_ZDOTDIR="$ZDOTDIR"
export ZDOTDIR="$HOME"                    # echte Config des Nutzers laden
[[ -f "$HOME/.zshenv" ]] && source "$HOME/.zshenv"

Das war noch nicht das Ende. Manche Plugins (OMZ!) setzen ZDOTDIR mitten im Startup erneut um. Also brauchte ich einen Wächter, der nach jedem Prompt prüft, ob mir jemand die Variable geklaut hat:

_relay_zdotdir_guard() {
    if [[ "$ZDOTDIR" == "$_RELAY_ZDOTDIR" ]]; then
        print -P "%F{yellow}⚠ Ein Plugin hat ZDOTDIR überschrieben.%f"
    fi
}
add-zsh-hook precmd _relay_zdotdir_guard

Dazu ein Cache-Fix, weil Powerlevel10k seinen Cache-Pfad aus ZDOTDIR ableitete und dann bei jedem Start neu kompilierte. XDG_CACHE_HOME explizit setzen, Ruhe.

Die Lektion: Die Shell-Umgebung eines echten Entwicklers ist ein Minenfeld. Was in deiner Testshell klappt, sagt fast nichts darüber aus, was in einem Setup mit fünf Frameworks und dreißig Plugins passiert. Ich habe hier mehr Zeit verbracht als mit dem gesamten Rendering.

Übrigens musste jede Shell ihre eigene Behandlung bekommen: bash braucht PROMPT_COMMAND plus einen DEBUG-Trap (und der feuert unter bash 3.2 auch für PROMPT_COMMAND selbst – also explizit _relay_*-Funktionen herausfiltern), fish braucht conf.d-Snippets und eigene Events. macOS liefert bis heute bash 3.2 aus. Man kann sich auf gar nichts verlassen.


Widrigkeit 4: ⌘1 drückt – und nichts passiert

Eine der frustrierendsten Sorten Bug: Der Code ist richtig, aber die Taste kommt nie an. Ich hatte Shortcuts wie ⌘1/⌘2/⌘3 gebaut, um zwischen Terminal, Editor und Browser umzuschalten. In der UI stand der Hinweis, die Tasten waren als SwiftUI-.keyboardShortcut verdrahtet – und trotzdem tat sich nichts.

Der Grund: Das PTY-Terminal ist ein NSView mit eigener Responder-Chain, und SwiftTerm schnappt sich blanke -Ziffern, bevor SwiftUI sie überhaupt zu Gesicht bekommt. Eine SwiftUI-.keyboardShortcut verliert dieses Rennen jedes Mal.

Die Lösung war, die SwiftUI-Ebene zu verlassen und einen lokalen NSEvent-Monitor zu installieren, der die Tasten abfängt, bevor die Responder-Chain sie verschluckt:

NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
    guard event.modifierFlags.contains(.command),
          let digit = Int(event.charactersIgnoringModifiers ?? "")
    else { return event }

    // Nur handeln, wenn DIESES Fenster das aktive ist
    guard WindowRegistry.activeManager === self.manager else { return event }

    switch digit {
    case 1: manager.convertActivePane(to: .terminal); return nil
    case 2: manager.convertActivePane(to: .editor);   return nil
    case 3: manager.convertActivePane(to: .browser);  return nil
    default: return event
    }
}

Zwei Dinge daran haben mich Nerven gekostet. Erstens: return nil bedeutet “Event geschluckt”, return event heißt “weiterreichen”. Vertut man sich, tippt der Nutzer plötzlich Ziffern ins Terminal, statt die Pane zu wechseln. Zweitens – und das war der fiese Teil: Jedes Fenster installiert seinen eigenen app-globalen Monitor. Ohne den WindowRegistry.activeManager-Check hat der zuerst installierte Monitor die Shortcuts für alle Fenster gekapert. Ein Bug, den man nur mit zwei offenen Fenstern überhaupt reproduziert.


Widrigkeit 5: Das deutsche Tastaturlayout hasst deine Shortcuts

Diese hätte ich fast nicht kommen sehen. Der Klassiker unter macOS-Apps ist, Panes mit ⌘[ und ⌘] zu durchblättern. Fühlt sich auf einer US-Tastatur natürlich an.

Auf einer deutschen Tastatur gibt es die eckigen Klammern nicht als eigene Tasten – sie liegen auf ⌥5 und ⌥6. Ein ⌘[ müsste ich also als ⌘⌥5 tippen, was erstens absurd ist und zweitens direkt mit meinen Workspace-Sprüngen (⌘⌥1⌘⌥9) kollidiert. Dasselbe beim Sidebar-Toggle: ⌘\ klingt gut, aber auf Deutsch ist Backslash ⌥⇧7 – als Schnellzugriff völlig unbrauchbar.

Also habe ich das ganze Shortcut-Schema für ein deutsches Layout durchdacht:

  • Pane-Fokus: ⌘⌥↓ / ⌘⌥↑ statt ⌘[ / ⌘] – Pfeiltasten kollidieren mit nichts
  • Sidebar (Rail) links: ⌘⇧L statt ⌘\
  • ⌘P bewusst frei gelassen, um die Muskelerinnerung an die Command-Palette nicht zu zerstören

Die Lektion: Shortcuts sind kein universelles Alphabet. Wer nur auf US-Layout testet, baut eine App, die sich für halb Europa falsch anfühlt. Und das merkt man erst, wenn man sie am eigenen Laptop benutzt und ständig ins Leere greift.


Widrigkeit 6: SwiftUI ignoriert deinen Alpha-Wert

Ich wollte durchscheinende Glass-Chrome-Flächen – Titelleiste, Sidebars, Pane-Header, alle mit demselben durchscheinenden Ton, sodass der Schreibtisch subtil durchschimmert. SwiftUI bietet dafür .toolbarBackground(...). Nur: Der Alpha-Kanal einer Color wird dabei stumpf ignoriert. Die Titelleiste blieb undurchsichtig, egal was ich einstellte.

Die einzige Lösung war, unter SwiftUI hindurchzugreifen und die Titelleiste direkt in AppKit zu malen. Man neutralisiert Apples eigene NSVisualEffectViews im Titlebar-Container und installiert einen eigenen .sidebar-Blur plus einen Tint, der exakt zum Rest der Chrome passt:

// Systemeigene Titlebar-VEVs unschädlich machen, eigenes Composite darüber
titlebarContainer.subviews
    .compactMap { $0 as? NSVisualEffectView }
    .forEach { $0.material = .contentBackground; $0.state = .inactive }

let blur = NSVisualEffectView()
blur.material = .sidebar
blur.blendingMode = .behindWindow
// darüber: theme.bg.opacity(backgroundOpacity)

Dazu kam ein Detail, an das man erst denkt, wenn es kaputt aussieht: Sobald die Chrome halbdurchsichtig wird, verschwinden dezente Labels (das Zahnrad-Icon, sekundäre Texte) im Durchscheinen. Ich musste die Vordergrund-Farben abhängig von der Deckkraft Richtung Primärfarbe interpolieren, damit alles lesbar bleibt:

extension AppTheme {
    func adjusted(forBackgroundOpacity opacity: CGFloat) -> AppTheme {
        guard opacity < 0.85 else { return self }
        let t = (0.85 - opacity) / 0.85
        return with(
            fgSecond:   fgSecond.lerp(to: fg, t: t * 0.55),
            fgTertiary: fgTertiary.lerp(to: fg, t: t * 0.80)
        )
    }
}

Die Lektion: SwiftUI ist großartig, bis man an eine seiner Kanten stößt – und dann muss man wissen, dass darunter immer noch AppKit liegt, das man anfassen kann und manchmal muss.


Widrigkeit 7: Man kann das nicht im App Store verkaufen

Die bitterste Erkenntnis kam ganz zum Schluss – und sie ist keine Code-Frage, sondern eine Plattform-Frage. Ein Terminal braucht fork() und ein PTY. Die Sandbox des Mac App Store verbietet beides. Es gibt keinen Workaround, keine Ausnahme-Berechtigung, nichts. Ein echter Terminal-Emulator ist im App Store schlicht nicht erlaubt.

Das bedeutete: eigener Vertrieb (Direkt-Download), eigenes Lizenzsystem, eigene Update-Infrastruktur. Ich habe Sparkle für Auto-Updates eingebunden, ein Offline-Lizenzsystem mit Ed25519-signierten Lizenzdateien gebaut (die App verifiziert lokal gegen einen öffentlichen Schlüssel und funktioniert damit komplett offline, ohne irgendein Geheimnis im Binary), und ein kleines Node-Backend, das die Zahlungs-Webhooks entgegennimmt und die Schlüssel ausstellt.

Nichts davon hat mit Swift zu tun. Aber es gehört zur ehrlichen Antwort auf die Frage “was kostet es, so eine App wirklich fertigzustellen”: Die letzten zwanzig Prozent – Vertrieb, Signierung, Lizenzen, Updates – waren fast so viel Arbeit wie die ersten achtzig.


Was ich mitgenommen habe

Wenn ich die ganzen Widrigkeiten auf ein paar Sätze eindampfe:

  1. Nimm nicht selbst den VT100-Parser in die Hand. SwiftTerm hat mir Monate erspart. Baue auf Bestehendem und veredle es, statt das Rad neu zu erfinden.
  2. Die Grenze zwischen App und Shell ist die schwierigste im ganzen Projekt. OSC-Sequenzen und Shell-Hooks sind fummelig, plattformabhängig und brechen in jedem exotischen Nutzer-Setup anders. Plane dafür mehr Zeit ein, als du denkst.
  3. Teste auf dem Layout und Setup, das du wirklich benutzt. Ein deutsches Tastaturlayout und eine vollgepackte zsh-Config haben mehr Bugs zutage gefördert als jeder Unit-Test.
  4. SwiftUI hat einen Boden, und darunter ist AppKit. Für 90 % ist SwiftUI ein Geschenk. Für die letzten 10 % – Titelleiste, globale Key-Monitore, Fenster-Chrome – musst du eine Etage tiefer.
  5. Die Plattform-Constraints bestimmen dein Geschäftsmodell. fork() schließt den App Store aus, und diese eine technische Tatsache hat den gesamten Vertriebsweg diktiert.

Am Ende steht eine App, die genau das tut, was ich mir vorgestellt hatte: ein Fenster für die ganze Arbeit. Und ein Ordner voller Changelogs, in denen hinter jeder nüchternen Zeile ein Nachmittag steckt, an dem irgendetwas partout nicht funktionieren wollte. Genau das ist der Teil, den man beim nächsten Projekt wieder unterschätzt – und der einen jedes Mal am meisten lehrt.