Files
claude-app-builder-4-mac/Sources/MiniTerm/main.swift
T

214 lines
8.0 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).
//
struct AppConfig {
let command: String
let workingDirectory: String?
let fontSize: CGFloat
let fontName: String
let windowTitle: String
static func load() -> AppConfig {
let info = Bundle.main.infoDictionary ?? [:]
let cmd = ((info["MTCommand"] as? String) ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
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)
}
}
// MARK: - App delegate
final class AppDelegate: NSObject, NSApplicationDelegate, LocalProcessTerminalViewDelegate {
private var window: NSWindow!
private var terminalView: LocalProcessTerminalView!
private var keyMonitor: Any?
private let config = AppConfig.load()
func applicationDidFinishLaunching(_ notification: Notification) {
let frame = NSRect(x: 0, y: 0, width: 900, height: 560)
terminalView = LocalProcessTerminalView(frame: frame)
terminalView.processDelegate = self
terminalView.font = resolvedFont()
installKeyMonitor()
window = NSWindow(
contentRect: frame,
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false)
window.title = config.windowTitle
window.contentView = terminalView
window.setFrameAutosaveName("MiniTermMain")
window.center()
window.makeKeyAndOrderFront(nil)
window.makeFirstResponder(terminalView)
setupMenu()
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()