[Swift 입문] 24편 — Accessibility API와 전역 단축키

🤖 이 글은 Claude Code(AI)가 작성합니다. | 시리즈 목차 | 이전: [23편] AppKit과 SwiftUI 통합

Accessibility API란?

macOS Accessibility API(접근성 API)는 원래 시각 장애인을 위한 스크린 리더(VoiceOver) 같은 보조 기술을 위해 만들어졌습니다. 그런데 이 API는 단순히 접근성 지원 이상의 능력을 갖고 있습니다.

  • 다른 앱의 UI 요소 읽기: 다른 앱에 있는 버튼 텍스트, 입력창 내용 등을 코드로 읽을 수 있습니다.
  • 다른 앱의 UI 조작: 버튼 클릭, 텍스트 입력 등을 코드로 수행할 수 있습니다.
  • 포커스된 앱 감지: 현재 어떤 앱이 활성화되어 있는지, 어떤 요소가 선택됐는지 알 수 있습니다.

이런 이유로 자동화 도구, 생산성 앱, AI 어시스턴트 같은 앱들이 Accessibility API를 활용합니다.


권한 요청

Accessibility API를 사용하려면 반드시 사용자에게 권한을 받아야 합니다. 권한 없이 사용하면 빈 데이터가 반환됩니다.

import AppKit

// 현재 접근성 권한 확인
func checkAccessibilityPermission() -> Bool {
    let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true]
    return AXIsProcessTrustedWithOptions(options as CFDictionary)
}

// 호출하면 "시스템 설정 > 개인 정보 보호 > 손쉬운 사용"으로 유도
if !checkAccessibilityPermission() {
    print("접근성 권한이 필요합니다. 시스템 설정을 확인하세요.")
}

kAXTrustedCheckOptionPrompt: true를 넣으면 권한이 없을 때 자동으로 시스템 설정 다이얼로그가 뜹니다.


AXUIElement — UI 요소 탐색

import AppKit

// 현재 포커스된 앱의 접근성 요소 가져오기
func getFocusedAppElement() -> AXUIElement? {
    guard let frontApp = NSWorkspace.shared.frontmostApplication else { return nil }
    let pid = frontApp.processIdentifier
    return AXUIElementCreateApplication(pid)
}

// 특정 속성 읽기
func getAttribute(_ element: AXUIElement, _ attribute: String) -> AnyObject? {
    var value: AnyObject?
    AXUIElementCopyAttributeValue(element, attribute as CFString, &value)
    return value
}

// 사용 예시
if let appElement = getFocusedAppElement() {
    // 앱 이름
    let appName = getAttribute(appElement, kAXTitleAttribute as String) as? String

    // 포커스된 창
    let focusedWindow = getAttribute(appElement, kAXFocusedWindowAttribute as String)

    // 포커스된 UI 요소 (현재 선택된 버튼, 입력창 등)
    let focusedElement = getAttribute(appElement, kAXFocusedUIElementAttribute as String)

    print("앱: \(appName ?? "알 수 없음")")
}

주요 AX 속성

상수 의미
kAXTitleAttribute 요소의 제목/이름
kAXValueAttribute 현재 값 (입력창의 텍스트 등)
kAXRoleAttribute 요소 역할 (button, textField 등)
kAXChildrenAttribute 자식 요소 목록
kAXFocusedWindowAttribute 포커스된 창
kAXFocusedUIElementAttribute 포커스된 UI 요소
kAXSelectedTextAttribute 현재 선택된 텍스트
kAXPositionAttribute 화면상 위치

선택된 텍스트 읽기

func getSelectedText() -> String? {
    guard let appElement = getFocusedAppElement() else { return nil }

    // 포커스된 UI 요소
    var focusedElement: AnyObject?
    AXUIElementCopyAttributeValue(
        appElement,
        kAXFocusedUIElementAttribute as CFString,
        &focusedElement
    )

    guard let element = focusedElement else { return nil }

    // 선택된 텍스트
    var selectedText: AnyObject?
    AXUIElementCopyAttributeValue(
        element as! AXUIElement,
        kAXSelectedTextAttribute as CFString,
        &selectedText
    )

    return selectedText as? String
}

전역 키보드 단축키

앱이 포커스되지 않아도 동작하는 전역 단축키는 유틸리티 앱의 핵심 기능입니다. macOS에서는 두 가지 방법이 있습니다.

1. NSEvent 글로벌 모니터 (앞 편에서 소개)

class GlobalHotkeyMonitor {
    private var monitor: Any?

    func start(handler: @escaping () -> Void) {
        monitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { event in
            // Cmd + Shift + Space
            let isCmdShiftSpace = event.modifierFlags.contains([.command, .shift])
                && event.keyCode == 49  // 스페이스바 키코드

            if isCmdShiftSpace {
                DispatchQueue.main.async { handler() }
            }
        }
    }

    func stop() {
        if let monitor = monitor {
            NSEvent.removeMonitor(monitor)
        }
    }
}

단, addGlobalMonitorForEvents는 Accessibility 권한 없이도 키코드를 받을 수 있지만, 일부 보안 환경에서는 제한될 수 있습니다.

2. Carbon HotKey API (더 안정적)

import Carbon

class HotKeyManager {
    private var hotKeyRef: EventHotKeyRef?
    private var eventHandler: EventHandlerRef?

    func register(keyCode: UInt32, modifiers: UInt32, handler: @escaping () -> Void) {
        // 핫키 ID
        let hotKeyID = EventHotKeyID(signature: OSType("htky".utf8.reduce(0) { ($0 << 8) | UInt32($1) }),
                                     id: 1)

        // 이벤트 핸들러 설치
        var eventSpec = EventTypeSpec(eventClass: UInt32(kEventClassKeyboard),
                                      eventKind: UInt32(kEventHotKeyPressed))

        InstallEventHandler(
            GetApplicationEventTarget(),
            { (_, event, _) -> OSStatus in
                NotificationCenter.default.post(name: .globalHotkeyPressed, object: nil)
                return noErr
            },
            1, &eventSpec, nil, &eventHandler
        )

        // 핫키 등록 (Cmd+Shift+Space 예시)
        RegisterEventHotKey(keyCode, modifiers, hotKeyID, GetApplicationEventTarget(), 0, &hotKeyRef)

        // 알림 수신
        NotificationCenter.default.addObserver(forName: .globalHotkeyPressed, object: nil, queue: .main) { _ in
            handler()
        }
    }

    func unregister() {
        if let hotKeyRef = hotKeyRef {
            UnregisterEventHotKey(hotKeyRef)
        }
    }
}

extension Notification.Name {
    static let globalHotkeyPressed = Notification.Name("globalHotkeyPressed")
}

NSWorkspace — 실행 중인 앱 감지

import AppKit

class AppWatcher {
    private var observers: [NSObjectProtocol] = []

    func startWatching() {
        // 앱 활성화 감지 (다른 앱으로 전환할 때)
        observers.append(
            NSWorkspace.shared.notificationCenter.addObserver(
                forName: NSWorkspace.didActivateApplicationNotification,
                object: nil,
                queue: .main
            ) { notification in
                let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication
                print("활성화된 앱: \(app?.localizedName ?? "알 수 없음")")
                print("번들 ID: \(app?.bundleIdentifier ?? "")")
            }
        )

        // 앱 실행 감지
        observers.append(
            NSWorkspace.shared.notificationCenter.addObserver(
                forName: NSWorkspace.didLaunchApplicationNotification,
                object: nil,
                queue: .main
            ) { notification in
                let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication
                print("새로 실행된 앱: \(app?.localizedName ?? "")")
            }
        )
    }

    func stopWatching() {
        observers.forEach { NSWorkspace.shared.notificationCenter.removeObserver($0) }
    }
}

특정 앱이 터미널인지 확인

let terminalBundleIDs = [
    "com.apple.Terminal",
    "com.googlecode.iterm2",
    "dev.warp.Warp-Stable",
    "com.github.wez.wezterm"
]

func isFrontmostAppTerminal() -> Bool {
    guard let frontApp = NSWorkspace.shared.frontmostApplication,
          let bundleID = frontApp.bundleIdentifier else { return false }
    return terminalBundleIDs.contains(bundleID)
}

SwiftUI에서 Accessibility 지원

내 앱의 접근성을 높이는 것도 중요합니다.

struct AccessibleButton: View {
    var body: some View {
        Button(action: doSomething) {
            Image(systemName: "trash")
        }
        // VoiceOver가 읽을 레이블
        .accessibilityLabel("삭제")
        .accessibilityHint("선택한 항목을 삭제합니다")

        // 접근성 요소 숨기기 (장식용 이미지)
        Image(systemName: "star.fill")
            .accessibilityHidden(true)

        // 슬라이더에 값 설명 추가
        Slider(value: $volume, in: 0...100)
            .accessibilityValue("\(Int(volume))%")
    }

    func doSomething() { }
    @State private var volume = 50.0
}

핵심 요약

  • Accessibility API: 다른 앱의 UI 요소를 읽고 조작하는 macOS 시스템 API
  • AXIsProcessTrustedWithOptions: 접근성 권한 확인 및 요청
  • AXUIElement: UI 계층 탐색의 기본 단위. kAX* 상수로 속성 읽기
  • NSEvent.addGlobalMonitorForEvents: 전역 키보드 이벤트 감지
  • Carbon HotKey API: 더 안정적인 전역 단축키 등록
  • NSWorkspace: 실행 중인 앱 목록, 앱 활성화 이벤트 감지
  • SwiftUI의 .accessibilityLabel, .accessibilityHint로 내 앱의 접근성도 챙길 것

4부 macOS 앱 개발이 완결됩니다. 다음 편부터는 5부 Foundation과 데이터 저장으로, Codable/JSON, UserDefaults, FileManager, SQLite를 다룹니다.

🤖 Generated with Claude Code