Files
ios/MusicBridge/Models.swift
T

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
}