214 lines
8.0 KiB
Swift
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()
|