解决命令行 SwiftUI 应用的键盘输入问题

我最近在做一个小项目:用 SwiftUI package 写一个 macOS 小工具,并通过命令行可执行文件运行。目标本来很简单:弹一个窗口,放一个文本输入框。结果这个看似简单的需求,把我带进了一个很有意思的坑:应用生命周期、窗口管理,以及从 Finder 启动和从终端启动之间那些微妙但关键的差异。

最初的问题

我一开始的代码非常精简。就是一个标准 SwiftUI ContentView,里面放一个 TextField

struct ContentView: View {
    @State private var text = ""

    var body: some View {
        TextField("Input", text: $text)
            .padding()
    }
}

运行方式也很“现代”,直接用 SwiftUI 的 App lifecycle protocol。

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

我在终端里跑 swift run 后,应用窗口确实出来了。但有个明显问题:我没法在输入框里打字。所有按键都被启动应用的终端接走了,而不是进到新开的应用窗口。

有意思的是,我之前有个更老、更啰嗦的版本,用的是传统 NSApplicationDelegate。那个版本完全正常。关键问题变成了:为什么“现代”的 SwiftUI App 写法,在这种启动方式下反而接不到键盘输入?

抽丝剥茧

我第一个猜测是应用激活(activation)的问题。你从 Finder 或 Dock 启动 .app bundle 时,macOS 会自动把应用切到前台并赋予焦点。但从命令行直接运行可执行文件时,这个“激活”步骤可能不会自动发生。窗口虽然出现了,系统焦点却还留在终端。

我那个能正常工作的 AppDelegate 版本里有个关键线索:它显式调用了 app.setActivationPolicy(.regular)app.activate(ignoringOtherApps: true)。这几乎坐实了我的猜想。那看起来解决思路就很直接:把同样的激活逻辑加到 SwiftUI App 生命周期里。

我第一次尝试是把激活代码写进 App struct 的 init(),看起来最像“应用级初始化”的地方。

@main
struct MyApp: App {
    init() {
        // Attempt #1
        NSApp.setActivationPolicy(.regular)
        NSApp.activate(ignoringOtherApps: true)
    }
    // ...
}

结果直接崩溃:Fatal error: Unexpectedly found nil while implicitly unwrapping an Optional value。这一下让我意识到一个关键点:NSApp 是指向共享 NSApplication 实例的全局变量,但在 SwiftUI Appinit() 执行时,它还没初始化好。它是隐式解包可选值,过早访问就是致命错误。

快速查了一圈后,我找到了更安全的替代:NSApplication.shared。这个静态属性会保证返回单例应用实例,不存在时也会先创建出来。用它就能避免上面的崩溃。

于是我第二次尝试:

@main
struct MyApp: App {
    init() {
        // Attempt #2
        NSApplication.shared.setActivationPolicy(.regular)
        NSApplication.shared.activate(ignoringOtherApps: true)
    }
    // ...
}

这次应用不崩了,键盘输入也恢复正常!我终于能在输入框里打字了。但新的细节问题又出现了:窗口不会自动到最前面,它经常躲在终端或者其他窗口后面。也就是说,我激活了应用,但没有激活到对应的窗口

这就引出了最后一块拼图。init() 执行时机早于 SwiftUI body 的计算,也早于 WindowGroup 真正创建 NSWindow。在激活调用发生的那一刻,窗口其实还不存在,自然也谈不上把它提到前台。激活逻辑必须延后到窗口已经上屏之后再执行。

这时,视图层级里的 .onAppear 就是最合适的位置,因为它只会在视图创建完成并即将显示时触发。

一个稳健且可复用的写法

最可靠的方案是做一个很小、不可见的 helper view,专门负责在正确时机执行激活逻辑。把这个 view 作为 ContentView 的 background 挂上去,就能保证命令行启动时每次都正确激活。

下面是最终可用代码:

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
                }
            }
    }
}

这个模式简单又有效。AppActivator 会等到 ContentView 和它的窗口都准备显示时再触发。此时去调用 NSApp 是安全的(因为它一定已经存在),然后设置 activation policy 并把应用带到前台。结果就是窗口可见、可交互、能接收键盘输入,这个问题就彻底解决了。