Add camera QR pairing, Bonjour discovery and pinned address recovery

This commit is contained in:
2026-09-12 15:52:31 +03:00
parent 5f16ec48aa
commit 397caf23ce
12 changed files with 451 additions and 20 deletions
+53 -8
View File
@@ -11,7 +11,7 @@ struct ContentView: View {
Label(store.status, systemImage: store.connected ? "wifi" : "wifi.slash")
.font(.subheadline).foregroundStyle(.secondary)
if store.connection == nil {
PairingView(store: store)
PairingView(store: store, discovery: store.discovery)
} else {
PlayerView(store: store)
Text(store.connection?.endpoint.host ?? "")
@@ -40,10 +40,12 @@ struct ContentView: View {
private struct PairingView: View {
@ObservedObject var store: PlayerStore
@ObservedObject var discovery: DiscoveryStore
@State private var endpoint = "https://"
@State private var fingerprint = ""
@State private var code = ""
@State private var invitation = ""
@State private var showScanner = false
var body: some View {
VStack(alignment: .leading, spacing: 18) {
@@ -53,6 +55,36 @@ private struct PairingView: View {
Text("Музыка на ПК. Управление здесь.").font(.title2.bold())
Text("Откройте сопряжение в MusicBridge на Windows. Подключите iPhone к той же домашней сети.")
.foregroundStyle(.secondary)
Button { showScanner = true } label: {
Label("Сканировать QR с ПК", systemImage: "qrcode.viewfinder")
.frame(maxWidth: .infinity)
}.buttonStyle(.borderedProminent).controlSize(.large).disabled(store.pairing)
Text("QR заполнит адрес, отпечаток и код. Затем нажмите «Подключить компьютер».")
.font(.caption).foregroundStyle(.secondary)
DisclosureGroup("Найти ПК в сети") {
VStack(alignment: .leading, spacing: 12) {
Text(discovery.message).font(.footnote).foregroundStyle(.secondary)
ForEach(discovery.computers) { computer in
Button {
endpoint = computer.endpoint
fingerprint = computer.fingerprint
code = ""
store.error = nil
} label: {
VStack(alignment: .leading) {
Label(computer.name, systemImage: "desktopcomputer")
Text(computer.endpoint).font(.caption)
}
}
}
Text("Выберите ПК, сверьте весь отпечаток с его экраном и введите свежий код сопряжения.")
.font(.caption).foregroundStyle(.secondary)
Button("Повторить поиск") { discovery.restart() }
Button("Настройки доступа к сети") {
if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) }
}
}.padding(.top, 8)
}.disabled(store.pairing)
Group {
TextField("Адрес ПК: https://192.168.1.10:8765", text: $endpoint).keyboardType(.URL)
TextField("SHA-256 сертификата с экрана ПК", text: $fingerprint)
@@ -74,17 +106,30 @@ private struct PairingView: View {
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 }
importText(invitation)
}
}.padding(.top, 12)
}.disabled(store.pairing)
}
.sheet(isPresented: $showScanner) { QRScannerSheet(receive: importText) }
.onAppear { consumeIncoming() }
.onChange(of: store.incomingInvitation) { _, _ in consumeIncoming() }
}
private func consumeIncoming() {
guard let text = store.incomingInvitation else { return }
store.incomingInvitation = nil
importText(text)
}
private func importText(_ text: String) {
do {
let parsed = try Invitation.parse(text.trimmingCharacters(in: .whitespacesAndNewlines))
endpoint = parsed.endpoint
fingerprint = parsed.certificateSha256
code = parsed.code
store.error = nil
} catch { store.error = error.localizedDescription }
}
}
+112
View File
@@ -0,0 +1,112 @@
import Foundation
import Combine
import Darwin
struct DiscoveredComputer: Identifiable, Equatable {
let name: String
let endpoint: String
let fingerprint: String
var id: String { fingerprint + endpoint }
static func parse(name: String, host: String, port: Int, txt: [String: Data]) -> Self? {
guard txt["v"] == Data("1".utf8), let data = txt["id"],
let pin = String(data: data, encoding: .utf8),
let connection = try? Connection(endpoint: "https://\(host):\(port)", fingerprint: pin) else { return nil }
return Self(name: String(name.prefix(80)), endpoint: connection.endpoint.absoluteString,
fingerprint: connection.fingerprint)
}
// An advertisement is a routing hint, never a replacement for saved trust.
func reconnect(_ saved: Connection) -> Connection? {
guard fingerprint == saved.fingerprint else { return nil }
return try? Connection(endpoint: endpoint, fingerprint: saved.fingerprint, token: saved.token)
}
}
// Started on the main run loop; Foundation delivers browser/service delegates there.
final class DiscoveryStore: NSObject, ObservableObject, NetServiceBrowserDelegate, NetServiceDelegate {
@Published private(set) var computers: [DiscoveredComputer] = []
@Published private(set) var message = "Поиск ПК не запущен"
private var browser: NetServiceBrowser?
private var services: [String: NetService] = [:]
private var records: [String: [DiscoveredComputer]] = [:]
private var refresh: Timer?
func start() {
guard browser == nil else { return }
message = "Ищем MusicBridge в локальной сети…"
let browser = NetServiceBrowser()
self.browser = browser
browser.delegate = self
browser.searchForServices(ofType: "_musicbridge._tcp.", inDomain: "local.")
refresh = Timer.scheduledTimer(withTimeInterval: 15, repeats: true) { [weak self] _ in
guard let self else { return }
for service in self.services.values {
service.stop()
service.resolve(withTimeout: 5)
}
}
}
func stop() {
refresh?.invalidate(); refresh = nil
browser?.delegate = nil; browser?.stop(); browser = nil
for service in services.values { service.delegate = nil; service.stop() }
services.removeAll(); records.removeAll(); computers = []
message = "Поиск ПК приостановлен"
}
func restart() { stop(); start() }
private func key(_ service: NetService) -> String { service.domain + service.type + service.name }
private func publish() {
computers = Array(Set(records.values.flatMap { $0 }.map(\.id))).sorted().compactMap { id in
records.values.lazy.flatMap { $0 }.first { $0.id == id }
}
message = computers.isEmpty ? "ПК пока не найден. Проверьте Wi-Fi и LAN-режим агента." : "Найдено адресов: \(computers.count)"
}
func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) {
guard self.browser === browser, services.count < 64, services[key(service)] == nil else { return }
services[key(service)] = service
service.delegate = self
service.resolve(withTimeout: 5)
}
func netServiceBrowser(_ browser: NetServiceBrowser, didRemove service: NetService, moreComing: Bool) {
guard self.browser === browser else { return }
let old = services.removeValue(forKey: key(service))
old?.delegate = nil; old?.stop()
records.removeValue(forKey: key(service)); publish()
}
func netServiceBrowser(_ browser: NetServiceBrowser, didNotSearch errorDict: [String: NSNumber]) {
guard self.browser === browser else { return }
stop()
message = "Поиск недоступен. Разрешите MusicBridge доступ к локальной сети в Настройках и повторите поиск."
}
func netServiceDidResolveAddress(_ sender: NetService) {
guard services[key(sender)] === sender else { return }
let txt = sender.txtRecordData().map(NetService.dictionary(fromTXTRecord:)) ?? [:]
records[key(sender)] = (sender.addresses ?? []).prefix(16).compactMap { data in
guard let host = Self.ipv4(data) else { return nil }
return DiscoveredComputer.parse(name: sender.name, host: host, port: sender.port, txt: txt)
}
publish()
}
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
guard services[key(sender)] === sender else { return }
records.removeValue(forKey: key(sender)); publish()
}
static func ipv4(_ data: Data) -> String? {
guard data.count >= MemoryLayout<sockaddr_in>.size else { return nil }
var address = data.withUnsafeBytes { $0.loadUnaligned(as: sockaddr_in.self) }
guard address.sin_family == sa_family_t(AF_INET) else { return nil }
var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN))
guard inet_ntop(AF_INET, &address.sin_addr, &buffer, socklen_t(INET_ADDRSTRLEN)) != nil else { return nil }
return String(cString: buffer)
}
}
+17
View File
@@ -30,6 +30,23 @@
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>MusicBridge подключается к вашему ПК, чтобы показывать музыку и управлять воспроизведением.</string>
<key>NSCameraUsageDescription</key>
<string>Камера сканирует QR в MusicBridge на ПК для безопасного сопряжения.</string>
<key>NSBonjourServices</key>
<array>
<string>_musicbridge._tcp</string>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>ru.yukinoki.musicbridge.pair</string>
<key>CFBundleURLSchemes</key>
<array>
<string>musicbridge</string>
</array>
</dict>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
+3 -1
View File
@@ -66,7 +66,9 @@ struct Invitation: Decodable {
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 invitation: Invitation
do { invitation = try JSONDecoder().decode(Invitation.self, from: data) }
catch { throw BridgeError.message("QR не содержит корректного приглашения MusicBridge. Откройте новый QR в агенте на ПК.") }
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
var expiry = formatter.date(from: invitation.expiresAt)
+1
View File
@@ -8,6 +8,7 @@ struct MusicBridgeApp: App {
var body: some Scene {
WindowGroup {
ContentView(store: store)
.onOpenURL { store.importInvitation($0.absoluteString) }
.task { store.setActive(phase == .active) }
.onChange(of: phase) { _, value in store.setActive(value == .active) }
}
+25 -2
View File
@@ -10,6 +10,8 @@ final class PlayerStore: ObservableObject {
@Published var connected = false
@Published var busy = false
@Published var pairing = false
@Published var incomingInvitation: String?
let discovery = DiscoveryStore()
private(set) var receivedAt = Date()
private var client: BridgeClient?
private var runner: Task<Void, Never>?
@@ -43,7 +45,16 @@ final class PlayerStore: ObservableObject {
func setActive(_ active: Bool) {
self.active = active
if active { resume() } else { stop(); status = "Соединение приостановлено" }
if active { discovery.start(); resume() }
else { discovery.stop(); stop(); status = "Соединение приостановлено" }
}
func importInvitation(_ text: String) {
guard connection == nil, !pairing else {
error = "Для подключения другого ПК сначала выберите «Забыть компьютер»."
return
}
incomingInvitation = text
}
func resume() {
@@ -52,15 +63,26 @@ final class PlayerStore: ObservableObject {
let current = generation
runner = Task { [weak self] in
var delay: UInt64 = 1
var attempt = 0
while !Task.isCancelled {
guard let self, self.generation == current else { return }
let client = BridgeClient(connection)
let saved = self.connection ?? connection
let alternatives = self.discovery.computers.compactMap { $0.reconnect(saved) }
.filter { $0.endpoint != saved.endpoint }
let candidates = [saved] + alternatives
let candidate = candidates[attempt % candidates.count]
let client = BridgeClient(candidate)
self.client = client
self.status = "Подключение к ПК…"
do {
let initial = try await client.state()
try Task.checkCancellation()
guard self.generation == current else { client.close(); return }
// Persist a discovered address only after pinned, authenticated HTTPS succeeds.
if candidate.endpoint != saved.endpoint {
try KeychainStore.save(candidate)
self.connection = candidate
}
self.accept(initial, client: client)
client.openEvents()
// Abort a silent/stalled stream even if the OS has not detected a lost Wi-Fi link.
@@ -94,6 +116,7 @@ final class PlayerStore: ObservableObject {
}
self.status = "ПК недоступен. Повтор через \(delay) с"
self.error = error.localizedDescription
attempt += 1
}
do { try await Task.sleep(nanoseconds: delay * 1_000_000_000) } catch { return }
delay = min(delay * 2, 16)
+135
View File
@@ -0,0 +1,135 @@
import SwiftUI
import AVFoundation
struct QRScannerSheet: View {
let receive: (String) -> Void
@Environment(\.dismiss) private var dismiss
@Environment(\.scenePhase) private var phase
@State private var error: String?
var body: some View {
NavigationStack {
VStack(spacing: 18) {
Text("Наведите камеру на QR в окне MusicBridge на ПК.")
.multilineTextAlignment(.center)
if let error {
ContentUnavailableView("Камера недоступна", systemImage: "camera", description: Text(error))
Button("Открыть настройки") {
if let url = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(url) }
}
Text("Можно закрыть сканер и ввести данные вручную.").font(.footnote)
} else {
QRScanner(receive: { text in receive(text); dismiss() }, failed: { error = $0 })
.clipShape(RoundedRectangle(cornerRadius: 20))
.accessibilityLabel("Камера для сканирования QR")
}
}.padding()
.navigationTitle("Сканировать QR")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Закрыть") { dismiss() } } }
.onChange(of: phase) { _, value in if value == .background { dismiss() } }
}
}
}
private struct QRScanner: UIViewControllerRepresentable {
let receive: (String) -> Void
let failed: (String) -> Void
func makeUIViewController(context: Context) -> QRScannerController {
QRScannerController(receive: receive, failed: failed)
}
func updateUIViewController(_ controller: QRScannerController, context: Context) {}
static func dismantleUIViewController(_ controller: QRScannerController, coordinator: ()) { controller.stop() }
}
private final class QRScannerController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
private let capture = AVCaptureSession()
private let queue = DispatchQueue(label: "MusicBridge.camera")
private var preview: AVCaptureVideoPreviewLayer?
private var visible = false
private var delivered = false
private var wantsRunning = false // camera queue only
private var configured = false // camera queue only
private let receive: (String) -> Void
private let failed: (String) -> Void
init(receive: @escaping (String) -> Void, failed: @escaping (String) -> Void) {
self.receive = receive; self.failed = failed
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
let preview = AVCaptureVideoPreviewLayer(session: capture)
preview.videoGravity = .resizeAspectFill
view.layer.addSublayer(preview)
self.preview = preview
}
override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews(); preview?.frame = view.bounds }
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
visible = true
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized: start()
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
DispatchQueue.main.async {
guard let self, self.visible else { return }
if granted { self.start() } else { self.failed("Разрешите доступ к камере в настройках MusicBridge.") }
}
}
default: failed("Разрешите доступ к камере в настройках MusicBridge.")
}
}
override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated); stop() }
private func start() {
queue.async { [weak self] in
guard let self else { return }
self.wantsRunning = true
do {
if !self.configured {
self.capture.beginConfiguration()
defer { self.capture.commitConfiguration() }
guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else {
throw BridgeError.message("Задняя камера не найдена. На симуляторе сканирование недоступно.")
}
let input = try AVCaptureDeviceInput(device: camera)
let output = AVCaptureMetadataOutput()
guard self.capture.canAddInput(input), self.capture.canAddOutput(output) else {
throw BridgeError.message("Не удалось запустить камеру. Закройте сканер и повторите попытку.")
}
self.capture.addInput(input); self.capture.addOutput(output)
guard output.availableMetadataObjectTypes.contains(.qr) else { throw BridgeError.message("Сканирование QR недоступно.") }
output.setMetadataObjectsDelegate(self, queue: .main)
output.metadataObjectTypes = [.qr]
self.configured = true
}
if self.wantsRunning { self.capture.startRunning() }
} catch {
DispatchQueue.main.async { [weak self] in
guard let self, self.visible else { return }
self.failed(error.localizedDescription)
}
}
}
}
func stop() {
visible = false
queue.async { [weak self] in
guard let self else { return }
self.wantsRunning = false
if self.capture.isRunning { self.capture.stopRunning() }
}
}
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput objects: [AVMetadataObject], from connection: AVCaptureConnection) {
guard visible, !delivered, let text = objects.compactMap({ ($0 as? AVMetadataMachineReadableCodeObject)?.stringValue }).first else { return }
delivered = true
stop()
receive(text)
}
}