Sfoglia il codice sorgente

Initial AirAccept implementation

hsiang 5 mesi fa
commit
173cdf9b3b

+ 24 - 0
.gitignore

@@ -0,0 +1,24 @@
+# macOS
+.DS_Store
+
+# Swift Package Manager
+.build/
+.swiftpm/
+
+# Generated app bundles and archives
+build/
+*.xcarchive
+*.dSYM/
+
+# Xcode local state
+DerivedData/
+*.xcuserstate
+xcuserdata/
+*.xcodeproj/project.xcworkspace/xcuserdata/
+*.xcodeproj/xcuserdata/
+
+# Local editor and environment files
+*.swp
+*.swo
+.env
+.env.*

+ 35 - 0
Makefile

@@ -0,0 +1,35 @@
+APP_NAME := AirAccept
+BUNDLE_ID := com.airaccept.AirAccept
+BUILD_DIR := .build/release
+APP_DIR := build/$(APP_NAME).app
+CONTENTS_DIR := $(APP_DIR)/Contents
+MACOS_DIR := $(CONTENTS_DIR)/MacOS
+RESOURCES_DIR := $(CONTENTS_DIR)/Resources
+
+.PHONY: build test app run debug-run clean
+
+build:
+	swift build -c release
+
+test:
+	swift test
+
+app: build
+	rm -rf "$(APP_DIR)"
+	mkdir -p "$(MACOS_DIR)" "$(RESOURCES_DIR)"
+	cp "$(BUILD_DIR)/$(APP_NAME)" "$(MACOS_DIR)/$(APP_NAME)"
+	cp "Resources/Info.plist" "$(CONTENTS_DIR)/Info.plist"
+	plutil -replace CFBundleIdentifier -string "$(BUNDLE_ID)" "$(CONTENTS_DIR)/Info.plist"
+	plutil -replace CFBundleExecutable -string "$(APP_NAME)" "$(CONTENTS_DIR)/Info.plist"
+	plutil -replace CFBundleName -string "$(APP_NAME)" "$(CONTENTS_DIR)/Info.plist"
+	plutil -replace CFBundleDisplayName -string "$(APP_NAME)" "$(CONTENTS_DIR)/Info.plist"
+	codesign --force --sign - "$(APP_DIR)"
+
+run: app
+	open "$(APP_DIR)"
+
+debug-run: app
+	"$(MACOS_DIR)/$(APP_NAME)"
+
+clean:
+	rm -rf .build build

+ 25 - 0
Package.swift

@@ -0,0 +1,25 @@
+// swift-tools-version: 5.9
+
+import PackageDescription
+
+let package = Package(
+    name: "AirAccept",
+    platforms: [
+        .macOS(.v13)
+    ],
+    products: [
+        .executable(name: "AirAccept", targets: ["AirAccept"]),
+        .library(name: "AirAcceptCore", targets: ["AirAcceptCore"])
+    ],
+    targets: [
+        .target(name: "AirAcceptCore"),
+        .executableTarget(
+            name: "AirAccept",
+            dependencies: ["AirAcceptCore"]
+        ),
+        .testTarget(
+            name: "AirAcceptCoreTests",
+            dependencies: ["AirAcceptCore"]
+        )
+    ]
+)

+ 103 - 0
README.md

@@ -0,0 +1,103 @@
+# AirAccept
+
+AirAccept is a small macOS menu bar app that watches for incoming AirPlay Receiver prompts and automatically presses `Accept` only when the prompt clearly matches an AirPlay request.
+
+It is intended for a Mac that already has the built-in AirPlay Receiver enabled in System Settings.
+
+## Requirements
+
+- macOS Sequoia 15 or Tahoe 26
+- Xcode command line tools or Xcode
+- Accessibility permission for AirAccept
+
+AirAccept does not require Screen Recording and does not use AppleScript/System Events automation.
+
+## Build
+
+```sh
+make test
+make app
+```
+
+The app bundle is created at:
+
+```text
+build/AirAccept.app
+```
+
+To build and launch in one step:
+
+```sh
+make run
+```
+
+If `open` appears to do nothing, run the app in the foreground to see launch errors:
+
+```sh
+make debug-run
+```
+
+When it is running correctly from `debug-run`, the command stays active until you quit AirAccept or press `Ctrl-C`.
+
+You can move that app bundle to `/Applications` after building. Keeping the app in a stable path is recommended because macOS Accessibility permissions are tied to the app identity and path.
+
+## First Run
+
+1. Launch `build/AirAccept.app`.
+2. Use the menu bar item `AirAccept` -> `Request Accessibility Permission`.
+3. In System Settings, allow AirAccept under Privacy & Security -> Accessibility.
+4. Quit and reopen AirAccept if macOS does not apply the permission immediately.
+
+To confirm it launched, check the log:
+
+```sh
+tail -n 20 "$HOME/Library/Logs/AirAccept/airaccept.log"
+```
+
+You should see a line like:
+
+```text
+[info] ... AirAccept launched pid=12345
+```
+
+## Configuration
+
+AirAccept creates this file on first run:
+
+```text
+~/Library/Application Support/AirAccept/config.json
+```
+
+Default config:
+
+```json
+{
+  "poll_interval_ms": 2500
+}
+```
+
+`poll_interval_ms` controls how often AirAccept checks Notification Center for a matching AirPlay request.
+
+## Logs
+
+AirAccept writes newline-delimited JSON events to:
+
+```text
+~/Library/Logs/AirAccept/airaccept.log
+```
+
+Each event includes timestamp, host process, prompt summary, parsed device name, action, and reason. It also writes to Apple unified logging with subsystem `com.airaccept.AirAccept`.
+
+## Launch at Login
+
+Use the menu bar item:
+
+```text
+AirAccept -> Launch at Login
+```
+
+This uses `SMAppService.mainApp`, the macOS 13+ login item API.
+
+## Safety Notes
+
+AirAccept does not click arbitrary system dialogs. A prompt must contain AirPlay-specific request text, such as a device wanting to AirPlay to this Mac, and an accept-like Accessibility action before AirAccept clicks through the Accessibility path.

+ 30 - 0
Resources/Info.plist

@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>CFBundleDevelopmentRegion</key>
+	<string>en</string>
+	<key>CFBundleExecutable</key>
+	<string>AirAccept</string>
+	<key>CFBundleIdentifier</key>
+	<string>com.airaccept.AirAccept</string>
+	<key>CFBundleInfoDictionaryVersion</key>
+	<string>6.0</string>
+	<key>CFBundleName</key>
+	<string>AirAccept</string>
+	<key>CFBundleDisplayName</key>
+	<string>AirAccept</string>
+	<key>CFBundlePackageType</key>
+	<string>APPL</string>
+	<key>CFBundleShortVersionString</key>
+	<string>0.1.0</string>
+	<key>CFBundleVersion</key>
+	<string>1</string>
+	<key>LSMinimumSystemVersion</key>
+	<string>13.0</string>
+	<key>LSUIElement</key>
+	<true/>
+	<key>NSHumanReadableCopyright</key>
+	<string>Copyright © 2026 AirAccept.</string>
+</dict>
+</plist>

+ 271 - 0
Sources/AirAccept/AccessibilityReader.swift

@@ -0,0 +1,271 @@
+import AirAcceptCore
+import AppKit
+import ApplicationServices
+import Foundation
+
+struct PromptCandidateWithButton {
+    var candidate: PromptCandidate
+    var match: AirPlayPromptMatch
+    var acceptButton: AXUIElement
+    var acceptActionName: String
+}
+
+enum AccessibilityReader {
+    private static let notificationCenterBundleID = "com.apple.notificationcenterui"
+
+    static func isTrusted(prompt: Bool) -> Bool {
+        let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: prompt] as CFDictionary
+        return AXIsProcessTrustedWithOptions(options)
+    }
+
+    static func notificationCenterCandidate() -> PromptCandidateWithButton? {
+        guard let window = notificationCenterWindow() else {
+            return nil
+        }
+
+        return LocalTreeSearch.findCandidate(
+            in: window,
+            hostProcess: notificationCenterBundleID,
+            maxDepth: 9,
+            maxNodes: 450
+        )
+    }
+
+    static func press(_ element: AXUIElement, actionName: String) -> AXError {
+        AXUIElementPerformAction(element, actionName as CFString)
+    }
+
+    private static func notificationCenterWindow() -> AXUIElement? {
+        guard
+            let app = NSRunningApplication
+                .runningApplications(withBundleIdentifier: notificationCenterBundleID)
+                .first
+        else {
+            return nil
+        }
+
+        let appElement = AXUIElementCreateApplication(app.processIdentifier)
+        var windowsRef: CFTypeRef?
+        guard
+            AXUIElementCopyAttributeValue(appElement, kAXWindowsAttribute as CFString, &windowsRef) == .success,
+            let windows = windowsRef as? [AXUIElement],
+            let window = windows.first
+        else {
+            return nil
+        }
+
+        return window
+    }
+}
+
+private enum LocalTreeSearch {
+    static func findCandidate(
+        in root: AXUIElement,
+        hostProcess: String,
+        maxDepth: Int,
+        maxNodes: Int
+    ) -> PromptCandidateWithButton? {
+        var visited = 0
+        return visit(
+            root,
+            hostProcess: hostProcess,
+            depth: 0,
+            maxDepth: maxDepth,
+            maxNodes: maxNodes,
+            visited: &visited
+        )
+    }
+
+    private static func visit(
+        _ element: AXUIElement,
+        hostProcess: String,
+        depth: Int,
+        maxDepth: Int,
+        maxNodes: Int,
+        visited: inout Int
+    ) -> PromptCandidateWithButton? {
+        guard depth <= maxDepth, visited < maxNodes else {
+            return nil
+        }
+        visited += 1
+
+        let snapshot = SnapshotBuilder.build(from: element, maxDepth: 4, maxNodes: 90)
+        let candidate = PromptCandidate(
+            hostProcess: hostProcess,
+            texts: snapshot.texts,
+            buttonTitles: snapshot.buttons.map(\.title)
+        )
+
+        if
+            let match = AirPlayPromptMatcher.match(candidate),
+            let acceptButton = snapshot.buttons.first(where: { $0.title == match.acceptButtonTitle })
+        {
+            return PromptCandidateWithButton(
+                candidate: candidate,
+                match: match,
+                acceptButton: acceptButton.element,
+                acceptActionName: acceptButton.actionName
+            )
+        }
+
+        for child in SnapshotBuilder.children(of: element) {
+            if let result = visit(
+                child,
+                hostProcess: hostProcess,
+                depth: depth + 1,
+                maxDepth: maxDepth,
+                maxNodes: maxNodes,
+                visited: &visited
+            ) {
+                return result
+            }
+        }
+
+        return nil
+    }
+}
+
+private struct SnapshotBuilder {
+    struct Button {
+        var title: String
+        var element: AXUIElement
+        var actionName: String
+    }
+
+    var texts: [String] = []
+    var buttons: [Button] = []
+
+    static func build(from root: AXUIElement, maxDepth: Int, maxNodes: Int) -> SnapshotBuilder {
+        var builder = SnapshotBuilder()
+        var visited = 0
+        builder.visit(root, depth: 0, maxDepth: maxDepth, maxNodes: maxNodes, visited: &visited)
+        return builder
+    }
+
+    private mutating func visit(
+        _ element: AXUIElement,
+        depth: Int,
+        maxDepth: Int,
+        maxNodes: Int,
+        visited: inout Int
+    ) {
+        guard depth <= maxDepth, visited < maxNodes else {
+            return
+        }
+        visited += 1
+
+        let role = stringAttribute(element, kAXRoleAttribute)
+        let title = stringAttribute(element, kAXTitleAttribute)
+        let value = stringAttribute(element, kAXValueAttribute)
+        let description = stringAttribute(element, kAXDescriptionAttribute)
+        let help = stringAttribute(element, kAXHelpAttribute)
+        let actionNames = actionNames(element)
+
+        for text in [title, value, description, help].compactMap({ $0 }) {
+            appendUnique(text, to: &texts)
+        }
+
+        if let button = buttonMetadata(
+            role: role,
+            title: title,
+            value: value,
+            description: description,
+            help: help,
+            actionNames: actionNames
+        ) {
+            buttons.append(Button(title: button.title, element: element, actionName: button.actionName))
+        }
+
+        for child in Self.children(of: element) {
+            visit(child, depth: depth + 1, maxDepth: maxDepth, maxNodes: maxNodes, visited: &visited)
+        }
+    }
+
+    private func stringAttribute(_ element: AXUIElement, _ attribute: String) -> String? {
+        var value: CFTypeRef?
+        guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success else {
+            return nil
+        }
+
+        if let string = value as? String {
+            return string.trimmingCharacters(in: .whitespacesAndNewlines)
+        }
+
+        if let attributed = value as? NSAttributedString {
+            return attributed.string.trimmingCharacters(in: .whitespacesAndNewlines)
+        }
+
+        return nil
+    }
+
+    static func children(of element: AXUIElement) -> [AXUIElement] {
+        var value: CFTypeRef?
+        guard
+            AXUIElementCopyAttributeValue(element, kAXChildrenAttribute as CFString, &value) == .success,
+            let children = value as? [AXUIElement]
+        else {
+            return []
+        }
+        return children
+    }
+
+    private func actionNames(_ element: AXUIElement) -> [String] {
+        var value: CFArray?
+        guard AXUIElementCopyActionNames(element, &value) == .success else {
+            return []
+        }
+        return (value as? [CFString] ?? []).map { String($0) }
+    }
+
+    private func buttonMetadata(
+        role: String?,
+        title: String?,
+        value: String?,
+        description: String?,
+        help: String?,
+        actionNames: [String]
+    ) -> (title: String, actionName: String)? {
+        let labels = [title, value, description, help]
+            .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
+            .filter { !$0.isEmpty }
+
+        if let namedAction = actionNames.first(where: { actionTitle($0).map(AirPlayPromptMatcher.isAcceptTitle) == true }) {
+            return (actionTitle(namedAction) ?? namedAction, namedAction)
+        }
+
+        if role == kAXButtonRole as String, let label = labels.first {
+            return (label, kAXPressAction as String)
+        }
+
+        guard
+            actionNames.contains(kAXPressAction as String),
+            let label = labels.first(where: AirPlayPromptMatcher.isAcceptTitle)
+        else {
+            return nil
+        }
+
+        return (label, kAXPressAction as String)
+    }
+
+    private func actionTitle(_ actionName: String) -> String? {
+        let normalized = actionName.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard normalized.localizedCaseInsensitiveContains("name:") else {
+            return nil
+        }
+
+        let parts = normalized.split(separator: ":", maxSplits: 1)
+        guard parts.count == 2 else {
+            return nil
+        }
+
+        return String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines)
+    }
+
+    private func appendUnique(_ text: String, to array: inout [String]) {
+        let cleaned = text.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !cleaned.isEmpty, !array.contains(cleaned) else {
+            return
+        }
+        array.append(cleaned)
+    }
+}

+ 14 - 0
Sources/AirAccept/AirAcceptMain.swift

@@ -0,0 +1,14 @@
+import AirAcceptCore
+import AppKit
+
+@main
+enum AirAcceptMain {
+    @MainActor
+    static func main() {
+        AirAcceptLogger().info("AirAccept executable started pid=\(ProcessInfo.processInfo.processIdentifier)")
+        let app = NSApplication.shared
+        let delegate = AppDelegate()
+        app.delegate = delegate
+        app.run()
+    }
+}

+ 123 - 0
Sources/AirAccept/AirPlayPromptWatcher.swift

@@ -0,0 +1,123 @@
+import AirAcceptCore
+import AppKit
+import ApplicationServices
+import Foundation
+
+@MainActor
+final class AirPlayPromptWatcher {
+    private let configStore: ConfigStore
+    private let logger: AirAcceptLogger
+    private var timer: Timer?
+    private var handledFingerprints: [String: Date] = [:]
+    private var enabled = true
+    private var lastTrustedState: Bool?
+
+    init(configStore: ConfigStore, logger: AirAcceptLogger) {
+        self.configStore = configStore
+        self.logger = logger
+    }
+
+    var isEnabled: Bool {
+        enabled
+    }
+
+    func setEnabled(_ enabled: Bool) {
+        self.enabled = enabled
+        if enabled {
+            start()
+        } else {
+            stop()
+        }
+    }
+
+    func start() {
+        guard enabled else {
+            return
+        }
+
+        if updateTrustedState(prompt: true) {
+            scanOnce()
+        } else {
+            logger.info("Accessibility permission is not granted yet")
+        }
+
+        let interval = currentPollInterval()
+        scheduleTimer(interval: interval)
+        logger.info("watcher started with poll interval \(interval)s")
+    }
+
+    func stop() {
+        timer?.invalidate()
+        timer = nil
+        logger.info("watcher stopped")
+    }
+
+    func scanOnce() {
+        guard enabled, updateTrustedState(prompt: false) else {
+            return
+        }
+
+        guard let result = AccessibilityReader.notificationCenterCandidate() else {
+            pruneHandledFingerprints()
+            return
+        }
+
+        handle(result: result)
+        pruneHandledFingerprints()
+    }
+
+    private func scheduleTimer(interval: TimeInterval) {
+        timer?.invalidate()
+        timer = Timer.scheduledTimer(withTimeInterval: max(interval, 0.25), repeats: true) { [weak self] _ in
+            Task { @MainActor in
+                self?.scanOnce()
+            }
+        }
+    }
+
+    private func currentPollInterval() -> TimeInterval {
+        let config = (try? configStore.loadOrCreate()) ?? AirAcceptConfig()
+        return TimeInterval(max(config.pollIntervalMilliseconds, 250)) / 1_000.0
+    }
+
+    private func updateTrustedState(prompt: Bool) -> Bool {
+        let trusted = AccessibilityReader.isTrusted(prompt: prompt)
+        if lastTrustedState != trusted {
+            lastTrustedState = trusted
+            logger.info("Accessibility permission is \(trusted ? "granted" : "not granted")")
+        }
+        return trusted
+    }
+
+    private func handle(result: PromptCandidateWithButton) {
+        let fingerprint = "\(result.match.summary)|\(result.match.deviceName ?? "unknown")"
+        guard handledFingerprints[fingerprint] == nil else {
+            return
+        }
+
+        let axError = AccessibilityReader.press(result.acceptButton, actionName: result.acceptActionName)
+        if axError == .success {
+            handledFingerprints[fingerprint] = Date()
+            logger.event(AirAcceptEvent(
+                hostProcess: result.candidate.hostProcess,
+                summary: result.match.summary,
+                deviceName: result.match.deviceName,
+                action: "accept",
+                reason: "accepted matched AirPlay request"
+            ))
+        } else {
+            logger.event(AirAcceptEvent(
+                hostProcess: result.candidate.hostProcess,
+                summary: result.match.summary,
+                deviceName: result.match.deviceName,
+                action: "error",
+                reason: "failed to press Accept: \(axError.rawValue)"
+            ))
+        }
+    }
+
+    private func pruneHandledFingerprints() {
+        let cutoff = Date().addingTimeInterval(-30)
+        handledFingerprints = handledFingerprints.filter { $0.value > cutoff }
+    }
+}

+ 156 - 0
Sources/AirAccept/AppDelegate.swift

@@ -0,0 +1,156 @@
+import AirAcceptCore
+import AppKit
+import Foundation
+
+@MainActor
+final class AppDelegate: NSObject, NSApplicationDelegate {
+    private let configStore = ConfigStore()
+    private let logger = AirAcceptLogger()
+    private lazy var watcher = AirPlayPromptWatcher(configStore: configStore, logger: logger)
+    private var statusItem: NSStatusItem?
+
+    func applicationDidFinishLaunching(_ notification: Notification) {
+        NSApp.setActivationPolicy(.accessory)
+        logger.info("AirAccept launched pid=\(ProcessInfo.processInfo.processIdentifier)")
+        ensureConfigExists()
+        buildMenu()
+        watcher.start()
+    }
+
+    func applicationWillTerminate(_ notification: Notification) {
+        logger.info("AirAccept terminating pid=\(ProcessInfo.processInfo.processIdentifier)")
+    }
+
+    private func ensureConfigExists() {
+        do {
+            _ = try configStore.loadOrCreate()
+        } catch {
+            logger.error("failed to create or load config: \(error.localizedDescription)")
+        }
+    }
+
+    private func buildMenu() {
+        let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
+        if let image = NSImage(systemSymbolName: "airplayvideo", accessibilityDescription: "AirAccept") {
+            image.isTemplate = true
+            item.button?.image = image
+        } else {
+            item.button?.title = "AA"
+        }
+        item.button?.toolTip = "AirAccept"
+        item.menu = makeMenu()
+        statusItem = item
+    }
+
+    private func makeMenu() -> NSMenu {
+        let menu = NSMenu()
+
+        let statusItem = NSMenuItem(
+            title: "Status: Running (PID \(ProcessInfo.processInfo.processIdentifier))",
+            action: nil,
+            keyEquivalent: ""
+        )
+        statusItem.isEnabled = false
+        menu.addItem(statusItem)
+
+        let permissionStateItem = NSMenuItem(
+            title: AccessibilityReader.isTrusted(prompt: false)
+                ? "Accessibility: Granted"
+                : "Accessibility: Not Granted",
+            action: nil,
+            keyEquivalent: ""
+        )
+        permissionStateItem.isEnabled = false
+        menu.addItem(permissionStateItem)
+
+        menu.addItem(.separator())
+
+        let watcherItem = NSMenuItem(
+            title: watcher.isEnabled ? "Disable Watcher" : "Enable Watcher",
+            action: #selector(toggleWatcher(_:)),
+            keyEquivalent: ""
+        )
+        watcherItem.target = self
+        menu.addItem(watcherItem)
+
+        let launchItem = NSMenuItem(
+            title: "Launch at Login",
+            action: #selector(toggleLaunchAtLogin(_:)),
+            keyEquivalent: ""
+        )
+        launchItem.target = self
+        launchItem.state = LoginItemManager.isEnabled ? .on : .off
+        menu.addItem(launchItem)
+
+        menu.addItem(.separator())
+
+        let permissionItem = NSMenuItem(
+            title: "Request Accessibility Permission",
+            action: #selector(requestAccessibilityPermission(_:)),
+            keyEquivalent: ""
+        )
+        permissionItem.target = self
+        menu.addItem(permissionItem)
+
+        let openConfigItem = NSMenuItem(
+            title: "Open Config",
+            action: #selector(openConfig(_:)),
+            keyEquivalent: ""
+        )
+        openConfigItem.target = self
+        menu.addItem(openConfigItem)
+
+        let openLogsItem = NSMenuItem(
+            title: "Open Logs",
+            action: #selector(openLogs(_:)),
+            keyEquivalent: ""
+        )
+        openLogsItem.target = self
+        menu.addItem(openLogsItem)
+
+        menu.addItem(.separator())
+
+        let quitItem = NSMenuItem(title: "Quit", action: #selector(quit(_:)), keyEquivalent: "q")
+        quitItem.target = self
+        menu.addItem(quitItem)
+
+        return menu
+    }
+
+    private func rebuildMenu() {
+        statusItem?.menu = makeMenu()
+    }
+
+    @objc private func toggleWatcher(_ sender: NSMenuItem) {
+        watcher.setEnabled(!watcher.isEnabled)
+        rebuildMenu()
+    }
+
+    @objc private func toggleLaunchAtLogin(_ sender: NSMenuItem) {
+        do {
+            try LoginItemManager.setEnabled(!LoginItemManager.isEnabled)
+            logger.info("launch at login set to \(LoginItemManager.isEnabled)")
+        } catch {
+            logger.error("failed to toggle launch at login: \(error.localizedDescription)")
+        }
+        rebuildMenu()
+    }
+
+    @objc private func requestAccessibilityPermission(_ sender: NSMenuItem) {
+        _ = AccessibilityReader.isTrusted(prompt: true)
+    }
+
+    @objc private func openConfig(_ sender: NSMenuItem) {
+        ensureConfigExists()
+        NSWorkspace.shared.activateFileViewerSelecting([configStore.configURL])
+    }
+
+    @objc private func openLogs(_ sender: NSMenuItem) {
+        NSWorkspace.shared.activateFileViewerSelecting([logger.logURL])
+    }
+
+    @objc private func quit(_ sender: NSMenuItem) {
+        watcher.stop()
+        NSApp.terminate(nil)
+    }
+}

+ 20 - 0
Sources/AirAccept/LoginItemManager.swift

@@ -0,0 +1,20 @@
+import Foundation
+import ServiceManagement
+
+enum LoginItemManager {
+    static var isEnabled: Bool {
+        SMAppService.mainApp.status == .enabled
+    }
+
+    static func setEnabled(_ enabled: Bool) throws {
+        if enabled {
+            if SMAppService.mainApp.status != .enabled {
+                try SMAppService.mainApp.register()
+            }
+        } else {
+            if SMAppService.mainApp.status == .enabled {
+                try SMAppService.mainApp.unregister()
+            }
+        }
+    }
+}

+ 71 - 0
Sources/AirAcceptCore/AirAcceptConfig.swift

@@ -0,0 +1,71 @@
+import Foundation
+
+public struct AirAcceptConfig: Codable, Equatable, Sendable {
+    public static let defaultPollIntervalMilliseconds = 2_500
+
+    public var pollIntervalMilliseconds: Int
+
+    public init(
+        pollIntervalMilliseconds: Int = Self.defaultPollIntervalMilliseconds
+    ) {
+        self.pollIntervalMilliseconds = pollIntervalMilliseconds
+    }
+
+    private enum CodingKeys: String, CodingKey {
+        case pollIntervalMilliseconds = "poll_interval_ms"
+    }
+
+    public init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        self.pollIntervalMilliseconds = try container.decodeIfPresent(
+            Int.self,
+            forKey: .pollIntervalMilliseconds
+        ) ?? Self.defaultPollIntervalMilliseconds
+    }
+}
+
+public final class ConfigStore {
+    public let configURL: URL
+    private let fileManager: FileManager
+
+    public init(
+        configURL: URL = ConfigStore.defaultConfigURL(),
+        fileManager: FileManager = .default
+    ) {
+        self.configURL = configURL
+        self.fileManager = fileManager
+    }
+
+    public static func defaultConfigURL() -> URL {
+        let baseURL = FileManager.default.homeDirectoryForCurrentUser
+            .appendingPathComponent("Library/Application Support/AirAccept", isDirectory: true)
+        return baseURL.appendingPathComponent("config.json", isDirectory: false)
+    }
+
+    public func loadOrCreate() throws -> AirAcceptConfig {
+        if !fileManager.fileExists(atPath: configURL.path) {
+            let config = AirAcceptConfig()
+            try save(config)
+            return config
+        }
+
+        let data = try Data(contentsOf: configURL)
+        var config = try JSONDecoder().decode(AirAcceptConfig.self, from: data)
+        if config.pollIntervalMilliseconds == 1_000 {
+            config.pollIntervalMilliseconds = AirAcceptConfig.defaultPollIntervalMilliseconds
+            try save(config)
+        }
+        return config
+    }
+
+    public func save(_ config: AirAcceptConfig) throws {
+        try fileManager.createDirectory(
+            at: configURL.deletingLastPathComponent(),
+            withIntermediateDirectories: true
+        )
+        let encoder = JSONEncoder()
+        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+        let data = try encoder.encode(config)
+        try data.write(to: configURL, options: [.atomic])
+    }
+}

+ 120 - 0
Sources/AirAcceptCore/AirAcceptLogger.swift

@@ -0,0 +1,120 @@
+import Foundation
+import OSLog
+
+public struct AirAcceptEvent: Codable, Equatable, Sendable {
+    public var timestamp: Date
+    public var hostProcess: String
+    public var summary: String
+    public var deviceName: String?
+    public var action: String
+    public var reason: String?
+
+    public init(
+        timestamp: Date = Date(),
+        hostProcess: String,
+        summary: String,
+        deviceName: String?,
+        action: String,
+        reason: String?
+    ) {
+        self.timestamp = timestamp
+        self.hostProcess = hostProcess
+        self.summary = summary
+        self.deviceName = deviceName
+        self.action = action
+        self.reason = reason
+    }
+}
+
+public final class AirAcceptLogger {
+    public let logURL: URL
+    private let logger = Logger(subsystem: "com.airaccept.AirAccept", category: "AirAccept")
+    private let fileManager: FileManager
+    private let encoder: JSONEncoder
+
+    public init(
+        logURL: URL = AirAcceptLogger.defaultLogURL(),
+        fileManager: FileManager = .default
+    ) {
+        self.logURL = logURL
+        self.fileManager = fileManager
+        self.encoder = JSONEncoder()
+        self.encoder.dateEncodingStrategy = .iso8601
+        self.encoder.outputFormatting = [.sortedKeys]
+    }
+
+    public static func defaultLogURL() -> URL {
+        FileManager.default.homeDirectoryForCurrentUser
+            .appendingPathComponent("Library/Logs/AirAccept", isDirectory: true)
+            .appendingPathComponent("airaccept.log", isDirectory: false)
+    }
+
+    public func info(_ message: String) {
+        logger.info("\(message, privacy: .public)")
+        appendLine("[info] \(isoTimestamp()) \(message)")
+    }
+
+    public func error(_ message: String) {
+        logger.error("\(message, privacy: .public)")
+        appendLine("[error] \(isoTimestamp()) \(message)")
+    }
+
+    public func event(_ event: AirAcceptEvent) {
+        var event = event
+        event.summary = sanitize(event.summary, limit: 600)
+        event.reason = event.reason.map { sanitize($0, limit: 240) }
+
+        logger.info(
+            "host=\(event.hostProcess, privacy: .public) action=\(event.action, privacy: .public) device=\(event.deviceName ?? "unknown", privacy: .public) reason=\(event.reason ?? "", privacy: .public)"
+        )
+
+        do {
+            let data = try encoder.encode(event)
+            let line = String(decoding: data, as: UTF8.self)
+            appendLine(line)
+        } catch {
+            appendLine("[error] \(isoTimestamp()) failed to encode event: \(error.localizedDescription)")
+        }
+    }
+
+    private func appendLine(_ line: String) {
+        do {
+            try fileManager.createDirectory(
+                at: logURL.deletingLastPathComponent(),
+                withIntermediateDirectories: true
+            )
+            let data = Data((line + "\n").utf8)
+            if fileManager.fileExists(atPath: logURL.path) {
+                let handle = try FileHandle(forWritingTo: logURL)
+                try handle.seekToEnd()
+                try handle.write(contentsOf: data)
+                try handle.close()
+            } else {
+                try data.write(to: logURL, options: [.atomic])
+            }
+        } catch {
+            logger.error("failed to append log file: \(error.localizedDescription, privacy: .public)")
+        }
+    }
+
+    private func isoTimestamp() -> String {
+        ISO8601DateFormatter().string(from: Date())
+    }
+
+    private func sanitize(_ value: String, limit: Int) -> String {
+        var result = value
+            .replacingOccurrences(of: "\n", with: " ")
+            .replacingOccurrences(of: "\r", with: " ")
+            .replacingOccurrences(of: "\t", with: " ")
+
+        while result.contains("  ") {
+            result = result.replacingOccurrences(of: "  ", with: " ")
+        }
+
+        if result.count > limit {
+            return String(result.prefix(limit - 3)) + "..."
+        }
+
+        return result
+    }
+}

+ 168 - 0
Sources/AirAcceptCore/AirPlayPromptMatcher.swift

@@ -0,0 +1,168 @@
+import Foundation
+
+public struct PromptCandidate: Equatable, Sendable {
+    public var hostProcess: String
+    public var texts: [String]
+    public var buttonTitles: [String]
+
+    public init(hostProcess: String, texts: [String], buttonTitles: [String]) {
+        self.hostProcess = hostProcess
+        self.texts = texts
+        self.buttonTitles = buttonTitles
+    }
+}
+
+public struct AirPlayPromptMatch: Equatable, Sendable {
+    public var summary: String
+    public var deviceName: String?
+    public var acceptButtonTitle: String
+    public var declineButtonTitle: String?
+
+    public init(
+        summary: String,
+        deviceName: String?,
+        acceptButtonTitle: String,
+        declineButtonTitle: String?
+    ) {
+        self.summary = summary
+        self.deviceName = deviceName
+        self.acceptButtonTitle = acceptButtonTitle
+        self.declineButtonTitle = declineButtonTitle
+    }
+}
+
+public enum AirPlayPromptMatcher {
+    public static let acceptTitles = [
+        "accept", "allow", "ok", "接受", "允許", "好"
+    ]
+
+    public static let declineTitles = [
+        "decline", "deny", "don't allow", "do not allow", "cancel", "拒絕", "不允許", "取消"
+    ]
+
+    private static let airPlayTerms = [
+        "airplay", "air play", "隔空播放", "螢幕鏡像", "屏幕镜像"
+    ]
+
+    private static let requestPatterns = [
+        #"would\s+like\s+to\s+airplay\s+to\s+this\s+mac"#,
+        #"wants\s+to\s+airplay\s+to\s+this\s+mac"#,
+        #"is\s+trying\s+to\s+airplay\s+to\s+this\s+mac"#,
+        #"想要.{0,40}(?:airplay|隔空播放|螢幕鏡像|屏幕镜像).{0,40}(?:這台|这台|此)\s*mac"#,
+        #"正在(?:嘗試|尝试).{0,40}(?:airplay|隔空播放|螢幕鏡像|屏幕镜像).{0,40}(?:這台|这台|此)\s*mac"#
+    ]
+
+    public static func match(_ candidate: PromptCandidate) -> AirPlayPromptMatch? {
+        let texts = candidate.texts
+            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+            .filter { !$0.isEmpty }
+
+        let combinedText = texts.joined(separator: " ")
+        let normalizedText = normalize(combinedText)
+        guard airPlayTerms.contains(where: { normalizedText.contains($0) }) else {
+            return nil
+        }
+
+        guard isAirPlayConnectionRequest(combinedText) else {
+            return nil
+        }
+
+        guard let acceptTitle = candidate.buttonTitles.first(where: isAcceptTitle) else {
+            return nil
+        }
+
+        return AirPlayPromptMatch(
+            summary: summarize(combinedText),
+            deviceName: extractDeviceName(from: combinedText),
+            acceptButtonTitle: acceptTitle,
+            declineButtonTitle: candidate.buttonTitles.first(where: isDeclineTitle)
+        )
+    }
+
+    public static func isAcceptTitle(_ title: String) -> Bool {
+        let normalizedTitle = normalize(title)
+        return acceptTitles.contains(normalizedTitle)
+    }
+
+    public static func isDeclineTitle(_ title: String) -> Bool {
+        let normalizedTitle = normalize(title)
+        return declineTitles.contains(normalizedTitle)
+    }
+
+    public static func extractDeviceName(from text: String) -> String? {
+        let patterns = [
+            #"["“](.+?)["”]\s+(?:would like|wants|is trying).{0,80}AirPlay"#,
+            #"(.+?)\s+(?:would like|wants|is trying).{0,80}AirPlay"#,
+            #"(?:Allow|Accept)\s+["“](.+?)["”].{0,80}AirPlay"#,
+            #"AirPlay.{0,80}(?:from|for)\s+["“](.+?)["”]"#,
+            #"["「](.+?)["」]\s*(?:想要|正在嘗試|正在尝试).{0,80}(?:AirPlay|隔空播放|螢幕鏡像|屏幕镜像)"#,
+            #"(?:接受|允許)\s*["「](.+?)["」].{0,80}(?:AirPlay|隔空播放|螢幕鏡像|屏幕镜像)"#
+        ]
+
+        for pattern in patterns {
+            if let match = firstCapture(in: text, pattern: pattern) {
+                let cleaned = cleanupDeviceName(match)
+                if !cleaned.isEmpty {
+                    return cleaned
+                }
+            }
+        }
+
+        return nil
+    }
+
+    private static func firstCapture(in text: String, pattern: String) -> String? {
+        guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
+            return nil
+        }
+
+        let range = NSRange(text.startIndex..<text.endIndex, in: text)
+        guard
+            let match = regex.firstMatch(in: text, options: [], range: range),
+            match.numberOfRanges > 1,
+            let captureRange = Range(match.range(at: 1), in: text)
+        else {
+            return nil
+        }
+
+        return String(text[captureRange])
+    }
+
+    private static func isAirPlayConnectionRequest(_ text: String) -> Bool {
+        requestPatterns.contains { pattern in
+            guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
+                return false
+            }
+
+            let range = NSRange(text.startIndex..<text.endIndex, in: text)
+            return regex.firstMatch(in: text, options: [], range: range) != nil
+        }
+    }
+
+    private static func cleanupDeviceName(_ value: String) -> String {
+        value
+            .replacingOccurrences(of: "\n", with: " ")
+            .replacingOccurrences(of: "  ", with: " ")
+            .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines.union(.punctuationCharacters))
+    }
+
+    private static func summarize(_ text: String) -> String {
+        let cleaned = text
+            .replacingOccurrences(of: "\n", with: " ")
+            .replacingOccurrences(of: "  ", with: " ")
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+
+        if cleaned.count <= 240 {
+            return cleaned
+        }
+
+        return String(cleaned.prefix(237)) + "..."
+    }
+
+    private static func normalize(_ text: String) -> String {
+        text
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+            .folding(options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], locale: .current)
+            .lowercased()
+    }
+}

+ 80 - 0
Tests/AirAcceptCoreTests/AirAcceptCoreTests.swift

@@ -0,0 +1,80 @@
+import XCTest
+@testable import AirAcceptCore
+
+final class AirAcceptCoreTests: XCTestCase {
+    func testDefaultConfigAcceptsMatchedAirPlayRequests() {
+        let config = AirAcceptConfig()
+        XCTAssertEqual(config.pollIntervalMilliseconds, 2_500)
+    }
+
+    func testMatchesEnglishAirPlayPrompt() {
+        let candidate = PromptCandidate(
+            hostProcess: "com.apple.AirPlayUIAgent",
+            texts: ["John's iPhone would like to AirPlay to this Mac."],
+            buttonTitles: ["Decline", "Accept"]
+        )
+
+        let match = AirPlayPromptMatcher.match(candidate)
+        XCTAssertEqual(match?.deviceName, "John's iPhone")
+        XCTAssertEqual(match?.acceptButtonTitle, "Accept")
+        XCTAssertEqual(match?.declineButtonTitle, "Decline")
+    }
+
+    func testMatchesQuotedTraditionalChinesePrompt() {
+        let candidate = PromptCandidate(
+            hostProcess: "com.apple.AirPlayUIAgent",
+            texts: ["「客廳 iPad」想要使用 AirPlay 連線到這台 Mac。"],
+            buttonTitles: ["拒絕", "接受"]
+        )
+
+        let match = AirPlayPromptMatcher.match(candidate)
+        XCTAssertEqual(match?.deviceName, "客廳 iPad")
+        XCTAssertEqual(match?.acceptButtonTitle, "接受")
+        XCTAssertEqual(match?.declineButtonTitle, "拒絕")
+    }
+
+    func testRejectsNonAirPlayAcceptDeclineDialog() {
+        let candidate = PromptCandidate(
+            hostProcess: "com.apple.SecurityAgent",
+            texts: ["A system change requires approval."],
+            buttonTitles: ["Decline", "Accept"]
+        )
+
+        XCTAssertNil(AirPlayPromptMatcher.match(candidate))
+    }
+
+    func testMatchesAirPlayPromptWithoutDeclineButton() {
+        let candidate = PromptCandidate(
+            hostProcess: "com.apple.AirPlayUIAgent",
+            texts: ["John's iPhone would like to AirPlay to this Mac."],
+            buttonTitles: ["Accept"]
+        )
+
+        let match = AirPlayPromptMatcher.match(candidate)
+        XCTAssertEqual(match?.deviceName, "John's iPhone")
+        XCTAssertEqual(match?.acceptButtonTitle, "Accept")
+        XCTAssertNil(match?.declineButtonTitle)
+    }
+
+    func testRejectsGenericAirPlayTextWithoutConnectionRequest() {
+        let candidate = PromptCandidate(
+            hostProcess: "com.apple.AirPlayUIAgent",
+            texts: ["AirPlay settings are available on this Mac."],
+            buttonTitles: ["Accept"]
+        )
+
+        XCTAssertNil(AirPlayPromptMatcher.match(candidate))
+    }
+
+    func testConfigStoreCreatesDefaultConfig() throws {
+        let directory = FileManager.default.temporaryDirectory
+            .appendingPathComponent(UUID().uuidString, isDirectory: true)
+        let url = directory.appendingPathComponent("config.json")
+        let store = ConfigStore(configURL: url)
+
+        let config = try store.loadOrCreate()
+
+        XCTAssertEqual(config, AirAcceptConfig())
+        XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
+    }
+}