@mentra/glasses-media 3.2.1 → 3.3.0-beta.1001
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -2
- package/android/src/main/java/com/mentra/glassesmedia/source/StillPhotoInbox.kt +50 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/WhipIngestProtocol.kt +22 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/WhipIngestServer.kt +110 -10
- package/index.d.ts +11 -0
- package/index.js +9 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/HotspotAccessPurpose.swift +5 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/HotspotInterfacePolicy.swift +155 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/HotspotJoinFailure.swift +23 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/HotspotNetworkOwnership.swift +22 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/MacHotspotAssociation.swift +161 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/MacHotspotAuthorization.swift +59 -0
- package/ios/GlassesHotspotNetwork.swift +254 -73
- package/ios/GlassesMedia.podspec +1 -1
- package/ios/GlassesMediaRelayModule.swift +61 -11
- package/package.json +6 -1
- package/ios/CoreKit/Sources/GlassesMediaCore/MacE2EHotspotLease.swift +0 -20
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import CoreLocation
|
|
2
|
+
import Foundation
|
|
3
|
+
import ObjectiveC
|
|
4
|
+
|
|
5
|
+
/// Blocking public CoreWLAN calls are isolated here; the existing hotspot owner
|
|
6
|
+
/// retains custody until they return, even after cancellation or a timeout.
|
|
7
|
+
final class MacHotspotAssociation {
|
|
8
|
+
private let lock = NSLock()
|
|
9
|
+
private var cancelled = false
|
|
10
|
+
private var interface: MacWifiInterface?
|
|
11
|
+
private var joinedSSID: String?
|
|
12
|
+
private let makeInterface: () throws -> MacWifiInterface
|
|
13
|
+
|
|
14
|
+
init(makeInterface: @escaping () throws -> MacWifiInterface = { try PublicCoreWlanInterface() }) {
|
|
15
|
+
self.makeInterface = makeInterface
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func associate(ssid: String, password: String) throws -> String {
|
|
19
|
+
try checkCurrent()
|
|
20
|
+
let interface = try makeInterface()
|
|
21
|
+
self.interface = interface
|
|
22
|
+
if try interface.currentSSID() != ssid {
|
|
23
|
+
let network = try interface.network(ssid: ssid)
|
|
24
|
+
try checkCurrent()
|
|
25
|
+
// Record the intended association before this blocking call. A late
|
|
26
|
+
// successful join still belongs to us and must be cleaned up.
|
|
27
|
+
joinedSSID = ssid
|
|
28
|
+
try interface.associate(network: network, password: password)
|
|
29
|
+
}
|
|
30
|
+
try checkCurrent()
|
|
31
|
+
guard try interface.currentSSID() == ssid else {
|
|
32
|
+
throw LocalMediaError("Mac Wi-Fi did not associate with the requested glasses hotspot")
|
|
33
|
+
}
|
|
34
|
+
return try interface.name()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func cancel() {
|
|
38
|
+
lock.lock(); cancelled = true; lock.unlock()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// Run on the same worker as association, after its blocking call returns.
|
|
42
|
+
/// A connection the user replaced must never be disconnected by our cleanup.
|
|
43
|
+
func leave() throws {
|
|
44
|
+
if let joinedSSID {
|
|
45
|
+
if try interface?.currentSSID() == joinedSSID {
|
|
46
|
+
interface?.disassociate()
|
|
47
|
+
guard try interface?.currentSSID() != joinedSSID else {
|
|
48
|
+
throw LocalMediaError("Mac Wi-Fi has not released the glasses hotspot")
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
interface = nil
|
|
53
|
+
joinedSSID = nil
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func currentSSID() throws -> String? {
|
|
57
|
+
try interface?.currentSSID()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private func checkCurrent() throws {
|
|
61
|
+
lock.lock(); let stopped = cancelled; lock.unlock()
|
|
62
|
+
if stopped { throw CancellationError() }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
protocol MacWifiInterface: AnyObject {
|
|
67
|
+
func name() throws -> String
|
|
68
|
+
func currentSSID() throws -> String?
|
|
69
|
+
func network(ssid: String) throws -> NSObject
|
|
70
|
+
func associate(network: NSObject, password: String) throws
|
|
71
|
+
func disassociate()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
enum MacWifiInvocation {
|
|
75
|
+
// Objective-C NSError** parameters use autoreleasing writeback. Strong
|
|
76
|
+
// pointer storage would over-release errors when the native pool drains.
|
|
77
|
+
typealias Scan = @convention(c) (AnyObject, Selector, NSData, AutoreleasingUnsafeMutablePointer<NSError?>) -> Unmanaged<NSSet>?
|
|
78
|
+
typealias Associate = @convention(c) (AnyObject, Selector, AnyObject, NSString, AutoreleasingUnsafeMutablePointer<NSError?>) -> Bool
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// CoreWLAN is a public macOS framework. Foundation loads it only in the Mac
|
|
82
|
+
/// process; Apple dyld permits macOS frameworks in iOS-on-Mac. The checked
|
|
83
|
+
/// Objective-C signatures below are the public CWInterface SDK declarations.
|
|
84
|
+
private final class PublicCoreWlanInterface: MacWifiInterface {
|
|
85
|
+
private let bundle: Bundle
|
|
86
|
+
private let interface: NSObject
|
|
87
|
+
private typealias Disassociate = @convention(c) (AnyObject, Selector) -> Void
|
|
88
|
+
|
|
89
|
+
init() throws {
|
|
90
|
+
guard let bundle = Bundle(path: "/System/Library/Frameworks/CoreWLAN.framework") else {
|
|
91
|
+
throw LocalMediaError("Mac Wi-Fi framework is unavailable")
|
|
92
|
+
}
|
|
93
|
+
do { try bundle.loadAndReturnError() }
|
|
94
|
+
catch { throw Self.failure("framework loading", error as NSError) }
|
|
95
|
+
guard let clientClass = NSClassFromString("CWWiFiClient") as? NSObject.Type,
|
|
96
|
+
clientClass.responds(to: NSSelectorFromString("sharedWiFiClient")),
|
|
97
|
+
let client = clientClass.perform(NSSelectorFromString("sharedWiFiClient"))?.takeUnretainedValue() as? NSObject,
|
|
98
|
+
client.responds(to: NSSelectorFromString("interface")),
|
|
99
|
+
let interface = client.perform(NSSelectorFromString("interface"))?.takeUnretainedValue() as? NSObject
|
|
100
|
+
else { throw LocalMediaError("Mac Wi-Fi interface is unavailable") }
|
|
101
|
+
for method in ["interfaceName", "ssidData", "scanForNetworksWithSSID:error:", "associateToNetwork:password:error:", "disassociate"] {
|
|
102
|
+
guard interface.responds(to: NSSelectorFromString(method)) else {
|
|
103
|
+
throw LocalMediaError("Mac Wi-Fi interface does not support hotspot association")
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
self.interface = interface
|
|
107
|
+
self.bundle = bundle
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
func name() throws -> String {
|
|
111
|
+
guard let name = interface.perform(NSSelectorFromString("interfaceName"))?.takeUnretainedValue() as? String,
|
|
112
|
+
!name.isEmpty else { throw LocalMediaError("Mac Wi-Fi interface has no identity") }
|
|
113
|
+
return name
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
func currentSSID() throws -> String? {
|
|
117
|
+
let authorization = CLLocationManager.authorizationStatus()
|
|
118
|
+
guard MacHotspotAuthorization.isAuthorized(authorization) else {
|
|
119
|
+
throw LocalMediaError("Location access is required to verify the current Mac Wi-Fi network")
|
|
120
|
+
}
|
|
121
|
+
guard let data = interface.perform(NSSelectorFromString("ssidData"))?.takeUnretainedValue() as? Data else { return nil }
|
|
122
|
+
return String(data: data, encoding: .utf8)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func network(ssid: String) throws -> NSObject {
|
|
126
|
+
let selector = NSSelectorFromString("scanForNetworksWithSSID:error:")
|
|
127
|
+
let invoke = unsafeBitCast(interface.method(for: selector), to: MacWifiInvocation.Scan.self)
|
|
128
|
+
var error: NSError?
|
|
129
|
+
guard let networks = invoke(interface, selector, NSData(data: Data(ssid.utf8)), &error)?.takeUnretainedValue() else {
|
|
130
|
+
throw Self.failure("scan", error)
|
|
131
|
+
}
|
|
132
|
+
// A directed scan can include unrelated networks. Match the requested
|
|
133
|
+
// SSID bytes without reading or logging BSSID or other scan identities.
|
|
134
|
+
for case let network as NSObject in networks {
|
|
135
|
+
guard network.responds(to: NSSelectorFromString("ssidData")),
|
|
136
|
+
let data = network.perform(NSSelectorFromString("ssidData"))?.takeUnretainedValue() as? Data,
|
|
137
|
+
data == Data(ssid.utf8) else { continue }
|
|
138
|
+
return network
|
|
139
|
+
}
|
|
140
|
+
throw LocalMediaError("The requested glasses hotspot was not found by Mac Wi-Fi")
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
func associate(network: NSObject, password: String) throws {
|
|
144
|
+
let selector = NSSelectorFromString("associateToNetwork:password:error:")
|
|
145
|
+
let invoke = unsafeBitCast(interface.method(for: selector), to: MacWifiInvocation.Associate.self)
|
|
146
|
+
var error: NSError?
|
|
147
|
+
guard invoke(interface, selector, network, password as NSString, &error) else { throw Self.failure("association", error) }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
func disassociate() {
|
|
151
|
+
let selector = NSSelectorFromString("disassociate")
|
|
152
|
+
let invoke = unsafeBitCast(interface.method(for: selector), to: Disassociate.self)
|
|
153
|
+
invoke(interface, selector)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private static func failure(_ operation: String, _ error: NSError?) -> Error {
|
|
157
|
+
let domain = error?.domain ?? "unavailable"
|
|
158
|
+
let safeDomain = domain.range(of: "^[A-Za-z0-9_.-]{1,128}$", options: .regularExpression) == nil ? "unavailable" : domain
|
|
159
|
+
return LocalMediaError("Mac Wi-Fi \(operation) failed (\(safeDomain), \(error?.code ?? -1))")
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import CoreLocation
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// CoreWLAN requires Location authorization to identify the requested network.
|
|
5
|
+
/// Use the app's existing system permission, with no separate connection screen.
|
|
6
|
+
final class MacHotspotAuthorization: NSObject, CLLocationManagerDelegate {
|
|
7
|
+
private var manager: CLLocationManager?
|
|
8
|
+
private var completion: ((Result<Void, Error>) -> Void)?
|
|
9
|
+
private var finished = false
|
|
10
|
+
|
|
11
|
+
func start(completion: @escaping (Result<Void, Error>) -> Void) {
|
|
12
|
+
DispatchQueue.main.async {
|
|
13
|
+
guard !self.finished else { completion(.failure(CancellationError())); return }
|
|
14
|
+
self.completion = completion
|
|
15
|
+
let manager = CLLocationManager()
|
|
16
|
+
self.manager = manager
|
|
17
|
+
manager.delegate = self
|
|
18
|
+
self.handle(manager.authorizationStatus)
|
|
19
|
+
if manager.authorizationStatus == .notDetermined { manager.requestWhenInUseAuthorization() }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func cancel() {
|
|
24
|
+
DispatchQueue.main.async { self.finish(.failure(CancellationError())) }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
|
28
|
+
handle(manager.authorizationStatus)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static func isAuthorized(_ status: CLAuthorizationStatus) -> Bool {
|
|
32
|
+
if status == .authorizedAlways { return true }
|
|
33
|
+
#if !os(macOS)
|
|
34
|
+
if status == .authorizedWhenInUse { return true }
|
|
35
|
+
#endif
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private func handle(_ status: CLAuthorizationStatus) {
|
|
40
|
+
if Self.isAuthorized(status) { finish(.success(())); return }
|
|
41
|
+
switch status {
|
|
42
|
+
case .denied, .restricted:
|
|
43
|
+
finish(.failure(LocalMediaError("Location access is required to identify the glasses hotspot on Mac")))
|
|
44
|
+
case .notDetermined: break
|
|
45
|
+
default:
|
|
46
|
+
finish(.failure(LocalMediaError("Mac hotspot Location authorization is unavailable")))
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private func finish(_ result: Result<Void, Error>) {
|
|
51
|
+
guard !finished else { return }
|
|
52
|
+
finished = true
|
|
53
|
+
manager?.delegate = nil
|
|
54
|
+
manager = nil
|
|
55
|
+
let reply = completion
|
|
56
|
+
completion = nil
|
|
57
|
+
reply?(result)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -4,81 +4,65 @@ import Network
|
|
|
4
4
|
import NetworkExtension
|
|
5
5
|
import OSLog
|
|
6
6
|
|
|
7
|
-
///
|
|
7
|
+
/// Shared hotspot association owner. Only local glasses traffic uses Wi-Fi.
|
|
8
8
|
public final class GlassesHotspotNetwork {
|
|
9
9
|
private let logger = Logger(subsystem: "com.mentra.glassesmedia", category: "hotspot")
|
|
10
10
|
private let queue = DispatchQueue(label: "com.mentra.glassesmedia.hotspot")
|
|
11
11
|
private var ssid: String?
|
|
12
12
|
private var lastHotspotSSID: String?
|
|
13
|
-
|
|
13
|
+
/// The verified Wi-Fi interface and DHCP address. Every later check uses this exact pair.
|
|
14
|
+
private var binding: HotspotInterfaceBinding?
|
|
15
|
+
private var accessPurpose: HotspotAccessPurpose = .outgoingClient
|
|
14
16
|
private var gatewayAddress: String?
|
|
15
17
|
private var generation = 0
|
|
16
18
|
private var applying = false
|
|
17
19
|
private var ownsConfiguration = false
|
|
18
20
|
private var cancelled = false
|
|
19
21
|
private var joinReply: ((Result<String, Error>) -> Void)?
|
|
20
|
-
private var leaveReplies: [() -> Void] = []
|
|
21
|
-
private var
|
|
22
|
+
private var leaveReplies: [(Result<Void, Error>) -> Void] = []
|
|
23
|
+
private var interfaceMonitors: [NWPathMonitor] = []
|
|
24
|
+
/// Wi-Fi identity learned since this join started; replaced on every join and cleared on leave.
|
|
25
|
+
private var wifiIdentity = HotspotWifiIdentity()
|
|
22
26
|
private var localAccess: LocalNetworkAccessRequest?
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
private var localAddress: String? {
|
|
28
|
+
binding?.address
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private let macWorker = DispatchQueue(label: "com.mentra.glassesmedia.mac-wifi")
|
|
32
|
+
private var macAssociation: MacHotspotAssociation?
|
|
33
|
+
private var macAuthorization: MacHotspotAuthorization?
|
|
34
|
+
private var ownershipToken: UUID?
|
|
35
|
+
private var finishingLeave = false
|
|
26
36
|
public var onLost: ((String) -> Void)?
|
|
27
37
|
public var onPermissionRequired: (() -> Void)?
|
|
28
38
|
public init() {}
|
|
29
39
|
|
|
30
|
-
public func join(ssid: String, passphrase: String, gateway: String? = nil, completion: @escaping (Result<String, Error>) -> Void) {
|
|
40
|
+
public func join(ssid: String, passphrase: String, gateway: String? = nil, purpose: HotspotAccessPurpose = .outgoingClient, completion: @escaping (Result<String, Error>) -> Void) {
|
|
31
41
|
queue.async {
|
|
32
42
|
guard self.ssid == nil, !self.applying else { completion(.failure(LocalMediaError("Previous hotspot session has not finished cleaning up"))); return }
|
|
33
43
|
if let gateway, !LocalMediaPolicy.isPrivate(gateway) {
|
|
34
44
|
completion(.failure(LocalMediaError("The glasses reported an invalid hotspot gateway"))); return
|
|
35
45
|
}
|
|
46
|
+
if ProcessInfo.processInfo.isiOSAppOnMac, gateway == nil {
|
|
47
|
+
completion(.failure(LocalMediaError("The glasses did not report their hotspot gateway"))); return
|
|
48
|
+
}
|
|
49
|
+
guard let token = HotspotNetworkOwnership.shared.acquire() else {
|
|
50
|
+
completion(.failure(LocalMediaError("The glasses hotspot is already in use"))); return
|
|
51
|
+
}
|
|
52
|
+
self.ownershipToken = token
|
|
36
53
|
self.generation += 1
|
|
37
54
|
let gen = self.generation
|
|
38
55
|
self.ssid = ssid
|
|
39
56
|
self.lastHotspotSSID = ssid
|
|
40
57
|
self.gatewayAddress = gateway
|
|
58
|
+
self.accessPurpose = purpose
|
|
41
59
|
self.cancelled = false
|
|
42
60
|
self.applying = false
|
|
43
61
|
self.ownsConfiguration = false
|
|
44
62
|
self.joinReply = completion
|
|
63
|
+
self.startInterfaceMonitors(generation: gen)
|
|
45
64
|
if ProcessInfo.processInfo.isiOSAppOnMac {
|
|
46
|
-
|
|
47
|
-
if let raw = ProcessInfo.processInfo.environment["MENTRA_E2E_PREJOINED_HOTSPOT"] {
|
|
48
|
-
let address = Self.wifiAddress()
|
|
49
|
-
guard !self.testLeaseConsumed,
|
|
50
|
-
let lease = try? JSONDecoder().decode(MacE2EHotspotLease.self, from: Data(raw.utf8)),
|
|
51
|
-
lease.matches(ssid: ssid, gateway: gateway, address: address, now: Date().timeIntervalSince1970),
|
|
52
|
-
let address
|
|
53
|
-
else {
|
|
54
|
-
self.finishJoin(.failure(LocalMediaError("Mac E2E network lease is invalid, expired or already consumed")))
|
|
55
|
-
self.finishLeave()
|
|
56
|
-
return
|
|
57
|
-
}
|
|
58
|
-
self.testLeaseConsumed = true
|
|
59
|
-
self.localAddress = address
|
|
60
|
-
self.logger.notice("HOTSPOT_JOIN test_harness_connection native_association_untested")
|
|
61
|
-
self.startMonitor(generation: gen)
|
|
62
|
-
self.waitForLocalAccess(address: address, generation: gen)
|
|
63
|
-
return
|
|
64
|
-
}
|
|
65
|
-
#endif
|
|
66
|
-
NEHotspotNetwork.fetchCurrent { network in
|
|
67
|
-
self.queue.async {
|
|
68
|
-
guard gen == self.generation, !self.cancelled else { return }
|
|
69
|
-
let address = Self.wifiAddress()
|
|
70
|
-
if let address, LocalMediaPolicy.canReuseHotspot(requestedSSID: ssid, currentSSID: network?.ssid,
|
|
71
|
-
address: address, gateway: gateway)
|
|
72
|
-
{
|
|
73
|
-
self.localAddress = address
|
|
74
|
-
self.logger.info("HOTSPOT_JOIN reuse_verified_macos_connection")
|
|
75
|
-
self.startMonitor(generation: gen)
|
|
76
|
-
self.waitForLocalAccess(address: address, generation: gen)
|
|
77
|
-
} else {
|
|
78
|
-
self.applyConfiguration(ssid: ssid, passphrase: passphrase, generation: gen)
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
65
|
+
self.authorizeMacAssociation(ssid: ssid, passphrase: passphrase, generation: gen)
|
|
82
66
|
} else {
|
|
83
67
|
self.applyConfiguration(ssid: ssid, passphrase: passphrase, generation: gen)
|
|
84
68
|
}
|
|
@@ -87,6 +71,8 @@ public final class GlassesHotspotNetwork {
|
|
|
87
71
|
// Its response time is not a hotspot association timeout.
|
|
88
72
|
guard gen == self.generation, self.joinReply != nil, self.localAccess == nil else { return }
|
|
89
73
|
self.cancelled = true
|
|
74
|
+
self.macAssociation?.cancel()
|
|
75
|
+
self.macAuthorization?.cancel()
|
|
90
76
|
self.finishJoin(.failure(LocalMediaError("Hotspot join timed out")))
|
|
91
77
|
// apply() cannot be cancelled. Retain the reservation until its callback and remove the
|
|
92
78
|
// late configuration before another call is permitted to acquire the network.
|
|
@@ -96,9 +82,11 @@ public final class GlassesHotspotNetwork {
|
|
|
96
82
|
}
|
|
97
83
|
}
|
|
98
84
|
|
|
99
|
-
public func leave(completion: @escaping () -> Void) {
|
|
85
|
+
public func leave(completion: @escaping (Result<Void, Error>) -> Void) {
|
|
100
86
|
queue.async {
|
|
101
87
|
self.cancelled = true
|
|
88
|
+
self.macAssociation?.cancel()
|
|
89
|
+
self.macAuthorization?.cancel()
|
|
102
90
|
self.leaveReplies.append(completion)
|
|
103
91
|
self.finishJoin(.failure(LocalMediaError("Hotspot join cancelled")))
|
|
104
92
|
if let ssid = self.ssid, self.ownsConfiguration { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
|
|
@@ -106,6 +94,24 @@ public final class GlassesHotspotNetwork {
|
|
|
106
94
|
}
|
|
107
95
|
}
|
|
108
96
|
|
|
97
|
+
/// The verified hotspot address while its own Wi-Fi interface still holds it; nil after
|
|
98
|
+
/// loss, leave or an address change. Never falls back to another interface.
|
|
99
|
+
public func verifiedAddress(completion: @escaping (String?) -> Void) {
|
|
100
|
+
queue.async {
|
|
101
|
+
guard let binding = self.binding, !self.cancelled, self.isBindingIntact(binding) else { completion(nil); return }
|
|
102
|
+
if let association = self.macAssociation {
|
|
103
|
+
let ssid = self.ssid
|
|
104
|
+
let gen = self.generation
|
|
105
|
+
self.macWorker.async {
|
|
106
|
+
let matched = (try? association.currentSSID()) == ssid
|
|
107
|
+
self.queue.async {
|
|
108
|
+
completion(gen == self.generation && !self.cancelled && matched && self.isBindingIntact(binding) ? binding.address : nil)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} else { completion(binding.address) }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
109
115
|
public func info(completion: @escaping ([String: Any]) -> Void) {
|
|
110
116
|
queue.async {
|
|
111
117
|
var value: [String: Any] = ["available": self.localAddress != nil]
|
|
@@ -124,7 +130,7 @@ public final class GlassesHotspotNetwork {
|
|
|
124
130
|
let parameters = NWParameters.tcp
|
|
125
131
|
if ProcessInfo.processInfo.isiOSAppOnMac {
|
|
126
132
|
// The Mac route probe rejects the Wi-Fi type constraint on an otherwise
|
|
127
|
-
// usable
|
|
133
|
+
// usable hotspot route. Restrict this connection to the verified source IP.
|
|
128
134
|
parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host(address), port: .any)
|
|
129
135
|
} else {
|
|
130
136
|
parameters.requiredInterfaceType = .wifi
|
|
@@ -188,6 +194,72 @@ public final class GlassesHotspotNetwork {
|
|
|
188
194
|
}
|
|
189
195
|
}
|
|
190
196
|
|
|
197
|
+
private func authorizeMacAssociation(ssid: String, passphrase: String, generation gen: Int) {
|
|
198
|
+
let authorization = MacHotspotAuthorization()
|
|
199
|
+
macAuthorization = authorization
|
|
200
|
+
authorization.start { result in
|
|
201
|
+
self.queue.async {
|
|
202
|
+
guard gen == self.generation, !self.cancelled else { return }
|
|
203
|
+
self.macAuthorization = nil
|
|
204
|
+
switch result {
|
|
205
|
+
case .success: self.applyMacAssociation(ssid: ssid, passphrase: passphrase, generation: gen)
|
|
206
|
+
case let .failure(error):
|
|
207
|
+
self.finishJoin(.failure(error)); self.finishLeave()
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private func applyMacAssociation(ssid: String, passphrase: String, generation gen: Int) {
|
|
214
|
+
let association = MacHotspotAssociation()
|
|
215
|
+
macAssociation = association
|
|
216
|
+
applying = true
|
|
217
|
+
macWorker.async {
|
|
218
|
+
let result = Result { try association.associate(ssid: ssid, password: passphrase) }
|
|
219
|
+
self.queue.async {
|
|
220
|
+
guard gen == self.generation else { return }
|
|
221
|
+
self.applying = false
|
|
222
|
+
if self.cancelled { self.finishLeave(); return }
|
|
223
|
+
switch result {
|
|
224
|
+
case let .success(name):
|
|
225
|
+
let index = Int(if_nametoindex(name))
|
|
226
|
+
guard index > 0 else {
|
|
227
|
+
self.finishJoin(.failure(LocalMediaError("Mac Wi-Fi interface disappeared during association")))
|
|
228
|
+
self.finishLeave(); return
|
|
229
|
+
}
|
|
230
|
+
self.wifiIdentity.observe([HotspotInterfaceReport(name: name, index: index, isWifi: true)])
|
|
231
|
+
self.waitForMacAddress(ssid: ssid, association: association, generation: gen, remaining: 60)
|
|
232
|
+
case let .failure(error):
|
|
233
|
+
self.finishJoin(.failure(error)); self.finishLeave()
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private func waitForMacAddress(ssid: String, association: MacHotspotAssociation, generation gen: Int, remaining: Int) {
|
|
240
|
+
guard gen == generation, !cancelled else { return }
|
|
241
|
+
macWorker.async {
|
|
242
|
+
let membership = Result { try association.currentSSID() == ssid }
|
|
243
|
+
self.queue.async {
|
|
244
|
+
guard gen == self.generation, !self.cancelled else { return }
|
|
245
|
+
if case let .failure(error) = membership {
|
|
246
|
+
self.finishJoin(.failure(error)); self.finishLeave(); return
|
|
247
|
+
}
|
|
248
|
+
if (try? membership.get()) == true, let selected = self.selectBinding() {
|
|
249
|
+
self.bind(selected)
|
|
250
|
+
self.finishAssociation(address: selected.address, generation: gen)
|
|
251
|
+
} else if remaining > 0 {
|
|
252
|
+
self.queue.asyncAfter(deadline: .now() + 0.5) {
|
|
253
|
+
self.waitForMacAddress(ssid: ssid, association: association, generation: gen, remaining: remaining - 1)
|
|
254
|
+
}
|
|
255
|
+
} else {
|
|
256
|
+
self.finishJoin(.failure(LocalMediaError("Mac Wi-Fi has no verified glasses hotspot address")))
|
|
257
|
+
self.finishLeave()
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
191
263
|
private func applyConfiguration(ssid: String, passphrase: String, generation gen: Int) {
|
|
192
264
|
let config = NEHotspotConfiguration(ssid: ssid, passphrase: passphrase, isWEP: false)
|
|
193
265
|
config.joinOnce = false
|
|
@@ -221,34 +293,93 @@ public final class GlassesHotspotNetwork {
|
|
|
221
293
|
NEHotspotNetwork.fetchCurrent { [weak self] network in
|
|
222
294
|
self?.queue.async {
|
|
223
295
|
guard let self, gen == self.generation, !self.cancelled else { return }
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
self.
|
|
228
|
-
self.startMonitor(generation: gen)
|
|
229
|
-
self.waitForLocalAccess(address: address, generation: gen)
|
|
296
|
+
// The selection applies the BLE gateway's client check when one was reported.
|
|
297
|
+
if network?.ssid == ssid, let selected = self.selectBinding() {
|
|
298
|
+
self.bind(selected)
|
|
299
|
+
self.finishAssociation(address: selected.address, generation: gen)
|
|
230
300
|
} else if remaining > 0 {
|
|
231
301
|
self.queue.asyncAfter(deadline: .now() + 0.5) { self.waitForAddress(ssid: ssid, generation: gen, remaining: remaining - 1) }
|
|
232
302
|
} else {
|
|
233
303
|
let association = network == nil ? "unavailable" : (network?.ssid == ssid ? "matched" : "different")
|
|
234
|
-
|
|
235
|
-
self.finishJoin(.failure(LocalMediaError("Glasses hotspot has no verified Wi-Fi address (SSID=\(association), Wi-Fi IPv4=\(address))")))
|
|
304
|
+
self.finishJoin(.failure(LocalMediaError("Glasses hotspot has no verified Wi-Fi address (SSID=\(association), \(self.wifiDiagnostic()))")))
|
|
236
305
|
self.finishLeave()
|
|
237
306
|
}
|
|
238
307
|
}
|
|
239
308
|
}
|
|
240
309
|
}
|
|
241
310
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
311
|
+
/// iOS can omit an internet-less Wi-Fi from its default path, and iOS-on-Mac has rejected
|
|
312
|
+
/// a Wi-Fi type constraint on a usable hotspot route. Both paths' interface types feed one
|
|
313
|
+
/// session identity (HotspotWifiIdentity): a positive Wi-Fi report outlives later omission
|
|
314
|
+
/// only while the same interface stays attached; contrary reports and replacement fail closed.
|
|
315
|
+
/// A phone that starts on cellular never gets a report; HotspotInterfacePolicy then uses
|
|
316
|
+
/// the hotspot client address as the evidence.
|
|
317
|
+
private func startInterfaceMonitors(generation gen: Int) {
|
|
318
|
+
interfaceMonitors.forEach { $0.cancel() }
|
|
319
|
+
let monitors = [NWPathMonitor(requiredInterfaceType: .wifi), NWPathMonitor()]
|
|
320
|
+
interfaceMonitors = monitors
|
|
321
|
+
wifiIdentity = HotspotWifiIdentity()
|
|
322
|
+
for monitor in monitors {
|
|
323
|
+
monitor.pathUpdateHandler = { [weak self] path in
|
|
324
|
+
guard let self, gen == self.generation else { return }
|
|
325
|
+
self.wifiIdentity.observe(path.availableInterfaces.map {
|
|
326
|
+
HotspotInterfaceReport(name: $0.name, index: $0.index, isWifi: $0.type == .wifi)
|
|
327
|
+
})
|
|
328
|
+
// This AP intentionally has no internet. Loss of its default internet path is not
|
|
329
|
+
// loss of the local link; check the verified interface and address instead.
|
|
330
|
+
if let binding = self.binding, !self.cancelled, !self.isBindingIntact(binding) {
|
|
331
|
+
self.onLost?("Glasses hotspot connection was lost")
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
monitor.start(queue: queue)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/// Every check samples interfaces first, so a detach or replacement between path updates
|
|
339
|
+
/// is recorded in the session identity before it is used.
|
|
340
|
+
private func observeInterfaces() -> HotspotInterfaceSnapshot {
|
|
341
|
+
let snapshot = Self.interfaceSnapshot()
|
|
342
|
+
wifiIdentity.observe(snapshot)
|
|
343
|
+
return snapshot
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private func selectBinding() -> HotspotInterfaceBinding? {
|
|
347
|
+
HotspotInterfacePolicy.select(snapshot: observeInterfaces(), identity: wifiIdentity, gateway: gatewayAddress)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private func isBindingIntact(_ binding: HotspotInterfaceBinding) -> Bool {
|
|
351
|
+
let snapshot = observeInterfaces()
|
|
352
|
+
return HotspotInterfacePolicy.isIntact(binding, snapshot: snapshot, identity: wifiIdentity)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private func bind(_ selected: HotspotInterfaceBinding) {
|
|
356
|
+
let evidence = wifiIdentity.trusted[selected.interface] == selected.index ? "wifi_report" : "hotspot_client"
|
|
357
|
+
wifiIdentity.adopt(selected)
|
|
358
|
+
binding = selected
|
|
359
|
+
logger.info("HOTSPOT_JOIN address_verified interface=\(selected.interface, privacy: .public) evidence=\(evidence, privacy: .public)")
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/// Interface names and their IPv4 addresses only; never credentials or SSIDs.
|
|
363
|
+
private func wifiDiagnostic() -> String {
|
|
364
|
+
let snapshot = observeInterfaces()
|
|
365
|
+
let wifi = Set(wifiIdentity.trusted.keys)
|
|
366
|
+
let addresses = snapshot.addresses.filter { wifi.contains($0.name) }.map { "\($0.name)=\($0.ipv4)" }
|
|
367
|
+
let other = snapshot.addresses.filter { !wifi.contains($0.name) && LocalMediaPolicy.isPrivate($0.ipv4) }
|
|
368
|
+
.map { "\($0.name)\(snapshot.ethernetLinks.contains($0.name) ? "" : "(non-ethernet)")=\($0.ipv4)" }
|
|
369
|
+
return "Wi-Fi interfaces=\(wifi.isEmpty ? "none" : wifi.sorted().joined(separator: ",")), " +
|
|
370
|
+
"Wi-Fi IPv4=\(addresses.isEmpty ? "none" : addresses.sorted().joined(separator: ",")), " +
|
|
371
|
+
"other private IPv4=\(other.isEmpty ? "none" : other.sorted().joined(separator: ","))"
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private func finishAssociation(address: String, generation gen: Int) {
|
|
375
|
+
// OTA serves incoming TCP. Its native listener proves server readiness; dialing
|
|
376
|
+
// the glasses' unrelated Gallery server would add a client permission dependency.
|
|
377
|
+
// TN3179: accepting incoming TCP does not require Local Network access.
|
|
378
|
+
if accessPurpose == .incomingServer {
|
|
379
|
+
finishJoin(.success(address))
|
|
380
|
+
} else {
|
|
381
|
+
waitForLocalAccess(address: address, generation: gen)
|
|
250
382
|
}
|
|
251
|
-
monitor.start(queue: queue)
|
|
252
383
|
}
|
|
253
384
|
|
|
254
385
|
private func waitForLocalAccess(address: String, generation gen: Int) {
|
|
@@ -279,34 +410,84 @@ public final class GlassesHotspotNetwork {
|
|
|
279
410
|
}
|
|
280
411
|
|
|
281
412
|
private func finishLeave() {
|
|
413
|
+
guard !finishingLeave else { return }
|
|
414
|
+
finishingLeave = true
|
|
415
|
+
if let association = macAssociation {
|
|
416
|
+
association.cancel()
|
|
417
|
+
// This queue is also the association queue: cleanup cannot overtake
|
|
418
|
+
// a blocking scan/join, and process-wide ownership remains held.
|
|
419
|
+
macWorker.async {
|
|
420
|
+
let result = Result { try association.leave() }
|
|
421
|
+
self.queue.async {
|
|
422
|
+
switch result {
|
|
423
|
+
case .success:
|
|
424
|
+
self.macAssociation = nil
|
|
425
|
+
self.completeLeave()
|
|
426
|
+
case let .failure(error):
|
|
427
|
+
// Retain the owner when we cannot prove which association
|
|
428
|
+
// cleanup would disconnect. A later leave can retry safely.
|
|
429
|
+
self.finishingLeave = false
|
|
430
|
+
let replies = self.leaveReplies
|
|
431
|
+
self.leaveReplies.removeAll()
|
|
432
|
+
replies.forEach { $0(.failure(error)) }
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
} else { completeLeave() }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private func completeLeave() {
|
|
282
440
|
generation += 1
|
|
441
|
+
macAuthorization?.cancel(); macAuthorization = nil
|
|
283
442
|
localAccess?.cancel(); localAccess = nil
|
|
284
|
-
|
|
443
|
+
interfaceMonitors.forEach { $0.cancel() }
|
|
444
|
+
interfaceMonitors = []
|
|
445
|
+
wifiIdentity = HotspotWifiIdentity()
|
|
285
446
|
if let ssid, ownsConfiguration { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
|
|
286
447
|
ownsConfiguration = false
|
|
287
448
|
ssid = nil
|
|
288
|
-
|
|
449
|
+
binding = nil
|
|
289
450
|
gatewayAddress = nil
|
|
451
|
+
finishingLeave = false
|
|
452
|
+
if let token = ownershipToken { HotspotNetworkOwnership.shared.release(token) }
|
|
453
|
+
ownershipToken = nil
|
|
290
454
|
let replies = leaveReplies
|
|
291
455
|
leaveReplies.removeAll()
|
|
292
|
-
replies.forEach { $0() }
|
|
456
|
+
replies.forEach { $0(.success(())) }
|
|
293
457
|
}
|
|
294
458
|
|
|
295
|
-
|
|
459
|
+
/// Every attached interface's kernel index and link type (its AF_LINK row) and every IPv4
|
|
460
|
+
/// address with its interface name and up state. Selection belongs to HotspotInterfacePolicy;
|
|
461
|
+
/// an interface name alone never identifies Wi-Fi. A failed read yields an empty sample, which
|
|
462
|
+
/// fails closed.
|
|
463
|
+
static func interfaceSnapshot() -> HotspotInterfaceSnapshot {
|
|
296
464
|
var interfaces: UnsafeMutablePointer<ifaddrs>?
|
|
297
|
-
guard getifaddrs(&interfaces) == 0 else { return
|
|
465
|
+
guard getifaddrs(&interfaces) == 0 else { return HotspotInterfaceSnapshot(links: [:], addresses: []) }
|
|
298
466
|
defer { freeifaddrs(interfaces) }
|
|
467
|
+
var links: [String: Int] = [:]
|
|
468
|
+
var ethernetLinks: Set<String> = []
|
|
469
|
+
var rows: [HotspotInterfaceAddress] = []
|
|
299
470
|
var cursor = interfaces
|
|
300
471
|
while let item = cursor {
|
|
301
472
|
defer { cursor = item.pointee.ifa_next }
|
|
302
473
|
let value = item.pointee
|
|
303
|
-
guard
|
|
304
|
-
|
|
474
|
+
guard let address = value.ifa_addr else { continue }
|
|
475
|
+
let name = String(cString: value.ifa_name)
|
|
476
|
+
if address.pointee.sa_family == UInt8(AF_LINK) {
|
|
477
|
+
let (index, type) = address.withMemoryRebound(to: sockaddr_dl.self, capacity: 1) {
|
|
478
|
+
(Int($0.pointee.sdl_index), Int32($0.pointee.sdl_type))
|
|
479
|
+
}
|
|
480
|
+
if index > 0 {
|
|
481
|
+
links[name] = index
|
|
482
|
+
if type == IFT_ETHER { ethernetLinks.insert(name) }
|
|
483
|
+
}
|
|
484
|
+
continue
|
|
485
|
+
}
|
|
486
|
+
guard address.pointee.sa_family == UInt8(AF_INET) else { continue }
|
|
305
487
|
var host = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
|
306
488
|
guard getnameinfo(address, socklen_t(address.pointee.sa_len), &host, socklen_t(host.count), nil, 0, NI_NUMERICHOST) == 0 else { continue }
|
|
307
|
-
|
|
308
|
-
if LocalMediaPolicy.isPrivate(ip) { return ip }
|
|
489
|
+
rows.append(HotspotInterfaceAddress(name: name, isUp: value.ifa_flags & UInt32(IFF_UP) != 0, ipv4: String(cString: host)))
|
|
309
490
|
}
|
|
310
|
-
return
|
|
491
|
+
return HotspotInterfaceSnapshot(links: links, addresses: rows, ethernetLinks: ethernetLinks)
|
|
311
492
|
}
|
|
312
493
|
}
|