DEV Community

Klakk 開発チーム
Klakk 開発チーム

Posted on

macOSのInput Monitoring判定で、2つのAPIと「実際に動くか」を分けて観察する

macOSでグローバルなキーイベントを受け取るアプリを作ると、権限判定を単純な Bool として扱いたくなります。しかし実装と検証を進めるほど、次の3つは分けて考えた方が安全でした。

  1. TCC上で権限がどう見えているか
  2. ユーザーへ権限を要求した直後か
  3. CGEvent.tapCreate が実際に成功し、イベントを受け取れるか

この記事では、macOS向け打鍵音アプリを作る過程で使った診断方法を、製品固有のUIから切り離して整理します。

2つの事前確認API

Listen-onlyのイベント監視では、次の2つを並べて観察できます。

import CoreGraphics
import IOKit.hidsystem

struct InputMonitoringStatus {
    let hid: IOHIDAccessType
    let cgGranted: Bool

    var looksGranted: Bool {
        hid == kIOHIDAccessTypeGranted || cgGranted
    }
}

func readInputMonitoringStatus() -> InputMonitoringStatus {
    InputMonitoringStatus(
        hid: IOHIDCheckAccess(kIOHIDRequestTypeListenEvent),
        cgGranted: CGPreflightListenEventAccess()
    )
}
Enter fullscreen mode Exit fullscreen mode

ここで重要なのは、looksGranted を「監視が必ず動く」という保証にしないことです。これはあくまで事前状態の観察結果です。プロセス、署名、実行パス、権限変更直後の状態などを切り分けるために、2つの値を別々にログへ残します。

ログへ記録するなら、入力内容ではなく次のような診断情報だけで十分です。

bundle_id=com.example.app
hid_status=granted
cg_preflight=true
event_tap_created=true
Enter fullscreen mode Exit fullscreen mode

キーコード、入力文字列、前後のアプリ名まで診断ログへ入れる必要はありません。

権限要求は「確認」と分ける

要求側のAPIは、確認のたびに呼ぶのではなく、初回案内やユーザーが明示的に「権限を開く」を選んだ時だけ呼びます。

func requestInputMonitoring() {
    let hidRequested = IOHIDRequestAccess(kIOHIDRequestTypeListenEvent)
    let cgRequested = CGRequestListenEventAccess()

    let current = readInputMonitoringStatus()
    print("hidRequested=\(hidRequested), cgRequested=\(cgRequested)")
    print("looksGranted=\(current.looksGranted)")
}
Enter fullscreen mode Exit fullscreen mode

要求APIの戻り値だけで次の画面を決めると、ユーザーがシステム設定へ移動して後から許可した場合を扱いにくくなります。要求後は、アプリ側の状態を次のように分けました。

  • notRequested: まだ案内していない
  • waitingForUser: システム設定での操作待ち
  • preflightGranted: 事前確認では許可済み
  • monitoring: Event Tapが作成され、監視中
  • revoked: 監視中に権限を失った

最終判定はEvent Tapの生成結果

事前確認が通っていても、Event Tapの作成失敗は別の事実として扱います。

let mask = (1 << CGEventType.keyDown.rawValue)
         | (1 << CGEventType.keyUp.rawValue)
         | (1 << CGEventType.flagsChanged.rawValue)

let tap = CGEvent.tapCreate(
    tap: .cgSessionEventTap,
    place: .headInsertEventTap,
    options: .listenOnly,
    eventsOfInterest: CGEventMask(mask),
    callback: callback,
    userInfo: context
)

guard let tap else {
    // 権限表示だけでなく、署名・実行パス・再起動要否も切り分ける
    return
}
Enter fullscreen mode Exit fullscreen mode

.listenOnly はイベントを書き換えない設計を表せますが、プライバシー説明の代わりにはなりません。アプリが何を処理し、何を保存せず、何を外部送信しないのかは別途明記する必要があります。

権限変更をポーリングする場合

システム設定を開いた後にアプリへ戻ってもらう導線では、短い間隔で状態を再確認する実装が分かりやすいです。ただし、次の境界を決めておきます。

  • Timerを重複生成しない
  • 監視開始後は要求用Timerを停止する
  • 権限喪失時はEvent TapとRunLoop Sourceを破棄する
  • 復旧時はユーザー設定が有効な場合だけ監視を再開する
  • アプリ終了時はTimerも明示的に破棄する

「権限がある」と「監視中」を同じフラグにすると、権限喪失後のUIと実体がずれやすくなります。権限状態、Event Tapの生存状態、ユーザーが機能をオンにしているかを別々に持つ方が復旧処理を書きやすくなりました。

まとめ

  • IOHIDCheckAccessCGPreflightListenEventAccess は診断のため別々に観察する
  • 要求APIは確認ループから分離し、ユーザー操作に結び付ける
  • 事前確認が通っていても、Event Tap生成結果を最終的な動作判定にする
  • 権限、監視状態、ユーザー設定を別の状態として管理する
  • 入力内容を診断ログやAnalyticsへ混ぜない

この記事は、macOS向け打鍵音アプリ Klakk の開発チームが、実装中に得た知見を一般化してまとめたものです。自社の有料製品へのリンクを含みます。

Klakkは物理キーコードと押下・解放状態をローカルで処理し、入力文字列を組み立てたり送信したりしません。ソフトウェアの音を追加する製品であり、物理キーボード自体の音や打鍵感は変えません。

Top comments (0)