Handle interrupted pairing, artwork retries and revoked access reliably
This commit is contained in:
@@ -15,6 +15,16 @@
|
|||||||
"isa" = "PBXBuildFile";
|
"isa" = "PBXBuildFile";
|
||||||
"fileRef" = "065A9D559DC80AAF876DA757";
|
"fileRef" = "065A9D559DC80AAF876DA757";
|
||||||
};
|
};
|
||||||
|
"763FA6A137E5E94274AC56A3" = {
|
||||||
|
"isa" = "PBXFileReference";
|
||||||
|
"lastKnownFileType" = "sourcecode.swift";
|
||||||
|
"path" = "ConnectionStorage.swift";
|
||||||
|
"sourceTree" = "<group>";
|
||||||
|
};
|
||||||
|
"40D2920E2C996D62B7190BA4" = {
|
||||||
|
"isa" = "PBXBuildFile";
|
||||||
|
"fileRef" = "763FA6A137E5E94274AC56A3";
|
||||||
|
};
|
||||||
"971C0AE92F167DE7AB145086" = {
|
"971C0AE92F167DE7AB145086" = {
|
||||||
"isa" = "PBXFileReference";
|
"isa" = "PBXFileReference";
|
||||||
"lastKnownFileType" = "sourcecode.swift";
|
"lastKnownFileType" = "sourcecode.swift";
|
||||||
@@ -89,7 +99,7 @@
|
|||||||
"isa" = "PBXGroup";
|
"isa" = "PBXGroup";
|
||||||
"path" = "MusicBridge";
|
"path" = "MusicBridge";
|
||||||
"sourceTree" = "<group>";
|
"sourceTree" = "<group>";
|
||||||
"children" = ("065A9D559DC80AAF876DA757", "971C0AE92F167DE7AB145086", "5F6F408829AB46D3B85FB911", "76FB6BFFC478599567424755", "124CF3F5AC075D1D6042DF64", "ADF0CD56882201BD3AC1581B", "9C432A0DA0BEC173839EBF86", "7148B52E915554F67E875C26",);
|
"children" = ("065A9D559DC80AAF876DA757", "763FA6A137E5E94274AC56A3", "971C0AE92F167DE7AB145086", "5F6F408829AB46D3B85FB911", "76FB6BFFC478599567424755", "124CF3F5AC075D1D6042DF64", "ADF0CD56882201BD3AC1581B", "9C432A0DA0BEC173839EBF86", "7148B52E915554F67E875C26",);
|
||||||
};
|
};
|
||||||
"C9F705D432CA45B6056BC572" = {
|
"C9F705D432CA45B6056BC572" = {
|
||||||
"isa" = "PBXFileReference";
|
"isa" = "PBXFileReference";
|
||||||
@@ -111,6 +121,16 @@
|
|||||||
"isa" = "PBXBuildFile";
|
"isa" = "PBXBuildFile";
|
||||||
"fileRef" = "ACD0393DCDDDF044F2966340";
|
"fileRef" = "ACD0393DCDDDF044F2966340";
|
||||||
};
|
};
|
||||||
|
"464F6C65BC2FAB6BCCC77F46" = {
|
||||||
|
"isa" = "PBXFileReference";
|
||||||
|
"lastKnownFileType" = "sourcecode.swift";
|
||||||
|
"path" = "ResilienceTests.swift";
|
||||||
|
"sourceTree" = "<group>";
|
||||||
|
};
|
||||||
|
"EFDB280E0B23F7E2A246BC89" = {
|
||||||
|
"isa" = "PBXBuildFile";
|
||||||
|
"fileRef" = "464F6C65BC2FAB6BCCC77F46";
|
||||||
|
};
|
||||||
"8BED860F1BD59C500D215E8D" = {
|
"8BED860F1BD59C500D215E8D" = {
|
||||||
"isa" = "PBXFileReference";
|
"isa" = "PBXFileReference";
|
||||||
"lastKnownFileType" = "sourcecode.swift";
|
"lastKnownFileType" = "sourcecode.swift";
|
||||||
@@ -125,7 +145,7 @@
|
|||||||
"isa" = "PBXGroup";
|
"isa" = "PBXGroup";
|
||||||
"path" = "MusicBridgeTests";
|
"path" = "MusicBridgeTests";
|
||||||
"sourceTree" = "<group>";
|
"sourceTree" = "<group>";
|
||||||
"children" = ("C9F705D432CA45B6056BC572", "ACD0393DCDDDF044F2966340", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",);
|
"children" = ("C9F705D432CA45B6056BC572", "ACD0393DCDDDF044F2966340", "464F6C65BC2FAB6BCCC77F46", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",);
|
||||||
};
|
};
|
||||||
"F16D05EC6B29248D2C61ADB1" = {
|
"F16D05EC6B29248D2C61ADB1" = {
|
||||||
"isa" = "PBXFileReference";
|
"isa" = "PBXFileReference";
|
||||||
@@ -157,7 +177,7 @@
|
|||||||
"72B74C8A2143F64FD53D71E5" = {
|
"72B74C8A2143F64FD53D71E5" = {
|
||||||
"isa" = "PBXSourcesBuildPhase";
|
"isa" = "PBXSourcesBuildPhase";
|
||||||
"buildActionMask" = 2147483647;
|
"buildActionMask" = 2147483647;
|
||||||
"files" = ("82F1C33CE4F8B8F641FF0461", "16EA630DE1FE9F0A16580C9D", "842E87CD23DCB5BBCA4340F6", "DA06B3B12E52FB69A6A34887", "DEDCCD95A140162B6D654871", "48E2FDCA11A6694953C86E6D", "47693934E8A974162CF08A38", "BE808A0D13A16FE67D32B7FB",);
|
"files" = ("82F1C33CE4F8B8F641FF0461", "40D2920E2C996D62B7190BA4", "16EA630DE1FE9F0A16580C9D", "842E87CD23DCB5BBCA4340F6", "DA06B3B12E52FB69A6A34887", "DEDCCD95A140162B6D654871", "48E2FDCA11A6694953C86E6D", "47693934E8A974162CF08A38", "BE808A0D13A16FE67D32B7FB",);
|
||||||
"runOnlyForDeploymentPostprocessing" = 0;
|
"runOnlyForDeploymentPostprocessing" = 0;
|
||||||
};
|
};
|
||||||
"734C4650C7A5604092763C32" = {
|
"734C4650C7A5604092763C32" = {
|
||||||
@@ -186,8 +206,8 @@
|
|||||||
"CODE_SIGN_STYLE" = "Automatic";
|
"CODE_SIGN_STYLE" = "Automatic";
|
||||||
"GENERATE_INFOPLIST_FILE" = "NO";
|
"GENERATE_INFOPLIST_FILE" = "NO";
|
||||||
"INFOPLIST_FILE" = "MusicBridge/Info.plist";
|
"INFOPLIST_FILE" = "MusicBridge/Info.plist";
|
||||||
"CURRENT_PROJECT_VERSION" = "3";
|
"CURRENT_PROJECT_VERSION" = "4";
|
||||||
"MARKETING_VERSION" = "0.2.0";
|
"MARKETING_VERSION" = "0.3.0";
|
||||||
"SWIFT_OPTIMIZATION_LEVEL" = "-Onone";
|
"SWIFT_OPTIMIZATION_LEVEL" = "-Onone";
|
||||||
"ENABLE_TESTABILITY" = "YES";
|
"ENABLE_TESTABILITY" = "YES";
|
||||||
"SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG";
|
"SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG";
|
||||||
@@ -207,8 +227,8 @@
|
|||||||
"CODE_SIGN_STYLE" = "Automatic";
|
"CODE_SIGN_STYLE" = "Automatic";
|
||||||
"GENERATE_INFOPLIST_FILE" = "NO";
|
"GENERATE_INFOPLIST_FILE" = "NO";
|
||||||
"INFOPLIST_FILE" = "MusicBridge/Info.plist";
|
"INFOPLIST_FILE" = "MusicBridge/Info.plist";
|
||||||
"CURRENT_PROJECT_VERSION" = "3";
|
"CURRENT_PROJECT_VERSION" = "4";
|
||||||
"MARKETING_VERSION" = "0.2.0";
|
"MARKETING_VERSION" = "0.3.0";
|
||||||
"SWIFT_OPTIMIZATION_LEVEL" = "-O";
|
"SWIFT_OPTIMIZATION_LEVEL" = "-O";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -239,7 +259,7 @@
|
|||||||
"AB453A3F0E1490D48374A69A" = {
|
"AB453A3F0E1490D48374A69A" = {
|
||||||
"isa" = "PBXSourcesBuildPhase";
|
"isa" = "PBXSourcesBuildPhase";
|
||||||
"buildActionMask" = 2147483647;
|
"buildActionMask" = 2147483647;
|
||||||
"files" = ("1DD7BB0437E9F53586831F22", "17E248E58CB43C680B48DD51", "DC1A5B0B94F4C98F1CA36676",);
|
"files" = ("1DD7BB0437E9F53586831F22", "17E248E58CB43C680B48DD51", "EFDB280E0B23F7E2A246BC89", "DC1A5B0B94F4C98F1CA36676",);
|
||||||
"runOnlyForDeploymentPostprocessing" = 0;
|
"runOnlyForDeploymentPostprocessing" = 0;
|
||||||
};
|
};
|
||||||
"9AA2A4C344B5C474AF5D336C" = {
|
"9AA2A4C344B5C474AF5D336C" = {
|
||||||
|
|||||||
@@ -86,14 +86,18 @@ final class BridgeClient {
|
|||||||
let (bytes, response) = try await session.bytes(for: request)
|
let (bytes, response) = try await session.bytes(for: request)
|
||||||
guard let response = response as? HTTPURLResponse else { throw BridgeError.message("Нет ответа от ПК.") }
|
guard let response = response as? HTTPURLResponse else { throw BridgeError.message("Нет ответа от ПК.") }
|
||||||
if response.statusCode == 401 { throw BridgeError.unauthorized }
|
if response.statusCode == 401 { throw BridgeError.unauthorized }
|
||||||
guard (200...299).contains(response.statusCode) else {
|
let success = (200...299).contains(response.statusCode)
|
||||||
throw BridgeError.message("ПК отклонил запрос (HTTP \(response.statusCode)). Проверьте код и состояние агента.")
|
let bodyLimit = success ? limit : min(limit, 16 * 1024)
|
||||||
}
|
|
||||||
var result = Data()
|
var result = Data()
|
||||||
for try await byte in bytes {
|
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)
|
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
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +105,7 @@ final class BridgeClient {
|
|||||||
let body = try JSONSerialization.data(withJSONObject: ["code": code, "deviceName": "MusicBridge iPhone"])
|
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)))
|
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 {
|
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
|
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)
|
PlayerView(store: store)
|
||||||
Text(store.connection?.endpoint.host ?? "")
|
Text(store.connection?.endpoint.host ?? "")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
if !store.connected {
|
if !store.connected && !store.needsPairing {
|
||||||
Button("Подключиться снова") { store.resume() }
|
Button("Подключиться снова") { store.resume() }
|
||||||
}
|
}
|
||||||
Button("Забыть компьютер", role: .destructive) { showForget = true }
|
Button("Забыть компьютер", role: .destructive) { showForget = true }
|
||||||
@@ -24,6 +24,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
if let error = store.error {
|
if let error = store.error {
|
||||||
Text(error).font(.callout).foregroundStyle(.red).textSelection(.enabled)
|
Text(error).font(.callout).foregroundStyle(.red).textSelection(.enabled)
|
||||||
|
Button("Скрыть сообщение") { store.error = nil }.font(.footnote)
|
||||||
}
|
}
|
||||||
}.padding(24)
|
}.padding(24)
|
||||||
}
|
}
|
||||||
@@ -152,6 +153,10 @@ private struct PlayerView: View {
|
|||||||
}
|
}
|
||||||
}.frame(height: 270).clipShape(RoundedRectangle(cornerRadius: 24))
|
}.frame(height: 270).clipShape(RoundedRectangle(cornerRadius: 24))
|
||||||
.accessibilityLabel("Обложка трека")
|
.accessibilityLabel("Обложка трека")
|
||||||
|
if let error = store.artworkError {
|
||||||
|
Text(error).font(.footnote).foregroundStyle(.secondary)
|
||||||
|
Button("Повторить загрузку обложки") { store.retryArtwork() }.disabled(!store.connected)
|
||||||
|
}
|
||||||
if let state = store.state {
|
if let state = store.state {
|
||||||
Text(state.hasSession ? (state.title.isEmpty ? "Без названия" : state.title) : "Откройте плеер на ПК")
|
Text(state.hasSession ? (state.title.isEmpty ? "Без названия" : state.title) : "Откройте плеер на ПК")
|
||||||
.font(.title2.bold()).multilineTextAlignment(.center)
|
.font(.title2.bold()).multilineTextAlignment(.center)
|
||||||
|
|||||||
@@ -3,12 +3,43 @@ import Foundation
|
|||||||
enum BridgeError: LocalizedError {
|
enum BridgeError: LocalizedError {
|
||||||
case message(String)
|
case message(String)
|
||||||
case unauthorized
|
case unauthorized
|
||||||
|
case http(status: Int, code: String?)
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .message(let text): return text
|
case .message(let text): return text
|
||||||
case .unauthorized: return "Доступ отозван. Выполните сопряжение заново."
|
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 {
|
struct Connection: Codable {
|
||||||
|
|||||||
+119
-22
@@ -10,6 +10,8 @@ final class PlayerStore: ObservableObject {
|
|||||||
@Published var connected = false
|
@Published var connected = false
|
||||||
@Published var busy = false
|
@Published var busy = false
|
||||||
@Published var pairing = false
|
@Published var pairing = false
|
||||||
|
@Published var needsPairing = false
|
||||||
|
@Published var artworkError: String?
|
||||||
@Published var incomingInvitation: String?
|
@Published var incomingInvitation: String?
|
||||||
let discovery = DiscoveryStore()
|
let discovery = DiscoveryStore()
|
||||||
private(set) var receivedAt = Date()
|
private(set) var receivedAt = Date()
|
||||||
@@ -19,34 +21,67 @@ final class PlayerStore: ObservableObject {
|
|||||||
private var artworkID: String?
|
private var artworkID: String?
|
||||||
private var generation = UUID()
|
private var generation = UUID()
|
||||||
private var active = false
|
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() {
|
init(storage: ConnectionStorage = .keychain, retryUnit: UInt64 = 1_000_000_000,
|
||||||
do { connection = try KeychainStore.load() }
|
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 }
|
catch { self.error = error.localizedDescription }
|
||||||
}
|
}
|
||||||
|
|
||||||
func pair(endpoint: String, fingerprint: String, code: String) async {
|
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
|
pairing = true
|
||||||
defer { pairing = false }
|
defer {
|
||||||
|
if pairingID == operation { pairing = false; pairingID = nil; pairingClient = nil }
|
||||||
|
}
|
||||||
error = nil
|
error = nil
|
||||||
do {
|
do {
|
||||||
guard Invitation.validCode(code) else { throw BridgeError.message("Нужен восьмизначный код с ПК.") }
|
guard Invitation.validCode(code) else { throw BridgeError.message("Нужен восьмизначный код с ПК.") }
|
||||||
var saved = try Connection(endpoint: endpoint, fingerprint: fingerprint)
|
var saved = try Connection(endpoint: endpoint, fingerprint: fingerprint)
|
||||||
let pairingClient = BridgeClient(saved)
|
let pairingClient = BridgeClient(saved)
|
||||||
|
self.pairingClient = pairingClient
|
||||||
defer { pairingClient.close() }
|
defer { pairingClient.close() }
|
||||||
saved.token = try await pairingClient.pair(code: code)
|
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 на ПК и повторите сопряжение.") }
|
catch { throw BridgeError.message("ПК принял сопряжение, но Keychain не сохранил его. Удалите запись MusicBridge iPhone на ПК и повторите сопряжение.") }
|
||||||
connection = saved
|
connection = saved
|
||||||
|
needsPairing = false
|
||||||
resume()
|
resume()
|
||||||
} catch { self.error = error.localizedDescription }
|
} catch {
|
||||||
|
guard pairingID == operation else { return }
|
||||||
|
self.error = BridgeError.describe(error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setActive(_ active: Bool) {
|
func setActive(_ active: Bool) {
|
||||||
|
guard self.active != active else { return }
|
||||||
self.active = active
|
self.active = active
|
||||||
if active { discovery.start(); resume() }
|
if active {
|
||||||
else { discovery.stop(); stop(); status = "Соединение приостановлено" }
|
// 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) {
|
func importInvitation(_ text: String) {
|
||||||
@@ -60,6 +95,7 @@ final class PlayerStore: ObservableObject {
|
|||||||
func resume() {
|
func resume() {
|
||||||
stop()
|
stop()
|
||||||
guard active else { return }
|
guard active else { return }
|
||||||
|
guard !needsPairing else { status = "Нужно повторное сопряжение"; return }
|
||||||
guard let connection else { status = "Подключите компьютер"; return }
|
guard let connection else { status = "Подключите компьютер"; return }
|
||||||
let current = generation
|
let current = generation
|
||||||
runner = Task { [weak self] in
|
runner = Task { [weak self] in
|
||||||
@@ -81,9 +117,10 @@ final class PlayerStore: ObservableObject {
|
|||||||
guard self.generation == current else { client.close(); return }
|
guard self.generation == current else { client.close(); return }
|
||||||
// Persist a discovered address only after pinned, authenticated HTTPS succeeds.
|
// Persist a discovered address only after pinned, authenticated HTTPS succeeds.
|
||||||
if candidate.endpoint != saved.endpoint {
|
if candidate.endpoint != saved.endpoint {
|
||||||
try KeychainStore.save(candidate)
|
try self.storage.save(candidate)
|
||||||
self.connection = candidate
|
self.connection = candidate
|
||||||
}
|
}
|
||||||
|
self.error = nil
|
||||||
self.accept(initial, client: client)
|
self.accept(initial, client: client)
|
||||||
client.openEvents()
|
client.openEvents()
|
||||||
// Abort a silent/stalled stream even if the OS has not detected a lost Wi-Fi link.
|
// 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.artworkTask?.cancel()
|
||||||
self.artwork = nil
|
self.artwork = nil
|
||||||
self.artworkID = nil
|
self.artworkID = nil
|
||||||
|
self.artworkError = nil
|
||||||
if case BridgeError.unauthorized = error {
|
if case BridgeError.unauthorized = error {
|
||||||
self.status = "Нужно повторное сопряжение"
|
self.revokeAccess()
|
||||||
self.error = error.localizedDescription
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.status = "ПК недоступен. Повтор через \(delay) с"
|
self.status = "ПК недоступен. Повтор через \(delay) с"
|
||||||
self.error = error.localizedDescription
|
self.error = BridgeError.describe(error)
|
||||||
attempt += 1
|
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)
|
delay = min(delay * 2, 16)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,20 +167,54 @@ final class PlayerStore: ObservableObject {
|
|||||||
receivedAt = Date()
|
receivedAt = Date()
|
||||||
connected = true
|
connected = true
|
||||||
status = "Подключено"
|
status = "Подключено"
|
||||||
error = nil
|
|
||||||
guard artworkID != snapshot.artworkId else { return }
|
guard artworkID != snapshot.artworkId else { return }
|
||||||
artworkID = snapshot.artworkId
|
artworkID = snapshot.artworkId
|
||||||
artworkTask?.cancel()
|
artworkTask?.cancel()
|
||||||
artwork = nil
|
artwork = nil
|
||||||
|
artworkError = nil
|
||||||
guard let id = snapshot.artworkId else { return }
|
guard let id = snapshot.artworkId else { return }
|
||||||
let current = generation
|
let current = generation
|
||||||
artworkTask = Task { [weak self] in
|
artworkTask = Task { [weak self] in
|
||||||
guard let data = try? await client.artwork(id), !Task.isCancelled,
|
for attempt in 0..<4 {
|
||||||
let self, self.generation == current, self.artworkID == id else { return }
|
do {
|
||||||
self.artwork = UIImage(data: data)
|
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 {
|
func send(_ type: String, value: Double? = nil) async {
|
||||||
guard connected, !busy, let client else { return }
|
guard connected, !busy, let client else { return }
|
||||||
let current = generation
|
let current = generation
|
||||||
@@ -152,20 +223,30 @@ final class PlayerStore: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
// Commands are never retried: their IDs are not idempotency keys.
|
// Commands are never retried: their IDs are not idempotency keys.
|
||||||
try await client.command(Command(type: type, value: value))
|
try await client.command(Command(type: type, value: value))
|
||||||
let fresh = try await client.state()
|
|
||||||
guard generation == current else { return }
|
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 {
|
} catch {
|
||||||
guard generation == current else { return }
|
guard generation == current else { return }
|
||||||
self.error = error.localizedDescription
|
if case BridgeError.unauthorized = error { revokeAccess(); return }
|
||||||
if case BridgeError.unauthorized = error { stop(); status = "Нужно повторное сопряжение" }
|
self.error = (error is URLError ? "Не удалось подтвердить выполнение команды. Перед повтором проверьте плеер. " : "") + BridgeError.describe(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func forget() {
|
func forget() {
|
||||||
do { try KeychainStore.delete() }
|
do { try storage.delete() }
|
||||||
catch { self.error = error.localizedDescription; return }
|
catch { self.error = error.localizedDescription; return }
|
||||||
stop()
|
stop()
|
||||||
|
cancelPairing()
|
||||||
|
needsPairing = false
|
||||||
connection = nil
|
connection = nil
|
||||||
status = "Подключите компьютер"
|
status = "Подключите компьютер"
|
||||||
error = nil
|
error = nil
|
||||||
@@ -183,5 +264,21 @@ final class PlayerStore: ObservableObject {
|
|||||||
state = nil
|
state = nil
|
||||||
artwork = nil
|
artwork = nil
|
||||||
artworkID = 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 = "Сопряжение прервано. Если запись устройства появилась на ПК, удалите её перед новой попыткой."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
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)]
|
deps = [add("test-dependency", "PBXTargetDependency", target=uid("MusicBridgetarget"), targetProxy=proxy)]
|
||||||
else:
|
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,
|
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",
|
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))
|
buildConfigurationList=configurations(name, settings), buildPhases=phases, buildRules=[], dependencies=deps))
|
||||||
|
|||||||
+83
-9
@@ -11,6 +11,8 @@ import ssl
|
|||||||
import struct
|
import struct
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
|
import zlib
|
||||||
|
|
||||||
root = pathlib.Path(__file__).resolve().parents[1]
|
root = pathlib.Path(__file__).resolve().parents[1]
|
||||||
work = root / "build/tls"
|
work = root / "build/tls"
|
||||||
@@ -34,12 +36,83 @@ extendedKeyUsage = serverAuth
|
|||||||
subjectAltName = DNS:localhost,IP:127.0.0.1
|
subjectAltName = DNS:localhost,IP:127.0.0.1
|
||||||
""")
|
""")
|
||||||
state = json.loads((root / "MusicBridgeTests/state-v1.json").read_text())
|
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):
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
protocol_version = "HTTP/1.1"
|
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):
|
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":
|
if self.path == "/v1/events":
|
||||||
accept = base64.b64encode(hashlib.sha1(
|
accept = base64.b64encode(hashlib.sha1(
|
||||||
(self.headers["Sec-WebSocket-Key"] + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()).decode()
|
(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("Connection", "Upgrade")
|
||||||
self.send_header("Sec-WebSocket-Accept", accept)
|
self.send_header("Sec-WebSocket-Accept", accept)
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
payload = json.dumps({"type": "state", "state": state}).encode()
|
snapshot = self.snapshot(token)
|
||||||
self.wfile.write(b"\x81\x7e" + struct.pack("!H", len(payload)) + payload)
|
if token == "late-art":
|
||||||
self.wfile.flush()
|
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:
|
else:
|
||||||
payload = json.dumps(state).encode()
|
self.reply(200, self.snapshot(token))
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
fixtures = {}
|
fixtures = {}
|
||||||
|
|||||||
Reference in New Issue
Block a user