Files
ios/MusicBridge/BridgeClient.swift
T

157 lines
7.5 KiB
Swift
Raw Normal View History

2026-09-10 09:11:55 +03:00
import Foundation
import Security
import CryptoKit
// One session per pinned PC. Never forward credentials to a redirected endpoint.
final class PinnedDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate {
let connection: Connection
init(_ connection: Connection) { self.connection = connection }
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
#if DEBUG
print("MusicBridge TLS: received server authentication challenge")
#endif
2026-09-10 09:11:55 +03:00
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
challenge.protectionSpace.host == connection.endpoint.host,
challenge.protectionSpace.port == (connection.endpoint.port ?? 443),
let trust = challenge.protectionSpace.serverTrust,
let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate], let leaf = chain.first else {
completionHandler(.cancelAuthenticationChallenge, nil); return
}
let digest = SHA256.hash(data: SecCertificateCopyData(leaf) as Data)
.map { String(format: "%02X", $0) }.joined()
guard digest == connection.fingerprint else {
#if DEBUG
print("MusicBridge TLS: fingerprint mismatch")
#endif
2026-09-10 09:11:55 +03:00
completionHandler(.cancelAuthenticationChallenge, nil); return
}
// Trust only this exact out-of-band certificate, while checking its validity dates.
SecTrustSetAnchorCertificates(trust, [leaf] as CFArray)
SecTrustSetAnchorCertificatesOnly(trust, true)
SecTrustSetPolicies(trust, SecPolicyCreateBasicX509())
var trustError: CFError?
guard SecTrustEvaluateWithError(trust, &trustError) else {
#if DEBUG
print("MusicBridge TLS: pinned certificate rejected: \(String(describing: trustError))")
#endif
2026-09-10 09:11:55 +03:00
completionHandler(.cancelAuthenticationChallenge, nil); return
}
completionHandler(.useCredential, URLCredential(trust: trust))
}
func urlSession(_ session: URLSession, task: URLSessionTask,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
urlSession(session, didReceive: challenge, completionHandler: completionHandler)
}
2026-09-10 09:11:55 +03:00
func urlSession(_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void) {
completionHandler(nil)
}
}
final class BridgeClient {
let connection: Connection
private let session: URLSession
private var socket: URLSessionWebSocketTask?
init(_ connection: Connection, timeout: TimeInterval = 10) {
2026-09-10 09:11:55 +03:00
self.connection = connection
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = timeout
config.timeoutIntervalForResource = timeout + 5
2026-09-10 09:11:55 +03:00
config.urlCache = nil
config.httpCookieStorage = nil
session = URLSession(configuration: config, delegate: PinnedDelegate(connection), delegateQueue: nil)
}
func close() { socket?.cancel(with: .goingAway, reason: nil); session.invalidateAndCancel() }
private func request(_ path: String, body: Data? = nil, authenticated: Bool = true) -> URLRequest {
var request = URLRequest(url: connection.endpoint.appendingPathComponent(path))
if authenticated, let token = connection.token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
if let body {
request.httpMethod = "POST"
request.httpBody = body
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
return request
}
private func data(_ request: URLRequest, limit: Int = 256 * 1024) async throws -> Data {
let (bytes, response) = try await session.bytes(for: request)
guard let response = response as? HTTPURLResponse else { throw BridgeError.message("Нет ответа от ПК.") }
if response.statusCode == 401 { throw BridgeError.unauthorized }
let success = (200...299).contains(response.statusCode)
let bodyLimit = success ? limit : min(limit, 16 * 1024)
2026-09-10 09:11:55 +03:00
var result = Data()
for try await byte in bytes {
guard result.count < bodyLimit else { throw BridgeError.message("Ответ ПК слишком большой.") }
2026-09-10 09:11:55 +03:00
result.append(byte)
}
guard success else {
struct Failure: Decodable { let code: String? }
let code = (try? JSONDecoder().decode(Failure.self, from: result))?.code
throw BridgeError.http(status: response.statusCode, code: code)
}
2026-09-10 09:11:55 +03:00
return result
}
func pair(code: String) async throws -> String {
let body = try JSONSerialization.data(withJSONObject: ["code": code, "deviceName": "MusicBridge iPhone"])
let response = try JSONDecoder().decode(PairResponse.self, from: await data(request("v1/pair", body: body, authenticated: false)))
guard response.success, let token = response.token, Connection.isFingerprint(token) else {
throw BridgeError.http(status: 403, code: response.code)
2026-09-10 09:11:55 +03:00
}
return token
}
func state() async throws -> MediaState {
try Self.validate(JSONDecoder().decode(MediaState.self, from: await data(request("v1/state"))))
}
static func validate(_ state: MediaState) throws -> MediaState {
guard state.protocolVersion == 1 else { throw BridgeError.message("Версия протокола ПК не поддерживается.") }
return state
}
func command(_ command: Command) async throws {
let response = try JSONDecoder().decode(CommandResponse.self, from: await data(request("v1/command", body: JSONEncoder().encode(command))))
guard response.success else { throw BridgeError.message(response.message) }
}
func artwork(_ id: String) async throws -> Data {
guard Connection.isFingerprint(id) else { throw BridgeError.message("Неверный ID обложки.") }
return try await data(request("v1/artwork/\(id)"), limit: 2 * 1024 * 1024)
}
func openEvents() {
var req = request("v1/events")
var url = URLComponents(url: req.url!, resolvingAgainstBaseURL: false)!
url.scheme = "wss"
req.url = url.url!
let socket = session.webSocketTask(with: req)
socket.maximumMessageSize = 256 * 1024
self.socket = socket
socket.resume()
}
func nextState() async throws -> MediaState {
guard let socket else { throw BridgeError.message("Соединение закрыто.") }
let message = try await socket.receive()
let payload: Data
switch message {
case .data(let data): payload = data
case .string(let string): payload = Data(string.utf8)
@unknown default: throw BridgeError.message("Неизвестный формат сообщения.")
}
let event = try JSONDecoder().decode(StateEvent.self, from: payload)
guard event.type == "state" else { throw BridgeError.message("Неизвестное событие.") }
return try Self.validate(event.state)
}
}