MacOSX
AppleScript
Swift
CGEvent
NSEvent

[macOS][Swift4.1] 他のアプリをキー操作する

CGEvent を使って他のアプリにキーイベントを流す、またはイベント生成して実行する方法を記載します。

経緯

そもそも何故他のアプリにキーイベントを送る必要に迫られたのかの説明からしますと、
複数のアプリに対して同じような処理をサポートしたい場合があります。例えば、自分のアプリから音楽プレーヤーアプリの再生/一時停止を切り替えたい、スライドショーアプリのページ送りを行いたい、しかしそれらの対象アプリを限定したくないケースがあります。

Macアプリで他のアプリを操作する場合、AppleEventを使ってメッセージを送ることで制御することができるのですが、その制御に必要なスクリプト AppleScript のサポート状況がアプリによってかなりばらつきがあります。フルフルで手厚くサポートされているもの、そこそこサポートされているもの、最低限しかサポートされていないもの、そもそも非対応なものなどなどがあり、一律に制御できないことが理由になります。

OS X 10.5 から導入された ScriptingBridge も結局対象アプリがサポートするAppleScriptの定義を元にヘッダファイルを起こすので問題点は何も変わりません。

AppleScriptの System Events に対してショートカットキーをキーイベントとして発生させる手もありますが、これは対象とするアプリを前面にしないと効かせられないので都度都度自分のアプリが背面に行ってしまいます。しかもスクリプトに書いたキーしかイベント発生させられません。

tell application "TextEdit"
    activate
end tell

tell application "System Events"
    tell application process "TextEdit"
        keystroke "1"
    end tell
end tell

またAppleScriptは基本文法こそ同じですが、アプリ独自でサポートしている機能については固有仕様のため対象アプリが変わったりサポートするアプリが増える度にその学習が発生するので非常に高コストです。

何か他に手立てがないかと唸りながら調査を進めるうちに CGEvent クラスを使うことでプロセスIDに対してキーイベントやマウスイベントが送信できることがわかりました。
スクリプトを介さないため実行時のタイムラグもほとんど発生しませんし、スクリプトをたくさん覚えたり書いたりする必要もありません。

但し、ストア公開アプリではSandbox対応する必要があるため今回紹介する方法は使えないというデメリットがあります。

プロジェクト

サンプルコードを以下にアップしました。
https://github.com/atsushijike/KeyEvent

  • Xcode 9.4.1
  • Swift 4.1

実装

プロジェクト > ターゲット > Capabilities > App Sandbox をオフにします。

プロセスID一覧

NSWorkspacerunningApplications で取得することができます。
ポップアップのメニューに追加します。

AppDelegate.swift
    override func viewDidLoad() {
    ...
    let menu = NSMenu()
        NSWorkspace.shared.runningApplications.forEach { (runningApplication) in
            let title = runningApplication.localizedName ?? runningApplication.bundleIdentifier ?? ""
            let menuItem = NSMenuItem(title: title, action: nil, keyEquivalent: "")
            menuItem.tag = Int(runningApplication.processIdentifier)
            menu.addItem(menuItem)
        }
        processPopUpButton.menu = menu
    ...
    }

キーイベントの監視と実行

ボタンが押されたら NSEvent.addLocalMonitorForEvents(matching:handler:) を使って .keyDown, .keyUp イベントを監視するようにします。

NSEvent から CGEvent が取得できるのでそれをそのまま postToPid() してプロセスIDに対してイベントを実行するようにします。

終わる時は NSEvent.removeMonitor() しておきます。

AppDelegate.swift
    @objc private func controlButtonDidSelect(sender: NSButton) {
        if processPopUpButton.isEnabled {
            // Stop monitoring for key events
            if let keyEventMonitor = keyEventMonitor {
                NSEvent.removeMonitor(keyEventMonitor)
            }
            keyEventMonitor = nil
            processPopUpButton.isEnabled = false
            controlButton.title = "Start controling"
        } else {
            // Start monitoring for key events
            keyEventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { [weak self] (event) in
                guard let `self` = self,
                    let pid = self.selectedProcessId,
                    let cgEvent = event.cgEvent else {
                        return nil
                }
                cgEvent.postToPid(pid)
                return nil  // Avoid system beep
            }
            processPopUpButton.isEnabled = true
            controlButton.title = "Stop controling"
        }
    }
AppDelegate.swift
    private var selectedProcessId: Int32? {
        guard let id = processPopUpButton.selectedItem?.tag else { return nil }
        return Int32(id)
    }

サンプルコードには含まれませんが、 CGEvent 自体を生成して実行させることもできます。
例えば、以下のような関数を定義しておいてボタンアクションに実行させれば良いかと思います。

    private func keyEvent(pid: Int32, keyCode: CGKeyCode, flags: CGEventFlags?, keyDown: Bool) {
        let source = CGEventSource(stateID: .hidSystemState)
        let event = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: keyDown)
        if let flags = flags {
            event?.flags = flags
        }
        event?.postToPid(pid)
    }