Solving Keyboard Input in Command-Line SwiftUI Apps
I recently embarked on a project to build a simple macOS utility as a command-line executable using a SwiftUI package. The goal was straightforward: create a window with a text field. However, what seemed simple on the surface led me down a fascinating rabbit hole of application lifecycle, window management, and the subtle differences between launching an app from Finder versus the terminal.
The Initial Problem
My initial setup was as minimal as possible. I had a standard SwiftUI ContentView with a single TextField.
struct ContentView: View {
@State private var text = ""
var body: some View {
TextField("Input", text: $text)
.padding()
}
}
To run it, I used the modern SwiftUI App lifecycle protocol.
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
When I executed the package from my terminal with swift run, the application window appeared as expected. But there was a glaring issue: I couldn’t type anything into the text field. Every keystroke was being registered by the terminal I had used to launch the app, not by my new application window.
Curiously, I had an older, more verbose version of the app that used a traditional NSApplicationDelegate. That version worked perfectly. The key question was: why did the modern SwiftUI App approach fail to capture keyboard input when launched this way?
Unraveling the Mystery
My first hypothesis was that the issue stemmed from application activation. When you launch a .app bundle from Finder or the Dock, macOS handles bringing the application to the foreground and giving it focus. However, when running an executable directly from the command line, this “activation” step might not be happening automatically. The window appears, but the system’s focus remains on the terminal.
The working AppDelegate version of my app contained a crucial clue: it included explicit calls to app.setActivationPolicy(.regular) and app.activate(ignoringOtherApps: true). This seemed to confirm my theory. The solution, then, should be to add this activation logic to the SwiftUI App lifecycle.
My first attempt was to place the activation code in the init() of my App struct. It seemed like the most logical place for app-level setup.
@main
struct MyApp: App {
init() {
// Attempt #1
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
}
// ...
}
This resulted in an immediate crash: Fatal error: Unexpectedly found nil while implicitly unwrapping an Optional value. This was a learning moment. I discovered that NSApp, a global variable pointing to the shared NSApplication instance, is not yet initialized when the SwiftUI App struct’s init() is called. It’s an implicitly unwrapped optional, and accessing it too early is a fatal mistake.
A quick investigation revealed the safer alternative: NSApplication.shared. This static property is guaranteed to return the singleton application instance, creating it if it doesn’t exist. Using this would prevent the crash.
So, I tried again:
@main
struct MyApp: App {
init() {
// Attempt #2
NSApplication.shared.setActivationPolicy(.regular)
NSApplication.shared.activate(ignoringOtherApps: true)
}
// ...
}
This time, the app launched without crashing, and the keyboard input issue was solved! I could now type in the text field. However, a new, more subtle problem emerged: the application window no longer came to the foreground automatically. It would appear behind my terminal or any other active windows. I had activated the application, but not its window.
This led me to the final piece of the puzzle. The init() method runs before the SwiftUI body is evaluated and the WindowGroup creates its NSWindow. At the point of activation, there simply was no window to bring to the front. The activation logic needed to be delayed until after the window was on screen.
The perfect place for this is the .onAppear modifier within the view hierarchy, as it executes only when the view has been created and is about to appear.
A Robust and Reusable Pattern
The most reliable solution is to create a small, invisible helper view whose only job is to handle the activation logic at the correct time. By adding this view as a background to our ContentView, we can ensure our app activates properly every time it’s launched from the command line.
Here is the final, working code:
import SwiftUI
import AppKit
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.background(AppActivator())
}
}
}
// A helper view that triggers app activation only once.
struct AppActivator: View {
@State private var didActivate = false
var body: some View {
// This view is invisible.
Color.clear
.onAppear {
// Ensure this runs only once.
if !didActivate {
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
didActivate = true
}
}
}
}
This pattern is elegant and effective. The AppActivator view waits until the ContentView and its window are ready to appear. At that moment, it safely calls NSApp (which is guaranteed to exist by then) to set the activation policy and bring the application to the foreground. This ensures the window is visible, active, and ready to receive keyboard input, solving the problem completely.