AirPlayPromptMatcher.swift 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. import Foundation
  2. public struct PromptCandidate: Equatable, Sendable {
  3. public var hostProcess: String
  4. public var texts: [String]
  5. public var buttonTitles: [String]
  6. public init(hostProcess: String, texts: [String], buttonTitles: [String]) {
  7. self.hostProcess = hostProcess
  8. self.texts = texts
  9. self.buttonTitles = buttonTitles
  10. }
  11. }
  12. public struct AirPlayPromptMatch: Equatable, Sendable {
  13. public var summary: String
  14. public var deviceName: String?
  15. public var acceptButtonTitle: String
  16. public var declineButtonTitle: String?
  17. public init(
  18. summary: String,
  19. deviceName: String?,
  20. acceptButtonTitle: String,
  21. declineButtonTitle: String?
  22. ) {
  23. self.summary = summary
  24. self.deviceName = deviceName
  25. self.acceptButtonTitle = acceptButtonTitle
  26. self.declineButtonTitle = declineButtonTitle
  27. }
  28. }
  29. public enum AirPlayPromptMatcher {
  30. public static let acceptTitles = [
  31. "accept", "allow", "ok", "接受", "允許", "好"
  32. ]
  33. public static let declineTitles = [
  34. "decline", "deny", "don't allow", "do not allow", "cancel", "拒絕", "不允許", "取消"
  35. ]
  36. private static let airPlayTerms = [
  37. "airplay", "air play", "隔空播放", "螢幕鏡像", "屏幕镜像"
  38. ]
  39. private static let requestPatterns = [
  40. #"would\s+like\s+to\s+airplay\s+to\s+this\s+mac"#,
  41. #"wants\s+to\s+airplay\s+to\s+this\s+mac"#,
  42. #"is\s+trying\s+to\s+airplay\s+to\s+this\s+mac"#,
  43. #"想要.{0,40}(?:airplay|隔空播放|螢幕鏡像|屏幕镜像).{0,40}(?:這台|这台|此)\s*mac"#,
  44. #"正在(?:嘗試|尝试).{0,40}(?:airplay|隔空播放|螢幕鏡像|屏幕镜像).{0,40}(?:這台|这台|此)\s*mac"#
  45. ]
  46. public static func match(_ candidate: PromptCandidate) -> AirPlayPromptMatch? {
  47. let texts = candidate.texts
  48. .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
  49. .filter { !$0.isEmpty }
  50. let combinedText = texts.joined(separator: " ")
  51. let normalizedText = normalize(combinedText)
  52. guard airPlayTerms.contains(where: { normalizedText.contains($0) }) else {
  53. return nil
  54. }
  55. guard isAirPlayConnectionRequest(combinedText) else {
  56. return nil
  57. }
  58. guard let acceptTitle = candidate.buttonTitles.first(where: isAcceptTitle) else {
  59. return nil
  60. }
  61. return AirPlayPromptMatch(
  62. summary: summarize(combinedText),
  63. deviceName: extractDeviceName(from: combinedText),
  64. acceptButtonTitle: acceptTitle,
  65. declineButtonTitle: candidate.buttonTitles.first(where: isDeclineTitle)
  66. )
  67. }
  68. public static func isAcceptTitle(_ title: String) -> Bool {
  69. let normalizedTitle = normalize(title)
  70. return acceptTitles.contains(normalizedTitle)
  71. }
  72. public static func isDeclineTitle(_ title: String) -> Bool {
  73. let normalizedTitle = normalize(title)
  74. return declineTitles.contains(normalizedTitle)
  75. }
  76. public static func extractDeviceName(from text: String) -> String? {
  77. let patterns = [
  78. #"["“](.+?)["”]\s+(?:would like|wants|is trying).{0,80}AirPlay"#,
  79. #"(.+?)\s+(?:would like|wants|is trying).{0,80}AirPlay"#,
  80. #"(?:Allow|Accept)\s+["“](.+?)["”].{0,80}AirPlay"#,
  81. #"AirPlay.{0,80}(?:from|for)\s+["“](.+?)["”]"#,
  82. #"["「](.+?)["」]\s*(?:想要|正在嘗試|正在尝试).{0,80}(?:AirPlay|隔空播放|螢幕鏡像|屏幕镜像)"#,
  83. #"(?:接受|允許)\s*["「](.+?)["」].{0,80}(?:AirPlay|隔空播放|螢幕鏡像|屏幕镜像)"#
  84. ]
  85. for pattern in patterns {
  86. if let match = firstCapture(in: text, pattern: pattern) {
  87. let cleaned = cleanupDeviceName(match)
  88. if !cleaned.isEmpty {
  89. return cleaned
  90. }
  91. }
  92. }
  93. return nil
  94. }
  95. private static func firstCapture(in text: String, pattern: String) -> String? {
  96. guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
  97. return nil
  98. }
  99. let range = NSRange(text.startIndex..<text.endIndex, in: text)
  100. guard
  101. let match = regex.firstMatch(in: text, options: [], range: range),
  102. match.numberOfRanges > 1,
  103. let captureRange = Range(match.range(at: 1), in: text)
  104. else {
  105. return nil
  106. }
  107. return String(text[captureRange])
  108. }
  109. private static func isAirPlayConnectionRequest(_ text: String) -> Bool {
  110. requestPatterns.contains { pattern in
  111. guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
  112. return false
  113. }
  114. let range = NSRange(text.startIndex..<text.endIndex, in: text)
  115. return regex.firstMatch(in: text, options: [], range: range) != nil
  116. }
  117. }
  118. private static func cleanupDeviceName(_ value: String) -> String {
  119. value
  120. .replacingOccurrences(of: "\n", with: " ")
  121. .replacingOccurrences(of: " ", with: " ")
  122. .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines.union(.punctuationCharacters))
  123. }
  124. private static func summarize(_ text: String) -> String {
  125. let cleaned = text
  126. .replacingOccurrences(of: "\n", with: " ")
  127. .replacingOccurrences(of: " ", with: " ")
  128. .trimmingCharacters(in: .whitespacesAndNewlines)
  129. if cleaned.count <= 240 {
  130. return cleaned
  131. }
  132. return String(cleaned.prefix(237)) + "..."
  133. }
  134. private static func normalize(_ text: String) -> String {
  135. text
  136. .trimmingCharacters(in: .whitespacesAndNewlines)
  137. .folding(options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], locale: .current)
  138. .lowercased()
  139. }
  140. }