From 397caf23ceb1f3bd4ef66b1f6922fc5b7ae4d665 Mon Sep 17 00:00:00 2001 From: ares Date: Sat, 12 Sep 2026 15:52:31 +0300 Subject: [PATCH] Add camera QR pairing, Bonjour discovery and pinned address recovery --- MusicBridge.xcodeproj/project.pbxproj | 46 +++++++-- MusicBridge/ContentView.swift | 61 ++++++++++-- MusicBridge/DiscoveryStore.swift | 112 +++++++++++++++++++++ MusicBridge/Info.plist | 17 ++++ MusicBridge/Models.swift | 4 +- MusicBridge/MusicBridgeApp.swift | 1 + MusicBridge/PlayerStore.swift | 27 +++++- MusicBridge/QRScanner.swift | 135 ++++++++++++++++++++++++++ MusicBridgeTests/DiscoveryTests.swift | 42 ++++++++ MusicBridgeTests/ProtocolTests.swift | 18 ++++ tools/generate_project.py | 5 +- tools/validate_project.py | 3 + 12 files changed, 451 insertions(+), 20 deletions(-) create mode 100644 MusicBridge/DiscoveryStore.swift create mode 100644 MusicBridge/QRScanner.swift create mode 100644 MusicBridgeTests/DiscoveryTests.swift diff --git a/MusicBridge.xcodeproj/project.pbxproj b/MusicBridge.xcodeproj/project.pbxproj index f825869..280f294 100644 --- a/MusicBridge.xcodeproj/project.pbxproj +++ b/MusicBridge.xcodeproj/project.pbxproj @@ -25,6 +25,16 @@ "isa" = "PBXBuildFile"; "fileRef" = "971C0AE92F167DE7AB145086"; }; + "5F6F408829AB46D3B85FB911" = { + "isa" = "PBXFileReference"; + "lastKnownFileType" = "sourcecode.swift"; + "path" = "DiscoveryStore.swift"; + "sourceTree" = ""; + }; + "842E87CD23DCB5BBCA4340F6" = { + "isa" = "PBXBuildFile"; + "fileRef" = "5F6F408829AB46D3B85FB911"; + }; "76FB6BFFC478599567424755" = { "isa" = "PBXFileReference"; "lastKnownFileType" = "sourcecode.swift"; @@ -65,11 +75,31 @@ "isa" = "PBXBuildFile"; "fileRef" = "9C432A0DA0BEC173839EBF86"; }; + "7148B52E915554F67E875C26" = { + "isa" = "PBXFileReference"; + "lastKnownFileType" = "sourcecode.swift"; + "path" = "QRScanner.swift"; + "sourceTree" = ""; + }; + "BE808A0D13A16FE67D32B7FB" = { + "isa" = "PBXBuildFile"; + "fileRef" = "7148B52E915554F67E875C26"; + }; "026F39792BBA0E5032536CED" = { "isa" = "PBXGroup"; "path" = "MusicBridge"; "sourceTree" = ""; - "children" = ("065A9D559DC80AAF876DA757", "971C0AE92F167DE7AB145086", "76FB6BFFC478599567424755", "124CF3F5AC075D1D6042DF64", "ADF0CD56882201BD3AC1581B", "9C432A0DA0BEC173839EBF86",); + "children" = ("065A9D559DC80AAF876DA757", "971C0AE92F167DE7AB145086", "5F6F408829AB46D3B85FB911", "76FB6BFFC478599567424755", "124CF3F5AC075D1D6042DF64", "ADF0CD56882201BD3AC1581B", "9C432A0DA0BEC173839EBF86", "7148B52E915554F67E875C26",); + }; + "C9F705D432CA45B6056BC572" = { + "isa" = "PBXFileReference"; + "lastKnownFileType" = "sourcecode.swift"; + "path" = "DiscoveryTests.swift"; + "sourceTree" = ""; + }; + "1DD7BB0437E9F53586831F22" = { + "isa" = "PBXBuildFile"; + "fileRef" = "C9F705D432CA45B6056BC572"; }; "ACD0393DCDDDF044F2966340" = { "isa" = "PBXFileReference"; @@ -95,7 +125,7 @@ "isa" = "PBXGroup"; "path" = "MusicBridgeTests"; "sourceTree" = ""; - "children" = ("ACD0393DCDDDF044F2966340", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",); + "children" = ("C9F705D432CA45B6056BC572", "ACD0393DCDDDF044F2966340", "8BED860F1BD59C500D215E8D", "F16D05EC6B29248D2C61ADB1", "4731785CAB9BB914B8834AE6",); }; "F16D05EC6B29248D2C61ADB1" = { "isa" = "PBXFileReference"; @@ -127,7 +157,7 @@ "72B74C8A2143F64FD53D71E5" = { "isa" = "PBXSourcesBuildPhase"; "buildActionMask" = 2147483647; - "files" = ("82F1C33CE4F8B8F641FF0461", "16EA630DE1FE9F0A16580C9D", "DA06B3B12E52FB69A6A34887", "DEDCCD95A140162B6D654871", "48E2FDCA11A6694953C86E6D", "47693934E8A974162CF08A38",); + "files" = ("82F1C33CE4F8B8F641FF0461", "16EA630DE1FE9F0A16580C9D", "842E87CD23DCB5BBCA4340F6", "DA06B3B12E52FB69A6A34887", "DEDCCD95A140162B6D654871", "48E2FDCA11A6694953C86E6D", "47693934E8A974162CF08A38", "BE808A0D13A16FE67D32B7FB",); "runOnlyForDeploymentPostprocessing" = 0; }; "734C4650C7A5604092763C32" = { @@ -156,8 +186,8 @@ "CODE_SIGN_STYLE" = "Automatic"; "GENERATE_INFOPLIST_FILE" = "NO"; "INFOPLIST_FILE" = "MusicBridge/Info.plist"; - "CURRENT_PROJECT_VERSION" = "2"; - "MARKETING_VERSION" = "0.1.1"; + "CURRENT_PROJECT_VERSION" = "3"; + "MARKETING_VERSION" = "0.2.0"; "SWIFT_OPTIMIZATION_LEVEL" = "-Onone"; "ENABLE_TESTABILITY" = "YES"; "SWIFT_ACTIVE_COMPILATION_CONDITIONS" = "DEBUG"; @@ -177,8 +207,8 @@ "CODE_SIGN_STYLE" = "Automatic"; "GENERATE_INFOPLIST_FILE" = "NO"; "INFOPLIST_FILE" = "MusicBridge/Info.plist"; - "CURRENT_PROJECT_VERSION" = "2"; - "MARKETING_VERSION" = "0.1.1"; + "CURRENT_PROJECT_VERSION" = "3"; + "MARKETING_VERSION" = "0.2.0"; "SWIFT_OPTIMIZATION_LEVEL" = "-O"; }; }; @@ -209,7 +239,7 @@ "AB453A3F0E1490D48374A69A" = { "isa" = "PBXSourcesBuildPhase"; "buildActionMask" = 2147483647; - "files" = ("17E248E58CB43C680B48DD51", "DC1A5B0B94F4C98F1CA36676",); + "files" = ("1DD7BB0437E9F53586831F22", "17E248E58CB43C680B48DD51", "DC1A5B0B94F4C98F1CA36676",); "runOnlyForDeploymentPostprocessing" = 0; }; "9AA2A4C344B5C474AF5D336C" = { diff --git a/MusicBridge/ContentView.swift b/MusicBridge/ContentView.swift index aec9b6e..7426881 100644 --- a/MusicBridge/ContentView.swift +++ b/MusicBridge/ContentView.swift @@ -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 } } } diff --git a/MusicBridge/DiscoveryStore.swift b/MusicBridge/DiscoveryStore.swift new file mode 100644 index 0000000..218f6b0 --- /dev/null +++ b/MusicBridge/DiscoveryStore.swift @@ -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.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) + } +} diff --git a/MusicBridge/Info.plist b/MusicBridge/Info.plist index 1a6357f..8a6a496 100644 --- a/MusicBridge/Info.plist +++ b/MusicBridge/Info.plist @@ -30,6 +30,23 @@ NSLocalNetworkUsageDescription MusicBridge подключается к вашему ПК, чтобы показывать музыку и управлять воспроизведением. + NSCameraUsageDescription + Камера сканирует QR в MusicBridge на ПК для безопасного сопряжения. + NSBonjourServices + + _musicbridge._tcp + + CFBundleURLTypes + + + CFBundleURLName + ru.yukinoki.musicbridge.pair + CFBundleURLSchemes + + musicbridge + + + NSAppTransportSecurity NSAllowsLocalNetworking diff --git a/MusicBridge/Models.swift b/MusicBridge/Models.swift index 635c02c..7e5b548 100644 --- a/MusicBridge/Models.swift +++ b/MusicBridge/Models.swift @@ -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) diff --git a/MusicBridge/MusicBridgeApp.swift b/MusicBridge/MusicBridgeApp.swift index b25ce18..8a6c331 100644 --- a/MusicBridge/MusicBridgeApp.swift +++ b/MusicBridge/MusicBridgeApp.swift @@ -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) } } diff --git a/MusicBridge/PlayerStore.swift b/MusicBridge/PlayerStore.swift index a84c8b0..712ef14 100644 --- a/MusicBridge/PlayerStore.swift +++ b/MusicBridge/PlayerStore.swift @@ -10,6 +10,8 @@ final class PlayerStore: ObservableObject { @Published var connected = false @Published var busy = false @Published var pairing = false + @Published var incomingInvitation: String? + let discovery = DiscoveryStore() private(set) var receivedAt = Date() private var client: BridgeClient? private var runner: Task? @@ -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) diff --git a/MusicBridge/QRScanner.swift b/MusicBridge/QRScanner.swift new file mode 100644 index 0000000..9c7261a --- /dev/null +++ b/MusicBridge/QRScanner.swift @@ -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) + } +} diff --git a/MusicBridgeTests/DiscoveryTests.swift b/MusicBridgeTests/DiscoveryTests.swift new file mode 100644 index 0000000..8d31da1 --- /dev/null +++ b/MusicBridgeTests/DiscoveryTests.swift @@ -0,0 +1,42 @@ +import XCTest +import Darwin +@testable import MusicBridge + +final class DiscoveryTests: XCTestCase { + let pin = String(repeating: "A", count: 64) + func testWindowsAdvertisementAndTrustPreservingRelocation() throws { + let txt = ["v": Data("1".utf8), "id": Data(pin.lowercased().utf8)] + let pc = try XCTUnwrap(DiscoveredComputer.parse(name: "MusicBridge-AAAAAAAAAAAA", host: "192.168.1.23", port: 8765, txt: txt)) + let saved = try Connection(endpoint: "https://192.168.1.2:8765", fingerprint: pin, token: "secret") + let moved = try XCTUnwrap(pc.reconnect(saved)) + XCTAssertEqual(moved.endpoint.absoluteString, "https://192.168.1.23:8765") + XCTAssertEqual(moved.fingerprint, saved.fingerprint) + XCTAssertEqual(moved.token, saved.token) + let other = try Connection(endpoint: saved.endpoint.absoluteString, fingerprint: String(repeating: "B", count: 64)) + XCTAssertNil(pc.reconnect(other)) + } + + func testRejectsPublicAddressesUnsupportedVersionAndBadPins() { + let txt = ["v": Data("1".utf8), "id": Data(pin.utf8)] + for host in ["8.8.8.8", "127.0.0.1", "localhost", "192.168.1.2/path"] { + XCTAssertNil(DiscoveredComputer.parse(name: "PC", host: host, port: 8765, txt: txt)) + } + for port in [0, -1, 65536] { + XCTAssertNil(DiscoveredComputer.parse(name: "PC", host: "192.168.1.2", port: port, txt: txt)) + } + XCTAssertNil(DiscoveredComputer.parse(name: "PC", host: "192.168.1.2", port: 8765, txt: ["v": Data("2".utf8), "id": Data(pin.utf8)])) + XCTAssertNil(DiscoveredComputer.parse(name: "PC", host: "192.168.1.2", port: 8765, txt: ["v": Data("1".utf8), "id": Data("bad".utf8)])) + } + + func testParsesBonjourSocketAddressWithoutAcceptingTruncatedOrIPv6Data() { + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + _ = "192.168.1.42".withCString { inet_pton(AF_INET, $0, &address.sin_addr) } + let data = withUnsafeBytes(of: &address) { Data($0) } + XCTAssertEqual(DiscoveryStore.ipv4(data), "192.168.1.42") + XCTAssertNil(DiscoveryStore.ipv4(Data(data.prefix(4)))) + address.sin_family = sa_family_t(AF_INET6) + XCTAssertNil(DiscoveryStore.ipv4(withUnsafeBytes(of: &address) { Data($0) })) + } +} diff --git a/MusicBridgeTests/ProtocolTests.swift b/MusicBridgeTests/ProtocolTests.swift index 72eccf0..d05b303 100644 --- a/MusicBridgeTests/ProtocolTests.swift +++ b/MusicBridgeTests/ProtocolTests.swift @@ -45,6 +45,24 @@ final class ProtocolTests: XCTestCase { XCTAssertFalse(Invitation.validCode("12345678")) } + func testWindowsInvitationTimestampAndMalformedJSON() throws { + // System.Text.Json emits up to seven fractional digits and a numeric UTC offset. + let json = """ + {"version":1,"endpoint":"https://192.168.1.23:8765","certificateSha256":"\(fingerprint)","code":"01234567","expiresAt":"2026-09-12T15:30:00.1234567+03:00"} + """ + func payload(_ json: String) -> String { + "musicbridge://pair?data=" + Data(json.utf8).base64EncodedString() + .replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") + } + let before = try XCTUnwrap(ISO8601DateFormatter().date(from: "2026-09-12T12:29:00Z")) + XCTAssertEqual(try Invitation.parse(payload(json), now: before).code, "01234567") + XCTAssertThrowsError(try Invitation.parse(payload(json), now: before.addingTimeInterval(120))) + XCTAssertThrowsError(try Invitation.parse(payload("{}"), now: before)) { error in + XCTAssertTrue(error is BridgeError) + } + XCTAssertThrowsError(try Invitation.parse("https://example.com")) + } + func testWindowsSnapshotAndTimeline() throws { let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "state-v1", withExtension: "json")) let state = try JSONDecoder().decode(MediaState.self, from: Data(contentsOf: url)) diff --git a/tools/generate_project.py b/tools/generate_project.py index 770e903..c8a7fa3 100644 --- a/tools/generate_project.py +++ b/tools/generate_project.py @@ -61,7 +61,7 @@ for name, sources, is_test in [("MusicBridge", app_sources, False), ("MusicBridg proxy = add("test-proxy", "PBXContainerItemProxy", containerPortal=uid("project"), proxyType=1, remoteGlobalIDString=uid("MusicBridgetarget"), remoteInfo="MusicBridge") deps = [add("test-dependency", "PBXTargetDependency", target=uid("MusicBridgetarget"), targetProxy=proxy)] else: - settings.update(INFOPLIST_FILE="MusicBridge/Info.plist", CURRENT_PROJECT_VERSION="2", MARKETING_VERSION="0.1.1") + settings.update(INFOPLIST_FILE="MusicBridge/Info.plist", CURRENT_PROJECT_VERSION="3", MARKETING_VERSION="0.2.0") targets.append(add(name + "target", "PBXNativeTarget", name=name, productName=name, productReference=product, productType="com.apple.product-type.bundle.unit-test" if is_test else "com.apple.product-type.application", buildConfigurationList=configurations(name, settings), buildPhases=phases, buildRules=[], dependencies=deps)) @@ -106,6 +106,9 @@ info = dict(CFBundleDevelopmentRegion="ru", CFBundleDisplayName="MusicBridge", C LSRequiresIPhoneOS=True, UILaunchScreen={}, UISupportedInterfaceOrientations=["UIInterfaceOrientationPortrait"], NSLocalNetworkUsageDescription="MusicBridge подключается к вашему ПК, чтобы показывать музыку и управлять воспроизведением.", + NSCameraUsageDescription="Камера сканирует QR в MusicBridge на ПК для безопасного сопряжения.", + NSBonjourServices=["_musicbridge._tcp"], + CFBundleURLTypes=[dict(CFBundleURLName="ru.yukinoki.musicbridge.pair", CFBundleURLSchemes=["musicbridge"])], # iOS 17+ requires explicit IP exceptions for custom self-signed trust. # BridgeClient still requires HTTPS, an exact SHA-256 pin and valid dates. # Never disable ATS globally or include public IP ranges. diff --git a/tools/validate_project.py b/tools/validate_project.py index 9dc7f28..a4bf959 100644 --- a/tools/validate_project.py +++ b/tools/validate_project.py @@ -25,6 +25,9 @@ referenced = set(re.findall(r'"([A-F0-9]{24})"', project)) assert defined == referenced, "Project has dangling object references." info = plistlib.loads(generated[2].read_bytes()) assert info["NSLocalNetworkUsageDescription"] +assert info["NSCameraUsageDescription"] +assert info["NSBonjourServices"] == ["_musicbridge._tcp"] +assert info["CFBundleURLTypes"][0]["CFBundleURLSchemes"] == ["musicbridge"] assert info["NSAppTransportSecurity"] == { "NSAllowsLocalNetworking": True, "NSExceptionDomains": {network: {"NSExceptionAllowsInsecureHTTPLoads": True}