Handle interrupted pairing, artwork retries and revoked access reliably
This commit is contained in:
@@ -86,14 +86,18 @@ final class BridgeClient {
|
||||
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 }
|
||||
guard (200...299).contains(response.statusCode) else {
|
||||
throw BridgeError.message("ПК отклонил запрос (HTTP \(response.statusCode)). Проверьте код и состояние агента.")
|
||||
}
|
||||
let success = (200...299).contains(response.statusCode)
|
||||
let bodyLimit = success ? limit : min(limit, 16 * 1024)
|
||||
var result = Data()
|
||||
for try await byte in bytes {
|
||||
guard result.count < limit else { throw BridgeError.message("Ответ ПК слишком большой.") }
|
||||
guard result.count < bodyLimit else { throw BridgeError.message("Ответ ПК слишком большой.") }
|
||||
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)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -101,7 +105,7 @@ final class BridgeClient {
|
||||
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.message("Сопряжение отклонено: \(response.code)")
|
||||
throw BridgeError.http(status: 403, code: response.code)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
// Injectable persistence keeps lifecycle tests away from the user's real Keychain.
|
||||
struct ConnectionStorage {
|
||||
var load: () throws -> Connection?
|
||||
var save: (Connection) throws -> Void
|
||||
var delete: () throws -> Void
|
||||
static let keychain = Self(load: KeychainStore.load, save: KeychainStore.save, delete: KeychainStore.delete)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ struct ContentView: View {
|
||||
PlayerView(store: store)
|
||||
Text(store.connection?.endpoint.host ?? "")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
if !store.connected {
|
||||
if !store.connected && !store.needsPairing {
|
||||
Button("Подключиться снова") { store.resume() }
|
||||
}
|
||||
Button("Забыть компьютер", role: .destructive) { showForget = true }
|
||||
@@ -24,6 +24,7 @@ struct ContentView: View {
|
||||
}
|
||||
if let error = store.error {
|
||||
Text(error).font(.callout).foregroundStyle(.red).textSelection(.enabled)
|
||||
Button("Скрыть сообщение") { store.error = nil }.font(.footnote)
|
||||
}
|
||||
}.padding(24)
|
||||
}
|
||||
@@ -152,6 +153,10 @@ private struct PlayerView: View {
|
||||
}
|
||||
}.frame(height: 270).clipShape(RoundedRectangle(cornerRadius: 24))
|
||||
.accessibilityLabel("Обложка трека")
|
||||
if let error = store.artworkError {
|
||||
Text(error).font(.footnote).foregroundStyle(.secondary)
|
||||
Button("Повторить загрузку обложки") { store.retryArtwork() }.disabled(!store.connected)
|
||||
}
|
||||
if let state = store.state {
|
||||
Text(state.hasSession ? (state.title.isEmpty ? "Без названия" : state.title) : "Откройте плеер на ПК")
|
||||
.font(.title2.bold()).multilineTextAlignment(.center)
|
||||
|
||||
@@ -3,12 +3,43 @@ 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 {
|
||||
|
||||
+119
-22
@@ -10,6 +10,8 @@ final class PlayerStore: ObservableObject {
|
||||
@Published var connected = false
|
||||
@Published var busy = false
|
||||
@Published var pairing = false
|
||||
@Published var needsPairing = false
|
||||
@Published var artworkError: String?
|
||||
@Published var incomingInvitation: String?
|
||||
let discovery = DiscoveryStore()
|
||||
private(set) var receivedAt = Date()
|
||||
@@ -19,34 +21,67 @@ final class PlayerStore: ObservableObject {
|
||||
private var artworkID: String?
|
||||
private var generation = UUID()
|
||||
private var active = false
|
||||
private var pairingID: UUID?
|
||||
private var pairingClient: BridgeClient?
|
||||
private let storage: ConnectionStorage
|
||||
private let retryUnit: UInt64
|
||||
private let artworkRetryUnit: UInt64
|
||||
private let discoveryEnabled: Bool
|
||||
|
||||
init() {
|
||||
do { connection = try KeychainStore.load() }
|
||||
init(storage: ConnectionStorage = .keychain, retryUnit: UInt64 = 1_000_000_000,
|
||||
artworkRetryUnit: UInt64 = 2_000_000_000, discoveryEnabled: Bool = true) {
|
||||
self.storage = storage
|
||||
self.retryUnit = retryUnit
|
||||
self.artworkRetryUnit = artworkRetryUnit
|
||||
self.discoveryEnabled = discoveryEnabled
|
||||
do { connection = try storage.load() }
|
||||
catch { self.error = error.localizedDescription }
|
||||
}
|
||||
|
||||
func pair(endpoint: String, fingerprint: String, code: String) async {
|
||||
guard !pairing else { return }
|
||||
guard active, !pairing else { return }
|
||||
let operation = UUID()
|
||||
pairingID = operation
|
||||
pairing = true
|
||||
defer { pairing = false }
|
||||
defer {
|
||||
if pairingID == operation { pairing = false; pairingID = nil; pairingClient = nil }
|
||||
}
|
||||
error = nil
|
||||
do {
|
||||
guard Invitation.validCode(code) else { throw BridgeError.message("Нужен восьмизначный код с ПК.") }
|
||||
var saved = try Connection(endpoint: endpoint, fingerprint: fingerprint)
|
||||
let pairingClient = BridgeClient(saved)
|
||||
self.pairingClient = pairingClient
|
||||
defer { pairingClient.close() }
|
||||
saved.token = try await pairingClient.pair(code: code)
|
||||
do { try KeychainStore.save(saved) }
|
||||
guard pairingID == operation, active else { return }
|
||||
do { try storage.save(saved) }
|
||||
catch { throw BridgeError.message("ПК принял сопряжение, но Keychain не сохранил его. Удалите запись MusicBridge iPhone на ПК и повторите сопряжение.") }
|
||||
connection = saved
|
||||
needsPairing = false
|
||||
resume()
|
||||
} catch { self.error = error.localizedDescription }
|
||||
} catch {
|
||||
guard pairingID == operation else { return }
|
||||
self.error = BridgeError.describe(error)
|
||||
}
|
||||
}
|
||||
|
||||
func setActive(_ active: Bool) {
|
||||
guard self.active != active else { return }
|
||||
self.active = active
|
||||
if active { discovery.start(); resume() }
|
||||
else { discovery.stop(); stop(); status = "Соединение приостановлено" }
|
||||
if active {
|
||||
// Retry loading if Keychain was locked when the app was created.
|
||||
if connection == nil {
|
||||
do { connection = try storage.load() }
|
||||
catch { error = BridgeError.describe(error) }
|
||||
}
|
||||
if discoveryEnabled { discovery.start() }
|
||||
resume()
|
||||
} else {
|
||||
cancelPairing()
|
||||
discovery.stop(); stop()
|
||||
status = needsPairing ? "Нужно повторное сопряжение" : "Соединение приостановлено"
|
||||
}
|
||||
}
|
||||
|
||||
func importInvitation(_ text: String) {
|
||||
@@ -60,6 +95,7 @@ final class PlayerStore: ObservableObject {
|
||||
func resume() {
|
||||
stop()
|
||||
guard active else { return }
|
||||
guard !needsPairing else { status = "Нужно повторное сопряжение"; return }
|
||||
guard let connection else { status = "Подключите компьютер"; return }
|
||||
let current = generation
|
||||
runner = Task { [weak self] in
|
||||
@@ -81,9 +117,10 @@ final class PlayerStore: ObservableObject {
|
||||
guard self.generation == current else { client.close(); return }
|
||||
// Persist a discovered address only after pinned, authenticated HTTPS succeeds.
|
||||
if candidate.endpoint != saved.endpoint {
|
||||
try KeychainStore.save(candidate)
|
||||
try self.storage.save(candidate)
|
||||
self.connection = candidate
|
||||
}
|
||||
self.error = nil
|
||||
self.accept(initial, client: client)
|
||||
client.openEvents()
|
||||
// Abort a silent/stalled stream even if the OS has not detected a lost Wi-Fi link.
|
||||
@@ -110,16 +147,16 @@ final class PlayerStore: ObservableObject {
|
||||
self.artworkTask?.cancel()
|
||||
self.artwork = nil
|
||||
self.artworkID = nil
|
||||
self.artworkError = nil
|
||||
if case BridgeError.unauthorized = error {
|
||||
self.status = "Нужно повторное сопряжение"
|
||||
self.error = error.localizedDescription
|
||||
self.revokeAccess()
|
||||
return
|
||||
}
|
||||
self.status = "ПК недоступен. Повтор через \(delay) с"
|
||||
self.error = error.localizedDescription
|
||||
self.error = BridgeError.describe(error)
|
||||
attempt += 1
|
||||
}
|
||||
do { try await Task.sleep(nanoseconds: delay * 1_000_000_000) } catch { return }
|
||||
do { try await Task.sleep(nanoseconds: delay * self.retryUnit) } catch { return }
|
||||
delay = min(delay * 2, 16)
|
||||
}
|
||||
}
|
||||
@@ -130,20 +167,54 @@ final class PlayerStore: ObservableObject {
|
||||
receivedAt = Date()
|
||||
connected = true
|
||||
status = "Подключено"
|
||||
error = nil
|
||||
guard artworkID != snapshot.artworkId else { return }
|
||||
artworkID = snapshot.artworkId
|
||||
artworkTask?.cancel()
|
||||
artwork = nil
|
||||
artworkError = nil
|
||||
guard let id = snapshot.artworkId else { return }
|
||||
let current = generation
|
||||
artworkTask = Task { [weak self] in
|
||||
guard let data = try? await client.artwork(id), !Task.isCancelled,
|
||||
let self, self.generation == current, self.artworkID == id else { return }
|
||||
self.artwork = UIImage(data: data)
|
||||
for attempt in 0..<4 {
|
||||
do {
|
||||
try Task.checkCancellation()
|
||||
let data = try await client.artwork(id)
|
||||
guard !Task.isCancelled, let self, self.generation == current, self.artworkID == id else { return }
|
||||
guard let image = UIImage(data: data) else { throw BridgeError.message("ПК прислал повреждённую обложку.") }
|
||||
self.artwork = image
|
||||
self.artworkError = nil
|
||||
return
|
||||
} catch {
|
||||
guard !Task.isCancelled, let self, self.generation == current, self.artworkID == id else { return }
|
||||
if case BridgeError.unauthorized = error { self.revokeAccess(); return }
|
||||
self.artworkError = attempt < 3 ? "Обложка пока недоступна. Повторяем загрузку…" : "Не удалось загрузить обложку. Можно повторить вручную."
|
||||
// A 404 often means the song changed between the state and image requests.
|
||||
if case BridgeError.http(status: 404, code: _) = error {
|
||||
do {
|
||||
let fresh = try await client.state()
|
||||
guard !Task.isCancelled, self.generation == current, self.artworkID == id else { return }
|
||||
self.accept(fresh, client: client)
|
||||
if self.artworkID != id { return }
|
||||
} catch {
|
||||
guard !Task.isCancelled, self.generation == current else { return }
|
||||
if case BridgeError.unauthorized = error { self.revokeAccess(); return }
|
||||
}
|
||||
}
|
||||
if attempt < 3 {
|
||||
do { try await Task.sleep(nanoseconds: self.artworkRetryUnit * UInt64(1 << attempt)) }
|
||||
catch { return }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func retryArtwork() {
|
||||
guard connected, let state, let client else { return }
|
||||
artworkID = nil
|
||||
accept(state, client: client)
|
||||
}
|
||||
|
||||
func send(_ type: String, value: Double? = nil) async {
|
||||
guard connected, !busy, let client else { return }
|
||||
let current = generation
|
||||
@@ -152,20 +223,30 @@ final class PlayerStore: ObservableObject {
|
||||
do {
|
||||
// Commands are never retried: their IDs are not idempotency keys.
|
||||
try await client.command(Command(type: type, value: value))
|
||||
let fresh = try await client.state()
|
||||
guard generation == current else { return }
|
||||
accept(fresh, client: client)
|
||||
self.error = nil
|
||||
do {
|
||||
let fresh = try await client.state()
|
||||
guard generation == current else { return }
|
||||
accept(fresh, client: client)
|
||||
} catch {
|
||||
guard generation == current else { return }
|
||||
if case BridgeError.unauthorized = error { revokeAccess(); return }
|
||||
self.error = "Команда выполнена, но состояние пока не обновилось. " + BridgeError.describe(error)
|
||||
}
|
||||
} catch {
|
||||
guard generation == current else { return }
|
||||
self.error = error.localizedDescription
|
||||
if case BridgeError.unauthorized = error { stop(); status = "Нужно повторное сопряжение" }
|
||||
if case BridgeError.unauthorized = error { revokeAccess(); return }
|
||||
self.error = (error is URLError ? "Не удалось подтвердить выполнение команды. Перед повтором проверьте плеер. " : "") + BridgeError.describe(error)
|
||||
}
|
||||
}
|
||||
|
||||
func forget() {
|
||||
do { try KeychainStore.delete() }
|
||||
do { try storage.delete() }
|
||||
catch { self.error = error.localizedDescription; return }
|
||||
stop()
|
||||
cancelPairing()
|
||||
needsPairing = false
|
||||
connection = nil
|
||||
status = "Подключите компьютер"
|
||||
error = nil
|
||||
@@ -183,5 +264,21 @@ final class PlayerStore: ObservableObject {
|
||||
state = nil
|
||||
artwork = nil
|
||||
artworkID = nil
|
||||
artworkError = nil
|
||||
}
|
||||
|
||||
private func revokeAccess() {
|
||||
stop()
|
||||
needsPairing = true
|
||||
status = "Нужно повторное сопряжение"
|
||||
error = BridgeError.unauthorized.localizedDescription
|
||||
}
|
||||
|
||||
private func cancelPairing() {
|
||||
guard pairingID != nil else { return }
|
||||
pairingID = nil
|
||||
pairingClient?.close(); pairingClient = nil
|
||||
pairing = false
|
||||
error = "Сопряжение прервано. Если запись устройства появилась на ПК, удалите её перед новой попыткой."
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user