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
+38 -8
View File
@@ -25,6 +25,16 @@
"isa" = "PBXBuildFile";
"fileRef" = "971C0AE92F167DE7AB145086";
};
"5F6F408829AB46D3B85FB911" = {
"isa" = "PBXFileReference";
"lastKnownFileType" = "sourcecode.swift";
"path" = "DiscoveryStore.swift";
"sourceTree" = "<group>";
};
"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" = "<group>";
};
"BE808A0D13A16FE67D32B7FB" = {
"isa" = "PBXBuildFile";
"fileRef" = "7148B52E915554F67E875C26";
};
"026F39792BBA0E5032536CED" = {
"isa" = "PBXGroup";
"path" = "MusicBridge";
"sourceTree" = "<group>";
"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" = "<group>";
};
"1DD7BB0437E9F53586831F22" = {
"isa" = "PBXBuildFile";
"fileRef" = "C9F705D432CA45B6056BC572";
};
"ACD0393DCDDDF044F2966340" = {
"isa" = "PBXFileReference";
@@ -95,7 +125,7 @@
"isa" = "PBXGroup";
"path" = "MusicBridgeTests";
"sourceTree" = "<group>";
"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" = {
+51 -6
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,18 +106,31 @@ private struct PairingView: View {
TextField("musicbridge://pair?data=…", text: $invitation, axis: .vertical)
.lineLimit(3...5).textInputAutocapitalization(.never).autocorrectionDisabled()
Button("Заполнить из приглашения") {
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(invitation.trimmingCharacters(in: .whitespacesAndNewlines))
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 }
}
}.padding(.top, 12)
}.disabled(store.pairing)
}
}
}
private struct PlayerView: View {
+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)
}
}
+42
View File
@@ -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<sockaddr_in>.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) }))
}
}
+18
View File
@@ -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))
+4 -1
View File
@@ -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.
+3
View File
@@ -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}