Add camera QR pairing, Bonjour discovery and pinned address recovery
This commit is contained in:
@@ -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" = {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user