Handle interrupted pairing, artwork retries and revoked access reliably

This commit is contained in:
2026-09-12 21:41:37 +03:00
parent afa771198d
commit c461c26f7f
9 changed files with 411 additions and 46 deletions
+119 -22
View File
@@ -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 = "Сопряжение прервано. Если запись устройства появилась на ПК, удалите её перед новой попыткой."
}
}