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.. 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.. 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() } }