This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)).")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user