150 lines
8.3 KiB
Swift
150 lines
8.3 KiB
Swift
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: Decodable {
|
|
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
|
|
}
|