AirPlayPromptWatcher.swift 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. import AirAcceptCore
  2. import AppKit
  3. import ApplicationServices
  4. import Foundation
  5. @MainActor
  6. final class AirPlayPromptWatcher {
  7. private let configStore: ConfigStore
  8. private let logger: AirAcceptLogger
  9. private var timer: Timer?
  10. private var handledFingerprints: [String: Date] = [:]
  11. private var enabled = true
  12. private var lastTrustedState: Bool?
  13. init(configStore: ConfigStore, logger: AirAcceptLogger) {
  14. self.configStore = configStore
  15. self.logger = logger
  16. }
  17. var isEnabled: Bool {
  18. enabled
  19. }
  20. func setEnabled(_ enabled: Bool) {
  21. self.enabled = enabled
  22. if enabled {
  23. start()
  24. } else {
  25. stop()
  26. }
  27. }
  28. func start() {
  29. guard enabled else {
  30. return
  31. }
  32. if updateTrustedState(prompt: true) {
  33. scanOnce()
  34. } else {
  35. logger.info("Accessibility permission is not granted yet")
  36. }
  37. let interval = currentPollInterval()
  38. scheduleTimer(interval: interval)
  39. logger.info("watcher started with poll interval \(interval)s")
  40. }
  41. func stop() {
  42. timer?.invalidate()
  43. timer = nil
  44. logger.info("watcher stopped")
  45. }
  46. func reloadConfiguration() {
  47. guard enabled else {
  48. return
  49. }
  50. let interval = currentPollInterval()
  51. scheduleTimer(interval: interval)
  52. logger.info("watcher poll interval updated to \(interval)s")
  53. }
  54. func scanOnce() {
  55. guard enabled, updateTrustedState(prompt: false) else {
  56. return
  57. }
  58. guard let result = AccessibilityReader.notificationCenterCandidate() else {
  59. pruneHandledFingerprints()
  60. return
  61. }
  62. handle(result: result)
  63. pruneHandledFingerprints()
  64. }
  65. private func scheduleTimer(interval: TimeInterval) {
  66. timer?.invalidate()
  67. timer = Timer.scheduledTimer(withTimeInterval: max(interval, 0.25), repeats: true) { [weak self] _ in
  68. Task { @MainActor in
  69. self?.scanOnce()
  70. }
  71. }
  72. }
  73. private func currentPollInterval() -> TimeInterval {
  74. let config = (try? configStore.loadOrCreate()) ?? AirAcceptConfig()
  75. return TimeInterval(max(config.pollIntervalMilliseconds, 250)) / 1_000.0
  76. }
  77. private func updateTrustedState(prompt: Bool) -> Bool {
  78. let trusted = AccessibilityReader.isTrusted(prompt: prompt)
  79. if lastTrustedState != trusted {
  80. lastTrustedState = trusted
  81. logger.info("Accessibility permission is \(trusted ? "granted" : "not granted")")
  82. }
  83. return trusted
  84. }
  85. private func handle(result: PromptCandidateWithButton) {
  86. let fingerprint = "\(result.match.summary)|\(result.match.deviceName ?? "unknown")"
  87. guard handledFingerprints[fingerprint] == nil else {
  88. return
  89. }
  90. let axError = AccessibilityReader.press(result.acceptButton, actionName: result.acceptActionName)
  91. if axError == .success {
  92. handledFingerprints[fingerprint] = Date()
  93. logger.event(AirAcceptEvent(
  94. hostProcess: result.candidate.hostProcess,
  95. summary: result.match.summary,
  96. deviceName: result.match.deviceName,
  97. action: "accept",
  98. reason: "accepted matched AirPlay request"
  99. ))
  100. } else {
  101. logger.event(AirAcceptEvent(
  102. hostProcess: result.candidate.hostProcess,
  103. summary: result.match.summary,
  104. deviceName: result.match.deviceName,
  105. action: "error",
  106. reason: "failed to press Accept: \(axError.rawValue)"
  107. ))
  108. }
  109. }
  110. private func pruneHandledFingerprints() {
  111. let cutoff = Date().addingTimeInterval(-30)
  112. handledFingerprints = handledFingerprints.filter { $0.value > cutoff }
  113. }
  114. }