diff --git a/MusicBridge.xcodeproj/project.pbxproj b/MusicBridge.xcodeproj/project.pbxproj index 280f294..0b510d1 100644 --- a/MusicBridge.xcodeproj/project.pbxproj +++ b/MusicBridge.xcodeproj/project.pbxproj @@ -15,6 +15,16 @@ "isa" = "PBXBuildFile"; "fileRef" = "065A9D559DC80AAF876DA757"; }; + "763FA6A137E5E94274AC56A3" = { + "isa" = "PBXFileReference"; + "lastKnownFileType" = "sourcecode.swift"; + "path" = "ConnectionStorage.swift"; + "sourceTree" = ""; + }; + "40D2920E2C996D62B7190BA4" = { + "isa" = "PBXBuildFile"; + "fileRef" = "763FA6A137E5E94274AC56A3"; + }; "971C0AE92F167DE7AB145086" = { "isa" = "PBXFileReference"; "lastKnownFileType" = "sourcecode.swift"; @@ -89,7 +99,7 @@ "isa" = "PBXGroup"; "path" = "MusicBridge"; "sourceTree" = ""; - "children" = ("065A9D559DC80AAF876DA757", "971C0AE92F167DE7AB145086", "5F6F408829AB46D3B85FB911", "76FB6BFFC478599567424755", "124CF3F5AC075D1D6042DF64", "ADF0CD56882201BD3AC1581B", "9C432A0DA0BEC173839EBF86", "7148B52E915554F67E875C26",); + "children" = ("065A9D559DC80AAF876DA757", "763FA6A137E5E94274AC56A3", "971C0AE92F167DE7AB145086", "5F6F408829AB46D3B85FB911", "76FB6BFFC478599567424755", "124CF3F5AC075D1D6042DF64", "ADF0CD56882201BD3AC1581B", "9C432A0DA0BEC173839EBF86", "7148B52E915554F67E875C26",); }; "C9F705D432CA45B6056BC572" = { "isa" = "PBXFileReference"; @@ -111,6 +121,16 @@ "isa" = "PBXBuildFile"; "fileRef" = "ACD0393DCDDDF044F2966340"; }; + "464F6C65BC2FAB6BCCC77F46" = { + "isa" = "PBXFileReference"; + "lastKnownFileType" = "sourcecode.swift"; + "path" = "ResilienceTests.swift"; + "sourceTree" = ""; + }; + "EFDB280E0B23F7E2A246BC89" = { + "isa" = "PBXBuildFile"; + "fileRef" = "464F6C65BC2FAB6BCCC77F46"; + }; "8BED860F1BD59C500D215E8D" = { "isa" = "PBXFileReference"; "lastKnownFileType" = "sourcecode.swift"; @@ -125,7 +145,7 @@ "isa" = "PBXGroup"; "path" = "MusicBridgeTests"; "sourceTree" = ""; - "children" = ("C9F705D432CA45B6056BC572", "ACD0393DCDDDF044F2966340", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",); + "children" = ("C9F705D432CA45B6056BC572", "ACD0393DCDDDF044F2966340", "464F6C65BC2FAB6BCCC77F46", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",); }; "F16D05EC6B29248D2C61ADB1" = { "isa" = "PBXFileReference"; @@ -157,7 +177,7 @@ "72B74C8A2143F64FD53D71E5" = { "isa" = "PBXSourcesBuildPhase"; "buildActionMask" = 2147483647; - "files" = ("82F1C33CE4F8B8F641FF0461", "16EA630DE1FE9F0A16580C9D", "842E87CD23DCB5BBCA4340F6", "DA06B3B12E52FB69A6A34887", "DEDCCD95A140162B6D654871", "48E2FDCA11A6694953C86E6D", "47693934E8A974162CF08A38", "BE808A0D13A16FE67D32B7FB",); + "files" = ("82F1C33CE4F8B8F641FF0461", "40D2920E2C996D62B7190BA4", "16EA630DE1FE9F0A16580C9D", "842E87CD23DCB5BBCA4340F6", "DA06B3B12E52FB69A6A34887", "DEDCCD95A140162B6D654871", "48E2FDCA11A6694953C86E6D", "47693934E8A974162CF08A38", "BE808A0D13A16FE67D32B7FB",); "runOnlyForDeploymentPostprocessing" = 0; }; "734C4650C7A5604092763C32" = { @@ -186,8 +206,8 @@ "CODE_SIGN_STYLE" = "Automatic"; "GENERATE_INFOPLIST_FILE" = "NO"; "INFOPLIST_FILE" = "MusicBridge/Info.plist"; - "CURRENT_PROJECT_VERSION" = "3"; - "MARKETING_VERSION" = "0.2.0"; + "CURRENT_PROJECT_VERSION" = "4"; + "MARKETING_VERSION" = "0.3.0"; "SWIFT_OPTIMIZATION_LEVEL" = "-Onone"; "ENABLE_TESTABILITY" = "YES"; "SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG"; @@ -207,8 +227,8 @@ "CODE_SIGN_STYLE" = "Automatic"; "GENERATE_INFOPLIST_FILE" = "NO"; "INFOPLIST_FILE" = "MusicBridge/Info.plist"; - "CURRENT_PROJECT_VERSION" = "3"; - "MARKETING_VERSION" = "0.2.0"; + "CURRENT_PROJECT_VERSION" = "4"; + "MARKETING_VERSION" = "0.3.0"; "SWIFT_OPTIMIZATION_LEVEL" = "-O"; }; }; @@ -239,7 +259,7 @@ "AB453A3F0E1490D48374A69A" = { "isa" = "PBXSourcesBuildPhase"; "buildActionMask" = 2147483647; - "files" = ("1DD7BB0437E9F53586831F22", "17E248E58CB43C680B48DD51", "DC1A5B0B94F4C98F1CA36676",); + "files" = ("1DD7BB0437E9F53586831F22", "17E248E58CB43C680B48DD51", "EFDB280E0B23F7E2A246BC89", "DC1A5B0B94F4C98F1CA36676",); "runOnlyForDeploymentPostprocessing" = 0; }; "9AA2A4C344B5C474AF5D336C" = { diff --git a/MusicBridge/BridgeClient.swift b/MusicBridge/BridgeClient.swift index 1731f69..175529d 100644 --- a/MusicBridge/BridgeClient.swift +++ b/MusicBridge/BridgeClient.swift @@ -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 } diff --git a/MusicBridge/ConnectionStorage.swift b/MusicBridge/ConnectionStorage.swift new file mode 100644 index 0000000..2ecfe22 --- /dev/null +++ b/MusicBridge/ConnectionStorage.swift @@ -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) +} diff --git a/MusicBridge/ContentView.swift b/MusicBridge/ContentView.swift index 80df7bb..0bf7fb7 100644 --- a/MusicBridge/ContentView.swift +++ b/MusicBridge/ContentView.swift @@ -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) diff --git a/MusicBridge/Models.swift b/MusicBridge/Models.swift index 7e5b548..9d40d44 100644 --- a/MusicBridge/Models.swift +++ b/MusicBridge/Models.swift @@ -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 { diff --git a/MusicBridge/PlayerStore.swift b/MusicBridge/PlayerStore.swift index 685e72a..04d9354 100644 --- a/MusicBridge/PlayerStore.swift +++ b/MusicBridge/PlayerStore.swift @@ -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 = "Сопряжение прервано. Если запись устройства появилась на ПК, удалите её перед новой попыткой." } } diff --git a/MusicBridgeTests/ResilienceTests.swift b/MusicBridgeTests/ResilienceTests.swift new file mode 100644 index 0000000..b60176a --- /dev/null +++ b/MusicBridgeTests/ResilienceTests.swift @@ -0,0 +1,125 @@ +import XCTest +@testable import MusicBridge + +@MainActor +final class ResilienceTests: XCTestCase { + private func connection(_ token: String) throws -> Connection { + let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "tls-fixture", withExtension: "json")) + let fixtures = try JSONDecoder().decode([String: [String: String]].self, from: Data(contentsOf: url)) + guard let fixture = fixtures["valid"] else { throw XCTSkip("Start tools/tls_fixture.py first.") } + return try Connection(endpoint: XCTUnwrap(fixture["endpoint"]), fingerprint: XCTUnwrap(fixture["fingerprint"]), token: token) + } + + private func store(_ token: String) throws -> PlayerStore { + let saved = try connection(token) + return PlayerStore(storage: ConnectionStorage(load: { saved }, save: { _ in }, delete: {}), + retryUnit: 20_000_000, artworkRetryUnit: 20_000_000, discoveryEnabled: false) + } + + private func eventually(_ condition: () -> Bool, file: StaticString = #filePath, line: UInt = #line) async throws { + for _ in 0..<200 { + if condition() { return } + try await Task.sleep(nanoseconds: 20_000_000) + } + XCTFail("Condition did not become true", file: file, line: line) + } + + func testRecoversAfterTemporaryServerFailureAndForegroundReturn() async throws { + let store = try store("recover") + defer { store.setActive(false) } + store.setActive(true) + try await eventually { store.connected } + store.setActive(false) + XCTAssertFalse(store.connected) + XCTAssertNil(store.state) + store.setActive(true) + try await eventually { store.connected } + } + + func testRevokedAccessStopsRetriesAndSurvivesForegroundReturn() async throws { + let store = try store("revoked") + defer { store.setActive(false) } + store.setActive(true) + try await eventually { store.needsPairing } + XCTAssertFalse(store.connected) + store.setActive(false); store.setActive(true) + try await Task.sleep(nanoseconds: 100_000_000) + XCTAssertEqual(store.status, "Нужно повторное сопряжение") + XCTAssertTrue(store.needsPairing) + XCTAssertFalse(store.connected) + } + + func testArtworkRetriesAfter404WithoutTrackChange() async throws { + let store = try store("artwork") + defer { store.setActive(false) } + store.setActive(true) + try await eventually { store.artwork != nil } + XCTAssertEqual(store.state?.artworkId, String(repeating: "A", count: 64)) + XCTAssertNil(store.artworkError) + } + + func testArtworkFailureStopsAtBoundedRetryAndAllowsManualRetry() async throws { + let store = try store("artwork-fail") + defer { store.setActive(false) } + store.setActive(true) + try await eventually { store.artworkError?.contains("вручную") == true } + XCTAssertTrue(store.connected) + store.retryArtwork() + try await eventually { store.artworkError?.contains("Повторяем") == true } + store.setActive(false) + XCTAssertNil(store.artworkError) + } + + func testLateArtworkDoesNotReplaceNewTrackImage() async throws { + let store = try store("late-art") + defer { store.setActive(false) } + store.setActive(true) + try await eventually { store.artwork?.size.width == 2 } + try await Task.sleep(nanoseconds: 600_000_000) + XCTAssertEqual(store.state?.artworkId, String(repeating: "B", count: 64)) + XCTAssertEqual(store.artwork?.size.width, 2) + } + + func testStreamUpdatesDoNotEraseRejectedCommandMessage() async throws { + let store = try store("command-reject") + defer { store.setActive(false) } + store.setActive(true) + try await eventually { store.connected } + await store.send("next") + try await Task.sleep(nanoseconds: 200_000_000) + XCTAssertEqual(store.error, "Плеер не поддерживает эту команду.") + XCTAssertFalse(store.busy) + } + + func testInterruptedPairingDoesNotSaveLateResponse() async throws { + let target = try connection("") + var saved = false + let store = PlayerStore(storage: ConnectionStorage(load: { nil }, save: { _ in saved = true }, delete: {}), discoveryEnabled: false) + store.setActive(true) + let task = Task { await store.pair(endpoint: target.endpoint.absoluteString, fingerprint: target.fingerprint, code: "33333333") } + try await eventually { store.pairing } + try await Task.sleep(nanoseconds: 80_000_000) + store.setActive(false) + await task.value + XCTAssertFalse(saved) + XCTAssertNil(store.connection) + XCTAssertFalse(store.pairing) + XCTAssertTrue(store.error?.contains("прервано") == true) + } + + func testRedirectIsRejectedAndPairingServerCodesArePreserved() async throws { + let client = BridgeClient(try connection("redirect")) + defer { client.close() } + do { _ = try await client.state(); XCTFail("Followed redirect") } + catch { + guard case BridgeError.http(status: 302, code: _) = error else { return XCTFail("Unexpected error: \(error)") } + } + for (code, expected) in [("11111111", "invalid_code"), ("22222222", "pairing_closed")] { + do { _ = try await client.pair(code: code); XCTFail("Accepted invalid pairing") } + catch { + guard case BridgeError.http(status: 403, code: let actual) = error else { return XCTFail("Unexpected error: \(error)") } + XCTAssertEqual(actual, expected) + } + } + } +} diff --git a/tools/generate_project.py b/tools/generate_project.py index c8a7fa3..1c1c494 100644 --- a/tools/generate_project.py +++ b/tools/generate_project.py @@ -61,7 +61,7 @@ for name, sources, is_test in [("MusicBridge", app_sources, False), ("MusicBridg proxy = add("test-proxy", "PBXContainerItemProxy", containerPortal=uid("project"), proxyType=1, remoteGlobalIDString=uid("MusicBridgetarget"), remoteInfo="MusicBridge") deps = [add("test-dependency", "PBXTargetDependency", target=uid("MusicBridgetarget"), targetProxy=proxy)] else: - settings.update(INFOPLIST_FILE="MusicBridge/Info.plist", CURRENT_PROJECT_VERSION="3", MARKETING_VERSION="0.2.0") + settings.update(INFOPLIST_FILE="MusicBridge/Info.plist", CURRENT_PROJECT_VERSION="4", MARKETING_VERSION="0.3.0") targets.append(add(name + "target", "PBXNativeTarget", name=name, productName=name, productReference=product, productType="com.apple.product-type.bundle.unit-test" if is_test else "com.apple.product-type.application", buildConfigurationList=configurations(name, settings), buildPhases=phases, buildRules=[], dependencies=deps)) diff --git a/tools/tls_fixture.py b/tools/tls_fixture.py index f929abe..a4e3504 100644 --- a/tools/tls_fixture.py +++ b/tools/tls_fixture.py @@ -11,6 +11,8 @@ import ssl import struct import subprocess import threading +import time +import zlib root = pathlib.Path(__file__).resolve().parents[1] work = root / "build/tls" @@ -34,12 +36,83 @@ extendedKeyUsage = serverAuth subjectAltName = DNS:localhost,IP:127.0.0.1 """) state = json.loads((root / "MusicBridgeTests/state-v1.json").read_text()) +counts = {} +counts_lock = threading.Lock() + + +def count(token, path): + with counts_lock: + key = (token, path) + counts[key] = counts.get(key, 0) + 1 + return counts[key] + + +def png(size): + def chunk(kind, data): + return struct.pack("!I", len(data)) + kind + data + struct.pack("!I", zlib.crc32(kind + data)) + return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack("!IIBBBBB", size, size, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress((b"\x00" + b"\x00\x80\xff" * size) * size)) + chunk(b"IEND", b"")) class Handler(http.server.BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" + def reply(self, status, body): + payload = json.dumps(body).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def snapshot(self, token): + result = dict(state) + result["artworkId"] = "A" * 64 if token in ("artwork", "artwork-fail", "late-art") else None + return result + + def do_POST(self): + body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", "0")))) + if self.path == "/v1/pair": + code = body.get("code") + if code == "33333333": + time.sleep(0.5) + self.reply(200, {"success": True, "code": "ok", "token": "A" * 64}) + else: + error = "pairing_closed" if code == "22222222" else "invalid_code" + self.reply(403, {"success": False, "code": error}) + else: + self.reply(200, {"success": False, "code": "unsupported", "message": "Плеер не поддерживает эту команду."}) + def do_GET(self): + token = self.headers.get("Authorization", "").removeprefix("Bearer ") + number = count(token, self.path) + if token == "revoked": + self.reply(401, {}) + return + if token == "redirect" and self.path == "/v1/state": + self.send_response(302) + self.send_header("Location", "/redirect-target") + self.send_header("Content-Length", "0") + self.end_headers() + return + if token == "recover" and self.path == "/v1/state" and number == 1: + self.reply(503, {}) + return + if self.path.startswith("/v1/artwork/"): + if token == "artwork-fail" or (token == "artwork" and number == 1): + self.reply(404, {}) + return + size = 1 + if token == "late-art": + if self.path.endswith("A" * 64): time.sleep(0.5) + else: size = 2 + payload = png(size) + self.send_response(200) + self.send_header("Content-Type", "image/png") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + return if self.path == "/v1/events": accept = base64.b64encode(hashlib.sha1( (self.headers["Sec-WebSocket-Key"] + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()).decode() @@ -48,16 +121,17 @@ class Handler(http.server.BaseHTTPRequestHandler): self.send_header("Connection", "Upgrade") self.send_header("Sec-WebSocket-Accept", accept) self.end_headers() - payload = json.dumps({"type": "state", "state": state}).encode() - self.wfile.write(b"\x81\x7e" + struct.pack("!H", len(payload)) + payload) - self.wfile.flush() + snapshot = self.snapshot(token) + if token == "late-art": + time.sleep(0.1) + snapshot["artworkId"] = "B" * 64 + for _ in range(30 if token == "command-reject" else 1): + payload = json.dumps({"type": "state", "state": snapshot}).encode() + self.wfile.write(b"\x81\x7e" + struct.pack("!H", len(payload)) + payload) + self.wfile.flush() + if token == "command-reject": time.sleep(0.05) else: - payload = json.dumps(state).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(payload))) - self.end_headers() - self.wfile.write(payload) + self.reply(200, self.snapshot(token)) fixtures = {}