117 lines
5.3 KiB
Swift
117 lines
5.3 KiB
Swift
|
|
import Foundation
|
||
|
|
|
||
|
|
enum BridgeError: LocalizedError {
|
||
|
|
case message(String)
|
||
|
|
case unauthorized
|
||
|
|
var errorDescription: String? {
|
||
|
|
switch self {
|
||
|
|
case .message(let text): return text
|
||
|
|
case .unauthorized: return "Доступ отозван. Выполните сопряжение заново."
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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 = try JSONDecoder().decode(Invitation.self, from: data)
|
||
|
|
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
|
||
|
|
}
|