Add native SwiftUI player and macOS CI
iOS build and tests / simulator (push) Has been cancelled

This commit is contained in:
2026-09-10 09:11:55 +03:00
parent 6307070194
commit 6174be5610
20 changed files with 1729 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
import Foundation
import Security
import CryptoKit
// One session per pinned PC. Never forward credentials to a redirected endpoint.
final class PinnedDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate {
let connection: Connection
init(_ connection: Connection) { self.connection = connection }
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
challenge.protectionSpace.host == connection.endpoint.host,
challenge.protectionSpace.port == (connection.endpoint.port ?? 443),
let trust = challenge.protectionSpace.serverTrust,
let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate], let leaf = chain.first else {
completionHandler(.cancelAuthenticationChallenge, nil); return
}
let digest = SHA256.hash(data: SecCertificateCopyData(leaf) as Data)
.map { String(format: "%02X", $0) }.joined()
guard digest == connection.fingerprint else {
completionHandler(.cancelAuthenticationChallenge, nil); return
}
// Trust only this exact out-of-band certificate, while checking its validity dates.
SecTrustSetAnchorCertificates(trust, [leaf] as CFArray)
SecTrustSetAnchorCertificatesOnly(trust, true)
SecTrustSetPolicies(trust, SecPolicyCreateBasicX509())
guard SecTrustEvaluateWithError(trust, nil) else {
completionHandler(.cancelAuthenticationChallenge, nil); return
}
completionHandler(.useCredential, URLCredential(trust: trust))
}
func urlSession(_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void) {
completionHandler(nil)
}
}
final class BridgeClient {
let connection: Connection
private let session: URLSession
private var socket: URLSessionWebSocketTask?
init(_ connection: Connection) {
self.connection = connection
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 10
config.timeoutIntervalForResource = 15
config.urlCache = nil
config.httpCookieStorage = nil
session = URLSession(configuration: config, delegate: PinnedDelegate(connection), delegateQueue: nil)
}
func close() { socket?.cancel(with: .goingAway, reason: nil); session.invalidateAndCancel() }
private func request(_ path: String, body: Data? = nil, authenticated: Bool = true) -> URLRequest {
var request = URLRequest(url: connection.endpoint.appendingPathComponent(path))
if authenticated, let token = connection.token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
if let body {
request.httpMethod = "POST"
request.httpBody = body
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
return request
}
private func data(_ request: URLRequest, limit: Int = 256 * 1024) async throws -> Data {
let (bytes, response) = try await session.bytes(for: request)
guard let response = response as? HTTPURLResponse else { throw BridgeError.message("Нет ответа от ПК.") }
if response.statusCode == 401 { throw BridgeError.unauthorized }
guard (200...299).contains(response.statusCode) else {
throw BridgeError.message("ПК отклонил запрос (HTTP \(response.statusCode)). Проверьте код и состояние агента.")
}
var result = Data()
for try await byte in bytes {
guard result.count < limit else { throw BridgeError.message("Ответ ПК слишком большой.") }
result.append(byte)
}
return result
}
func pair(code: String) async throws -> String {
let body = try JSONSerialization.data(withJSONObject: ["code": code, "deviceName": "MusicBridge iPhone"])
let response = try JSONDecoder().decode(PairResponse.self, from: await data(request("v1/pair", body: body, authenticated: false)))
guard response.success, let token = response.token, Connection.isFingerprint(token) else {
throw BridgeError.message("Сопряжение отклонено: \(response.code)")
}
return token
}
func state() async throws -> MediaState {
try Self.validate(JSONDecoder().decode(MediaState.self, from: await data(request("v1/state"))))
}
static func validate(_ state: MediaState) throws -> MediaState {
guard state.protocolVersion == 1 else { throw BridgeError.message("Версия протокола ПК не поддерживается.") }
return state
}
func command(_ command: Command) async throws {
let response = try JSONDecoder().decode(CommandResponse.self, from: await data(request("v1/command", body: JSONEncoder().encode(command))))
guard response.success else { throw BridgeError.message(response.message) }
}
func artwork(_ id: String) async throws -> Data {
guard Connection.isFingerprint(id) else { throw BridgeError.message("Неверный ID обложки.") }
return try await data(request("v1/artwork/\(id)"), limit: 2 * 1024 * 1024)
}
func openEvents() {
var req = request("v1/events")
var url = URLComponents(url: req.url!, resolvingAgainstBaseURL: false)!
url.scheme = "wss"
req.url = url.url!
let socket = session.webSocketTask(with: req)
socket.maximumMessageSize = 256 * 1024
self.socket = socket
socket.resume()
}
func nextState() async throws -> MediaState {
guard let socket else { throw BridgeError.message("Соединение закрыто.") }
let message = try await socket.receive()
let payload: Data
switch message {
case .data(let data): payload = data
case .string(let string): payload = Data(string.utf8)
@unknown default: throw BridgeError.message("Неизвестный формат сообщения.")
}
let event = try JSONDecoder().decode(StateEvent.self, from: payload)
guard event.type == "state" else { throw BridgeError.message("Неизвестное событие.") }
return try Self.validate(event.state)
}
}
+166
View File
@@ -0,0 +1,166 @@
import SwiftUI
struct ContentView: View {
@ObservedObject var store: PlayerStore
@State private var showForget = false
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
Label(store.status, systemImage: store.connected ? "wifi" : "wifi.slash")
.font(.subheadline).foregroundStyle(.secondary)
if store.connection == nil {
PairingView(store: store)
} else {
PlayerView(store: store)
Text(store.connection?.endpoint.host ?? "")
.font(.caption).foregroundStyle(.secondary)
if !store.connected {
Button("Подключиться снова") { store.resume() }
}
Button("Забыть компьютер", role: .destructive) { showForget = true }
.font(.footnote)
}
if let error = store.error {
Text(error).font(.callout).foregroundStyle(.red).textSelection(.enabled)
}
}.padding(24)
}
.background(Color(.systemGroupedBackground))
.navigationTitle("MusicBridge")
.confirmationDialog("Забыть этот компьютер?", isPresented: $showForget, titleVisibility: .visible) {
Button("Забыть", role: .destructive) { store.forget() }
} message: {
Text("Токен будет удалён с iPhone. Чтобы отозвать доступ на ПК, удалите устройство в окне MusicBridge.")
}
}.tint(.indigo)
}
}
private struct PairingView: View {
@ObservedObject var store: PlayerStore
@State private var endpoint = "https://"
@State private var fingerprint = ""
@State private var code = ""
@State private var invitation = ""
var body: some View {
VStack(alignment: .leading, spacing: 18) {
Image(systemName: "desktopcomputer.and.arrow.down")
.font(.system(size: 48)).foregroundStyle(.indigo)
.frame(maxWidth: .infinity).accessibilityHidden(true)
Text("Музыка на ПК. Управление здесь.").font(.title2.bold())
Text("Откройте сопряжение в MusicBridge на Windows. Подключите iPhone к той же домашней сети.")
.foregroundStyle(.secondary)
Group {
TextField("Адрес ПК: https://192.168.1.10:8765", text: $endpoint).keyboardType(.URL)
TextField("SHA-256 сертификата с экрана ПК", text: $fingerprint)
TextField("Восьмизначный код", text: $code).keyboardType(.numberPad)
}.textFieldStyle(.roundedBorder).textInputAutocapitalization(.never).autocorrectionDisabled()
Text("Сверьте весь отпечаток с экраном ПК перед подключением.").font(.caption).foregroundStyle(.secondary)
Button {
Task { await store.pair(endpoint: endpoint.trimmingCharacters(in: .whitespacesAndNewlines),
fingerprint: fingerprint.trimmingCharacters(in: .whitespacesAndNewlines),
code: code.trimmingCharacters(in: .whitespacesAndNewlines)) }
} label: {
HStack {
if store.pairing { ProgressView().tint(.white) }
Text(store.pairing ? "Сопряжение…" : "Подключить компьютер")
}.frame(maxWidth: .infinity)
}.buttonStyle(.borderedProminent).controlSize(.large).disabled(store.pairing)
DisclosureGroup("Импорт строки из QR") {
VStack(spacing: 12) {
TextField("musicbridge://pair?data=…", text: $invitation, axis: .vertical)
.lineLimit(3...5).textInputAutocapitalization(.never).autocorrectionDisabled()
Button("Заполнить из приглашения") {
do {
let parsed = try Invitation.parse(invitation.trimmingCharacters(in: .whitespacesAndNewlines))
endpoint = parsed.endpoint
fingerprint = parsed.certificateSha256
code = parsed.code
store.error = nil
} catch { store.error = error.localizedDescription }
}
}.padding(.top, 12)
}.disabled(store.pairing)
}
}
}
private struct PlayerView: View {
@ObservedObject var store: PlayerStore
@State private var seekValue = 0.0
@State private var seeking = false
@State private var volumeValue = 0.0
@State private var changingVolume = false
var body: some View {
VStack(spacing: 20) {
Group {
if let artwork = store.artwork {
Image(uiImage: artwork).resizable().scaledToFit()
} else {
RoundedRectangle(cornerRadius: 24).fill(.indigo.opacity(0.12))
.overlay { Image(systemName: "music.note").font(.system(size: 72)).foregroundStyle(.indigo) }
}
}.frame(height: 270).clipShape(RoundedRectangle(cornerRadius: 24))
.accessibilityLabel("Обложка трека")
if let state = store.state {
Text(state.hasSession ? (state.title.isEmpty ? "Без названия" : state.title) : "Откройте плеер на ПК")
.font(.title2.bold()).multilineTextAlignment(.center)
Text(state.artist).foregroundStyle(.secondary)
TimelineView(.periodic(from: .now, by: 0.25)) { timeline in
let position = state.position(elapsed: timeline.date.timeIntervalSince(store.receivedAt))
VStack {
Slider(value: Binding(get: { seeking ? seekValue : position }, set: { seekValue = $0 }),
in: state.minSeekSeconds...max(state.minSeekSeconds + 0.001, state.maxSeekSeconds)) { editing in
if editing { seekValue = position }
seeking = editing
if !editing { Task { await store.send("seek", value: seekValue) } }
}.disabled(!state.capabilities.seek || state.maxSeekSeconds <= state.minSeekSeconds || store.busy)
.accessibilityLabel("Позиция трека")
HStack {
Text(time((seeking ? seekValue : position) - state.startSeconds))
Spacer()
Text(time(state.endSeconds - state.startSeconds))
}.font(.caption.monospacedDigit()).foregroundStyle(.secondary)
}
}
HStack(spacing: 36) {
control("previous", icon: "backward.end.fill", label: "Предыдущий трек", enabled: state.capabilities.previous)
let playing = state.playbackStatus == "Playing"
let command = state.capabilities.toggle ? "toggle" : (playing ? "pause" : "play")
control(command, icon: playing ? "pause.circle.fill" : "play.circle.fill", label: playing ? "Пауза" : "Воспроизвести",
enabled: state.capabilities.toggle || (playing ? state.capabilities.pause : state.capabilities.play), size: 64)
control("next", icon: "forward.end.fill", label: "Следующий трек", enabled: state.capabilities.next)
}
HStack {
control("toggleMute", icon: state.muted == true ? "speaker.slash.fill" : "speaker.wave.2.fill",
label: state.muted == true ? "Включить звук ПК" : "Выключить звук ПК", enabled: state.muted != nil, size: 22)
Slider(value: Binding(get: { changingVolume ? volumeValue : (state.volume ?? 0) }, set: { volumeValue = $0 }), in: 0...1) { editing in
if editing { volumeValue = state.volume ?? 0 }
changingVolume = editing
if !editing { Task { await store.send("volume", value: volumeValue) } }
}.disabled(state.volume == nil || store.busy).accessibilityLabel("Системная громкость ПК")
}
ForEach([state.mediaError, state.volumeError, state.artworkError].compactMap { $0 }, id: \.self) {
Text($0).font(.caption).foregroundStyle(.secondary)
}
} else {
Text("Ожидаем состояние компьютера").foregroundStyle(.secondary)
}
}.disabled(!store.connected)
}
private func control(_ type: String, icon: String, label: String, enabled: Bool, size: CGFloat = 28) -> some View {
Button { Task { await store.send(type) } } label: {
Image(systemName: icon).font(.system(size: size)).frame(minWidth: 44, minHeight: 44)
}.disabled(!enabled || store.busy).accessibilityLabel(label)
}
private func time(_ seconds: Double) -> String {
let value = Int(min(max(0, seconds), 359999))
return String(format: "%d:%02d", value / 60, value % 60)
}
}
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>ru</string>
<key>CFBundleDisplayName</key>
<string>MusicBridge</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>MusicBridge подключается к вашему ПК, чтобы показывать музыку и управлять воспроизведением.</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>
+41
View File
@@ -0,0 +1,41 @@
import Foundation
import Security
enum KeychainStore {
private static var query: [String: Any] {
[kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "MusicBridge.connection.v1", kSecAttrAccount as String: "primaryPC"]
}
static func load() throws -> Connection? {
var q = query
q[kSecReturnData as String] = true
q[kSecMatchLimit as String] = kSecMatchLimitOne
var item: CFTypeRef?
let status = SecItemCopyMatching(q as CFDictionary, &item)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess, let data = item as? Data else { throw failure(status) }
let saved = try JSONDecoder().decode(Connection.self, from: data)
guard let token = saved.token, Connection.isFingerprint(token) else { throw BridgeError.message("Повреждена запись сопряжения.") }
return try Connection(endpoint: saved.endpoint.absoluteString, fingerprint: saved.fingerprint, token: token)
}
static func save(_ connection: Connection) throws {
let values: [String: Any] = [kSecValueData as String: try JSONEncoder().encode(connection),
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly]
var status = SecItemUpdate(query as CFDictionary, values as CFDictionary)
if status == errSecItemNotFound {
status = SecItemAdd(query.merging(values) { _, new in new } as CFDictionary, nil)
}
guard status == errSecSuccess else { throw failure(status) }
}
static func delete() throws {
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else { throw failure(status) }
}
private static func failure(_ status: OSStatus) -> BridgeError {
.message("Не удалось прочитать или сохранить сопряжение в Keychain (\(status)).")
}
}
+116
View File
@@ -0,0 +1,116 @@
import Foundation
enum BridgeError: LocalizedError {
case message(String)
case unauthorized
var errorDescription: String? {
switch self {
case .message(let text): return text
case .unauthorized: return "Доступ отозван. Выполните сопряжение заново."
}
}
}
struct Connection: Codable {
let endpoint: URL
let fingerprint: String
var token: String?
init(endpoint: String, fingerprint: String, token: String? = nil) throws {
guard let url = URL(string: endpoint), let parts = URLComponents(url: url, resolvingAgainstBaseURL: false),
parts.scheme == "https", parts.user == nil, parts.password == nil,
parts.query == nil, parts.fragment == nil,
parts.path.isEmpty || parts.path == "/",
let host = parts.host, Self.isPrivateIPv4(host),
parts.port == nil || (1...65535).contains(parts.port!) else {
throw BridgeError.message("Укажите HTTPS-адрес ПК в домашней сети, например https://192.168.1.10:8765.")
}
guard Self.isFingerprint(fingerprint) else {
throw BridgeError.message("Отпечаток сертификата должен содержать 64 шестнадцатеричных символа.")
}
self.endpoint = url
self.fingerprint = fingerprint.uppercased()
self.token = token
}
static func isFingerprint(_ value: String) -> Bool {
value.utf8.count == 64 && value.utf8.allSatisfy { (48...57).contains($0) || (65...70).contains($0) || (97...102).contains($0) }
}
static func isPrivateIPv4(_ host: String) -> Bool {
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count == 4 else { return false }
let bytes = parts.compactMap { part -> UInt8? in
guard !part.isEmpty, part.utf8.allSatisfy({ (48...57).contains($0) }),
part.count == 1 || part.first != "0" else { return nil }
return UInt8(part)
}
guard bytes.count == 4 else { return false }
return bytes[0] == 10 || (bytes[0] == 172 && (16...31).contains(bytes[1])) || (bytes[0] == 192 && bytes[1] == 168)
}
}
struct Invitation: Decodable {
let version: Int
let endpoint: String
let certificateSha256: String
let code: String
let expiresAt: String
static func parse(_ text: String, now: Date = Date()) throws -> Invitation {
guard text.utf8.count <= 8192,
let url = URLComponents(string: text), url.scheme == "musicbridge", url.host == "pair",
url.user == nil, url.password == nil, url.port == nil, url.path.isEmpty, url.fragment == nil,
let items = url.queryItems, items.count == 1, items[0].name == "data",
let encoded = items[0].value else { throw BridgeError.message("Неверная строка сопряжения.") }
var base64 = encoded.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4)
guard let data = Data(base64Encoded: base64) else { throw BridgeError.message("Повреждённые данные QR.") }
let invitation = try JSONDecoder().decode(Invitation.self, from: data)
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
var expiry = formatter.date(from: invitation.expiresAt)
if expiry == nil {
formatter.formatOptions = [.withInternetDateTime]
expiry = formatter.date(from: invitation.expiresAt)
}
guard invitation.version == 1, validCode(invitation.code), let expiry, expiry > now else {
throw BridgeError.message("Код истёк или версия QR не поддерживается. Откройте новый QR на ПК.")
}
_ = try Connection(endpoint: invitation.endpoint, fingerprint: invitation.certificateSha256)
return invitation
}
static func validCode(_ code: String) -> Bool {
code.utf8.count == 8 && code.utf8.allSatisfy { (48...57).contains($0) }
}
}
struct Capabilities: Decodable {
let play, pause, toggle, next, previous, seek: Bool
}
struct MediaState: Decodable {
let protocolVersion: Int
let hasSession: Bool
let title, artist, source, playbackStatus: String
let positionSeconds, startSeconds, endSeconds, minSeekSeconds, maxSeekSeconds, playbackRate: Double
let capabilities: Capabilities
let volume: Double?
let muted: Bool?
let mediaError, volumeError, artworkId, artworkError: String?
func position(elapsed: TimeInterval) -> Double {
let projected = positionSeconds + (playbackStatus == "Playing" ? max(0, elapsed) * playbackRate : 0)
return min(max(startSeconds, projected), max(startSeconds, endSeconds))
}
}
struct StateEvent: Decodable { let type: String; let state: MediaState }
struct PairResponse: Decodable { let success: Bool; let token: String?; let code: String }
struct CommandResponse: Decodable { let success: Bool; let code, message: String }
struct Command: Encodable {
let type: String
let value: Double?
let id = UUID().uuidString
}
+15
View File
@@ -0,0 +1,15 @@
import SwiftUI
@main
struct MusicBridgeApp: App {
@StateObject private var store = PlayerStore()
@Environment(\.scenePhase) private var phase
var body: some Scene {
WindowGroup {
ContentView(store: store)
.task { store.setActive(phase == .active) }
.onChange(of: phase) { _, value in store.setActive(value == .active) }
}
}
}
+163
View File
@@ -0,0 +1,163 @@
import SwiftUI
@MainActor
final class PlayerStore: ObservableObject {
@Published var connection: Connection?
@Published var state: MediaState?
@Published var artwork: UIImage?
@Published var status = "Подключите компьютер"
@Published var error: String?
@Published var connected = false
@Published var busy = false
@Published var pairing = false
private(set) var receivedAt = Date()
private var client: BridgeClient?
private var runner: Task<Void, Never>?
private var artworkTask: Task<Void, Never>?
private var artworkID: String?
private var generation = UUID()
private var active = false
init() {
do { connection = try KeychainStore.load() }
catch { self.error = error.localizedDescription }
}
func pair(endpoint: String, fingerprint: String, code: String) async {
guard !pairing else { return }
pairing = true
defer { pairing = false }
error = nil
do {
guard Invitation.validCode(code) else { throw BridgeError.message("Нужен восьмизначный код с ПК.") }
var saved = try Connection(endpoint: endpoint, fingerprint: fingerprint)
let pairingClient = BridgeClient(saved)
defer { pairingClient.close() }
saved.token = try await pairingClient.pair(code: code)
do { try KeychainStore.save(saved) }
catch { throw BridgeError.message("ПК принял сопряжение, но Keychain не сохранил его. Удалите запись MusicBridge iPhone на ПК и повторите сопряжение.") }
connection = saved
resume()
} catch { self.error = error.localizedDescription }
}
func setActive(_ active: Bool) {
self.active = active
if active { resume() } else { stop(); status = "Соединение приостановлено" }
}
func resume() {
stop()
guard active, let connection else { return }
let current = generation
runner = Task { [weak self] in
var delay: UInt64 = 1
while !Task.isCancelled {
guard let self, self.generation == current else { return }
let client = BridgeClient(connection)
self.client = client
self.status = "Подключение к ПК…"
do {
let initial = try await client.state()
try Task.checkCancellation()
guard self.generation == current else { client.close(); return }
self.accept(initial, client: client)
client.openEvents()
// Abort a silent/stalled stream even if the OS has not detected a lost Wi-Fi link.
let watchdog = Task { [weak self] in
while !Task.isCancelled {
do { try await Task.sleep(nanoseconds: 5_000_000_000) } catch { return }
guard let self, self.generation == current else { return }
if Date().timeIntervalSince(self.receivedAt) > 15 { client.close(); return }
}
}
defer { watchdog.cancel() }
while !Task.isCancelled {
let snapshot = try await client.nextState()
try Task.checkCancellation()
guard self.generation == current else { return }
self.accept(snapshot, client: client)
delay = 1
}
} catch {
client.close()
guard !Task.isCancelled, self.generation == current else { return }
self.connected = false
self.state = nil
self.artworkTask?.cancel()
self.artwork = nil
self.artworkID = nil
if case BridgeError.unauthorized = error {
self.status = "Нужно повторное сопряжение"
self.error = error.localizedDescription
return
}
self.status = "ПК недоступен. Повтор через \(delay) с"
self.error = error.localizedDescription
}
do { try await Task.sleep(nanoseconds: delay * 1_000_000_000) } catch { return }
delay = min(delay * 2, 16)
}
}
}
private func accept(_ snapshot: MediaState, client: BridgeClient) {
state = snapshot
receivedAt = Date()
connected = true
status = "Подключено"
error = nil
guard artworkID != snapshot.artworkId else { return }
artworkID = snapshot.artworkId
artworkTask?.cancel()
artwork = 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)
}
}
func send(_ type: String, value: Double? = nil) async {
guard connected, !busy, let client else { return }
let current = generation
busy = true
defer { if generation == current { busy = false } }
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)
} catch {
guard generation == current else { return }
self.error = error.localizedDescription
if case BridgeError.unauthorized = error { stop(); status = "Нужно повторное сопряжение" }
}
}
func forget() {
do { try KeychainStore.delete() }
catch { self.error = error.localizedDescription; return }
stop()
connection = nil
status = "Подключите компьютер"
error = nil
}
private func stop() {
generation = UUID()
runner?.cancel()
runner = nil
artworkTask?.cancel()
client?.close()
client = nil
connected = false
busy = false
state = nil
artwork = nil
artworkID = nil
}
}