import Foundation enum BridgeError: LocalizedError { case message(String) case unauthorized case http(status: Int, code: String?) var errorDescription: String? { switch self { case .message(let text): return text case .unauthorized: return "Доступ отозван. Выполните сопряжение заново." case .http(let status, let code): switch code { case "pairing_closed": return "Код истёк или сопряжение закрыто. Откройте новый QR на ПК." case "invalid_code": return "Неверный код. Проверьте восемь цифр на экране ПК." case "device_limit": return "На ПК уже сохранено восемь устройств. Удалите ненужное устройство в агенте." case "storage_failed": return "ПК не смог сохранить сопряжение. Проверьте сообщение в Windows-агенте." case "invalid_name": return "ПК отклонил имя устройства. Обновите приложение." default: if (300...399).contains(status) { return "ПК перенаправляет запрос на другой адрес. Проверьте адрес MusicBridge." } if status == 404 { return "Данные на ПК уже изменились. Повторите загрузку." } if status == 429 || status == 503 { return "ПК временно занят. Повторите попытку немного позже." } return "ПК отклонил запрос (HTTP \(status))." } } } static func describe(_ error: Error) -> String { if let error = error as? URLError { switch error.code { case .timedOut: return "ПК не ответил вовремя. Проверьте, что агент запущен и компьютер не спит." case .notConnectedToInternet, .networkConnectionLost, .cannotConnectToHost, .cannotFindHost: return "Нет связи с ПК. Проверьте Wi-Fi, LAN-режим агента и доступ MusicBridge к локальной сети." case .serverCertificateUntrusted, .serverCertificateHasBadDate, .serverCertificateHasUnknownRoot, .serverCertificateNotYetValid, .secureConnectionFailed, .userCancelledAuthentication: return "Не удалось подтвердить сертификат ПК. Сверьте отпечаток и дату на устройствах; при смене сертификата выполните сопряжение заново." case .cancelled: return "Соединение прервано." default: break } } if error is DecodingError { return "ПК прислал данные неподдерживаемого формата. Проверьте версии агента и приложения." } return error.localizedDescription } } struct Connection: Codable { let endpoint: URL let fingerprint: String var token: String? init(endpoint: String, fingerprint: String, token: String? = nil) throws { guard let url = URL(string: endpoint), let parts = URLComponents(url: url, resolvingAgainstBaseURL: false), parts.scheme == "https", parts.user == nil, parts.password == nil, parts.query == nil, parts.fragment == nil, parts.path.isEmpty || parts.path == "/", let host = parts.host, Self.isPrivateIPv4(host), parts.port == nil || (1...65535).contains(parts.port!) else { throw BridgeError.message("Укажите HTTPS-адрес ПК в домашней сети, например https://192.168.1.10:8765.") } guard Self.isFingerprint(fingerprint) else { throw BridgeError.message("Отпечаток сертификата должен содержать 64 шестнадцатеричных символа.") } self.endpoint = url self.fingerprint = fingerprint.uppercased() self.token = token } static func isFingerprint(_ value: String) -> Bool { value.utf8.count == 64 && value.utf8.allSatisfy { (48...57).contains($0) || (65...70).contains($0) || (97...102).contains($0) } } static func isPrivateIPv4(_ host: String) -> Bool { let parts = host.split(separator: ".", omittingEmptySubsequences: false) guard parts.count == 4 else { return false } let bytes = parts.compactMap { part -> UInt8? in guard !part.isEmpty, part.utf8.allSatisfy({ (48...57).contains($0) }), part.count == 1 || part.first != "0" else { return nil } return UInt8(part) } guard bytes.count == 4 else { return false } return bytes[0] == 10 || (bytes[0] == 172 && (16...31).contains(bytes[1])) || (bytes[0] == 192 && bytes[1] == 168) } } struct Invitation: Decodable { let version: Int let endpoint: String let certificateSha256: String let code: String let expiresAt: String static func parse(_ text: String, now: Date = Date()) throws -> Invitation { guard text.utf8.count <= 8192, let url = URLComponents(string: text), url.scheme == "musicbridge", url.host == "pair", url.user == nil, url.password == nil, url.port == nil, url.path.isEmpty, url.fragment == nil, let items = url.queryItems, items.count == 1, items[0].name == "data", let encoded = items[0].value else { throw BridgeError.message("Неверная строка сопряжения.") } var base64 = encoded.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4) guard let data = Data(base64Encoded: base64) else { throw BridgeError.message("Повреждённые данные QR.") } let invitation: Invitation do { invitation = try JSONDecoder().decode(Invitation.self, from: data) } catch { throw BridgeError.message("QR не содержит корректного приглашения MusicBridge. Откройте новый QR в агенте на ПК.") } let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] var expiry = formatter.date(from: invitation.expiresAt) if expiry == nil { formatter.formatOptions = [.withInternetDateTime] expiry = formatter.date(from: invitation.expiresAt) } guard invitation.version == 1, validCode(invitation.code), let expiry, expiry > now else { throw BridgeError.message("Код истёк или версия QR не поддерживается. Откройте новый QR на ПК.") } _ = try Connection(endpoint: invitation.endpoint, fingerprint: invitation.certificateSha256) return invitation } static func validCode(_ code: String) -> Bool { code.utf8.count == 8 && code.utf8.allSatisfy { (48...57).contains($0) } } } struct Capabilities: Codable { let play, pause, toggle, next, previous, seek: Bool } struct MediaState: Decodable { let protocolVersion: Int let hasSession: Bool let title, artist, source, playbackStatus: String let positionSeconds, startSeconds, endSeconds, minSeekSeconds, maxSeekSeconds, playbackRate: Double let capabilities: Capabilities let volume: Double? let muted: Bool? let mediaError, volumeError, artworkId, artworkError: String? func position(elapsed: TimeInterval) -> Double { let projected = positionSeconds + (playbackStatus == "Playing" ? max(0, elapsed) * playbackRate : 0) return min(max(startSeconds, projected), max(startSeconds, endSeconds)) } } struct StateEvent: Decodable { let type: String; let state: MediaState } struct PairResponse: Decodable { let success: Bool; let token: String?; let code: String } struct CommandResponse: Decodable { let success: Bool; let code, message: String } struct Command: Encodable { let type: String let value: Double? let id = UUID().uuidString }