@mentra/glasses-media 3.2.1-dev.314 → 3.3.0-beta.1000

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.
@@ -0,0 +1,204 @@
1
+ import Foundation
2
+
3
+ /// The frame-agnostic half of `DecodedFrameTap`: sink ownership, failure isolation and metrics.
4
+ ///
5
+ /// The call owns the decoder; a preview sink is a guest. `offer` never lets a thrown error reach
6
+ /// the decoder thread, and with no sink attached and telemetry off it costs one uncontended lock
7
+ /// around a single reference load (Swift has no portable atomics for iOS 15).
8
+ /// Swift runtime traps (force-unwraps, bounds failures) are process failures and cannot be caught
9
+ /// here; the preview prevents them by validating geometry before touching memory.
10
+ public final class DecodedFrameTapCore<Frame> {
11
+ /// A snapshot of decoder-thread observations, drained by the reader.
12
+ public struct Metrics {
13
+ public let framesOffered: Int
14
+ public let framesWithSink: Int
15
+ public let offerTotalNs: Int64
16
+ public let offerMaxNs: Int64
17
+ public let cadenceSamples: Int
18
+ public let cadenceTotalNs: Int64
19
+ public let cadenceMaxNs: Int64
20
+ public let sinkExceptions: Int
21
+ /// Frames the ACS sender accepted, recorded at its call sites after `offer`.
22
+ public let acsFramesSent: Int
23
+
24
+ /// Mean time inside `offer` over every timed frame, with or without a sink.
25
+ public var offerMeanUs: Double {
26
+ framesOffered > 0 ? Double(offerTotalNs) / Double(framesOffered) / 1000.0 : 0
27
+ }
28
+
29
+ public var offerMaxUs: Double { Double(offerMaxNs) / 1000.0 }
30
+
31
+ public var cadenceMeanMs: Double {
32
+ cadenceSamples > 0 ? Double(cadenceTotalNs) / Double(cadenceSamples) / 1_000_000.0 : 0
33
+ }
34
+
35
+ public var cadenceMaxMs: Double { Double(cadenceMaxNs) / 1_000_000.0 }
36
+ }
37
+
38
+ private final class State {
39
+ let sink: ((Frame) throws -> Void)?
40
+ let onSinkError: ((Error) -> Void)?
41
+ init(sink: ((Frame) throws -> Void)?, onSinkError: ((Error) -> Void)?) {
42
+ self.sink = sink
43
+ self.onSinkError = onSinkError
44
+ }
45
+ }
46
+
47
+ private let stateLock = NSLock()
48
+ /// Replaced wholesale, never mutated, so `offer` reads one reference under a short lock.
49
+ private var state: State?
50
+ private var generation: UInt64 = 0
51
+ private var sink: ((Frame) throws -> Void)?
52
+ private var onSinkError: ((Error) -> Void)?
53
+ public private(set) var telemetryEnabled = false
54
+
55
+ private let metricsLock = NSLock()
56
+ private var framesOffered = 0
57
+ private var framesWithSink = 0
58
+ private var offerTotalNs: Int64 = 0
59
+ private var offerMaxNs: Int64 = 0
60
+ private var cadenceSamples = 0
61
+ private var cadenceTotalNs: Int64 = 0
62
+ private var cadenceMaxNs: Int64 = 0
63
+ private var sinkExceptions = 0
64
+ private var acsFramesSent = 0
65
+ private var lastOfferAtNs: Int64 = 0
66
+
67
+ private let clock: () -> Int64
68
+
69
+ public init(clock: @escaping () -> Int64 = { Int64(DispatchTime.now().uptimeNanoseconds) }) {
70
+ self.clock = clock
71
+ }
72
+
73
+ /// Install the sink and return the generation that owns it. `onSinkError` runs on the decoder
74
+ /// thread after a thrown error has been caught and counted; it must only schedule work.
75
+ @discardableResult
76
+ public func attach(_ sink: @escaping (Frame) throws -> Void, onSinkError: ((Error) -> Void)? = nil) -> UInt64 {
77
+ stateLock.lock()
78
+ defer { stateLock.unlock() }
79
+ generation &+= 1
80
+ self.sink = sink
81
+ self.onSinkError = onSinkError
82
+ publishLocked()
83
+ return generation
84
+ }
85
+
86
+ /// Remove the sink only if it is still the one `generation` installed.
87
+ @discardableResult
88
+ public func detach(generation: UInt64) -> Bool {
89
+ stateLock.lock()
90
+ defer { stateLock.unlock() }
91
+ guard generation == self.generation, sink != nil else { return false }
92
+ sink = nil
93
+ onSinkError = nil
94
+ publishLocked()
95
+ return true
96
+ }
97
+
98
+ public func isCurrent(generation: UInt64) -> Bool {
99
+ stateLock.lock()
100
+ defer { stateLock.unlock() }
101
+ return generation == self.generation && sink != nil
102
+ }
103
+
104
+ public var hasSink: Bool {
105
+ stateLock.lock()
106
+ defer { stateLock.unlock() }
107
+ return sink != nil
108
+ }
109
+
110
+ /// Collect cadence and offer cost even with no sink, so preview off/on can be compared.
111
+ public func setTelemetryEnabled(_ enabled: Bool) {
112
+ stateLock.lock()
113
+ defer { stateLock.unlock() }
114
+ telemetryEnabled = enabled
115
+ publishLocked()
116
+ }
117
+
118
+ private func publishLocked() {
119
+ let wasCollecting = state != nil
120
+ state = (sink == nil && !telemetryEnabled) ? nil : State(sink: sink, onSinkError: onSinkError)
121
+ if !wasCollecting, state != nil {
122
+ // The first gap after collection resumes would span the whole idle period.
123
+ metricsLock.lock()
124
+ lastOfferAtNs = 0
125
+ metricsLock.unlock()
126
+ }
127
+ }
128
+
129
+ /// Read and clear the accumulated observations. Called about once a second by the reporter.
130
+ public func drainMetrics() -> Metrics {
131
+ metricsLock.lock()
132
+ defer { metricsLock.unlock() }
133
+ let snapshot = Metrics(
134
+ framesOffered: framesOffered,
135
+ framesWithSink: framesWithSink,
136
+ offerTotalNs: offerTotalNs,
137
+ offerMaxNs: offerMaxNs,
138
+ cadenceSamples: cadenceSamples,
139
+ cadenceTotalNs: cadenceTotalNs,
140
+ cadenceMaxNs: cadenceMaxNs,
141
+ sinkExceptions: sinkExceptions,
142
+ acsFramesSent: acsFramesSent
143
+ )
144
+ framesOffered = 0
145
+ framesWithSink = 0
146
+ offerTotalNs = 0
147
+ offerMaxNs = 0
148
+ cadenceSamples = 0
149
+ cadenceTotalNs = 0
150
+ cadenceMaxNs = 0
151
+ sinkExceptions = 0
152
+ acsFramesSent = 0
153
+ return snapshot
154
+ }
155
+
156
+ /// The ACS sender accepted a frame. Counted under the same gate as `offer`.
157
+ public func recordAcsSend() {
158
+ stateLock.lock()
159
+ let collecting = state != nil
160
+ stateLock.unlock()
161
+ guard collecting else { return }
162
+ metricsLock.lock()
163
+ acsFramesSent += 1
164
+ metricsLock.unlock()
165
+ }
166
+
167
+ /// Called on the decoder thread, immediately before the frame goes to ACS.
168
+ public func offer(_ frame: Frame) {
169
+ stateLock.lock()
170
+ let current = state
171
+ stateLock.unlock()
172
+ guard let current else { return }
173
+
174
+ let started = clock()
175
+ var failure: Error?
176
+ if let sink = current.sink {
177
+ do {
178
+ try sink(frame)
179
+ } catch {
180
+ failure = error
181
+ }
182
+ }
183
+ let elapsed = clock() - started
184
+
185
+ metricsLock.lock()
186
+ framesOffered += 1
187
+ if lastOfferAtNs > 0 {
188
+ let gap = started - lastOfferAtNs
189
+ cadenceSamples += 1
190
+ cadenceTotalNs += gap
191
+ if gap > cadenceMaxNs { cadenceMaxNs = gap }
192
+ }
193
+ lastOfferAtNs = started
194
+ offerTotalNs += elapsed
195
+ if elapsed > offerMaxNs { offerMaxNs = elapsed }
196
+ if current.sink != nil { framesWithSink += 1 }
197
+ if failure != nil { sinkExceptions += 1 }
198
+ metricsLock.unlock()
199
+
200
+ if let failure {
201
+ current.onSinkError?(failure)
202
+ }
203
+ }
204
+ }
@@ -0,0 +1,5 @@
1
+ /// The traffic needed after joining the verified glasses Wi-Fi network.
2
+ public enum HotspotAccessPurpose: String {
3
+ case outgoingClient = "outgoing-client"
4
+ case incomingServer = "incoming-server"
5
+ }
@@ -0,0 +1,155 @@
1
+ import Foundation
2
+
3
+ /// One IPv4 address row reported by `getifaddrs`.
4
+ public struct HotspotInterfaceAddress: Equatable {
5
+ public let name: String
6
+ public let isUp: Bool
7
+ public let ipv4: String
8
+
9
+ public init(name: String, isUp: Bool, ipv4: String) {
10
+ self.name = name
11
+ self.isUp = isUp
12
+ self.ipv4 = ipv4
13
+ }
14
+ }
15
+
16
+ /// One `getifaddrs` sample: every attached interface's kernel index (its `AF_LINK` row, present
17
+ /// with or without an IPv4 address) and every IPv4 address row.
18
+ public struct HotspotInterfaceSnapshot: Equatable {
19
+ public let links: [String: Int]
20
+ public let addresses: [HotspotInterfaceAddress]
21
+ /// Attached interfaces whose `AF_LINK` type is `IFT_ETHER`. Apple platforms report Wi-Fi and
22
+ /// wired Ethernet this way; cellular, VPN tunnel and loopback links have other types.
23
+ public let ethernetLinks: Set<String>
24
+
25
+ public init(links: [String: Int], addresses: [HotspotInterfaceAddress], ethernetLinks: Set<String> = []) {
26
+ self.links = links
27
+ self.addresses = addresses
28
+ self.ethernetLinks = ethernetLinks
29
+ }
30
+ }
31
+
32
+ /// Network framework's type for one interface on one evaluated path.
33
+ public struct HotspotInterfaceReport: Equatable {
34
+ public let name: String
35
+ public let index: Int
36
+ public let isWifi: Bool
37
+
38
+ public init(name: String, index: Int, isWifi: Bool) {
39
+ self.name = name
40
+ self.index = index
41
+ self.isWifi = isWifi
42
+ }
43
+ }
44
+
45
+ /// The Wi-Fi interface carrying the glasses hotspot and the DHCP address it held when verified.
46
+ public struct HotspotInterfaceBinding: Hashable {
47
+ public let interface: String
48
+ public let index: Int
49
+ public let address: String
50
+
51
+ public init(interface: String, index: Int, address: String) {
52
+ self.interface = interface
53
+ self.index = index
54
+ self.address = address
55
+ }
56
+ }
57
+
58
+ /// Wi-Fi identity learned during one hotspot session. Create a new value for every join.
59
+ ///
60
+ /// A path's interface list describes routes, not hardware: after Wi-Fi moves to an AP without
61
+ /// internet, both the default and the Wi-Fi-constrained path can omit it. An interface's type
62
+ /// does not change while it stays attached, so a positive Wi-Fi report is kept for the same
63
+ /// name and kernel index until that interface is detached or replaced. Omission from a later
64
+ /// report is not evidence. Any non-Wi-Fi report for a name rejects it for the rest of the session.
65
+ public struct HotspotWifiIdentity: Equatable {
66
+ public private(set) var trusted: [String: Int] = [:]
67
+ public private(set) var rejected: Set<String> = []
68
+ /// Trusted names later seen detached or replaced. A fresh report may trust the name again
69
+ /// for discovery, but an address verified before the change stays lost.
70
+ public private(set) var detached: Set<String> = []
71
+
72
+ public init() {}
73
+
74
+ public mutating func observe(_ reports: [HotspotInterfaceReport]) {
75
+ for report in reports where !report.isWifi {
76
+ rejected.insert(report.name)
77
+ trusted[report.name] = nil
78
+ }
79
+ for report in reports where report.isWifi && !report.name.isEmpty && report.index > 0 && !rejected.contains(report.name) {
80
+ // A new index means a different interface took the name; only this report vouches for it.
81
+ if let index = trusted[report.name], index != report.index { detached.insert(report.name) }
82
+ trusted[report.name] = report.index
83
+ }
84
+ }
85
+
86
+ /// Forget an interface that is no longer attached, or whose name now has another index.
87
+ /// Only a new positive report can re-establish it.
88
+ public mutating func observe(_ snapshot: HotspotInterfaceSnapshot) {
89
+ for (name, index) in trusted where snapshot.links[name] != index {
90
+ trusted[name] = nil
91
+ detached.insert(name)
92
+ }
93
+ }
94
+
95
+ /// Pin a binding chosen from hotspot client evidence (see `HotspotInterfacePolicy.select`)
96
+ /// as this session's Wi-Fi interface. It then ends exactly like a reported one.
97
+ public mutating func adopt(_ binding: HotspotInterfaceBinding) {
98
+ guard !rejected.contains(binding.interface), !detached.contains(binding.interface) else { return }
99
+ trusted[binding.interface] = binding.index
100
+ }
101
+ }
102
+
103
+ /// Wi-Fi is not always `en0`: a Mac can have built-in Ethernet on `en0` and Wi-Fi on `en1`.
104
+ /// Identify the hotspot link by interface type and address, never by name or by the first
105
+ /// private address, and keep that exact interface for the rest of the session.
106
+ public enum HotspotInterfacePolicy {
107
+ /// The unique up IPv4 address, on an interface this session trusts as Wi-Fi and that is still
108
+ /// attached with the same index, that can be the hotspot client. With a BLE-reported gateway
109
+ /// it must be a valid client on that gateway's /24. Missing or ambiguous candidates return nil.
110
+ ///
111
+ /// A phone on cellular has no Wi-Fi path when the join starts, and after association iOS
112
+ /// leaves the internet-less glasses AP out of both paths, so no Wi-Fi report ever arrives.
113
+ /// Only when the session has no trusted Wi-Fi interface, the hotspot client address is the
114
+ /// evidence instead: the unique valid client of the BLE gateway's /24 on an attached
115
+ /// Ethernet-type link that no path has reported as non-Wi-Fi. Callers bind it only after the
116
+ /// glasses SSID matched, then require a connection from it to reach the gateway, and must
117
+ /// `adopt` it so the session pins that interface.
118
+ public static func select(snapshot: HotspotInterfaceSnapshot, identity: HotspotWifiIdentity,
119
+ gateway: String?) -> HotspotInterfaceBinding?
120
+ {
121
+ if !identity.trusted.isEmpty {
122
+ return unique(snapshot, gateway: gateway) { identity.trusted[$0] }
123
+ }
124
+ guard let gateway else { return nil }
125
+ return unique(snapshot, gateway: gateway) { name in
126
+ guard snapshot.ethernetLinks.contains(name), !identity.rejected.contains(name),
127
+ !identity.detached.contains(name) else { return nil }
128
+ return snapshot.links[name]
129
+ }
130
+ }
131
+
132
+ private static func unique(_ snapshot: HotspotInterfaceSnapshot, gateway: String?,
133
+ index eligibleIndex: (String) -> Int?) -> HotspotInterfaceBinding?
134
+ {
135
+ let bindings = Set(snapshot.addresses.compactMap { row -> HotspotInterfaceBinding? in
136
+ guard let index = eligibleIndex(row.name), snapshot.links[row.name] == index,
137
+ row.isUp, LocalMediaPolicy.isPrivate(row.ipv4) else { return nil }
138
+ if let gateway, !LocalMediaPolicy.isHotspotClientAddress(row.ipv4, gateway: gateway) { return nil }
139
+ return HotspotInterfaceBinding(interface: row.name, index: index, address: row.ipv4)
140
+ })
141
+ return bindings.count == 1 ? bindings.first : nil
142
+ }
143
+
144
+ /// A verified link remains usable only while the same attached interface is up with the same
145
+ /// address. Later path reports may omit it. Another interface acquiring that address, a
146
+ /// detach or replacement under the same name, or a non-Wi-Fi report ends it for the session.
147
+ /// Pass an identity that has already observed `snapshot`.
148
+ public static func isIntact(_ binding: HotspotInterfaceBinding, snapshot: HotspotInterfaceSnapshot,
149
+ identity: HotspotWifiIdentity) -> Bool
150
+ {
151
+ guard identity.trusted[binding.interface] == binding.index, !identity.detached.contains(binding.interface),
152
+ snapshot.links[binding.interface] == binding.index else { return false }
153
+ return snapshot.addresses.contains { $0.name == binding.interface && $0.isUp && $0.ipv4 == binding.address }
154
+ }
155
+ }
@@ -0,0 +1,23 @@
1
+ import Foundation
2
+
3
+ /// Explicit bridge facts; never forward native userInfo or arbitrary NSError descriptions.
4
+ public struct HotspotJoinFailure {
5
+ public let code: String
6
+ public let message: String
7
+
8
+ public init(_ error: Error) {
9
+ if error is CancellationError || (error as? LocalMediaError)?.message == "Hotspot join cancelled" {
10
+ code = "ERR_HOTSPOT_CANCELLED"
11
+ message = "Hotspot join cancelled"
12
+ } else if let local = error as? LocalMediaError {
13
+ code = "ERR_HOTSPOT_JOIN"
14
+ // These messages are authored by the owned hotspot implementation, not NSError.
15
+ message = String(local.message.filter { !$0.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) }.prefix(512))
16
+ } else {
17
+ code = "ERR_HOTSPOT_NATIVE"
18
+ let native = error as NSError
19
+ let domain = native.domain.range(of: "^[A-Za-z0-9_.-]{1,128}$", options: .regularExpression) == nil ? "unavailable" : native.domain
20
+ message = "Hotspot join failed (\(domain), \(native.code))"
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,22 @@
1
+ import Foundation
2
+
3
+ /// One process-wide writer across OTA, gallery and native media sessions.
4
+ /// Callers release only after native association and owned cleanup have ended.
5
+ final class HotspotNetworkOwnership {
6
+ static let shared = HotspotNetworkOwnership()
7
+ private let lock = NSLock()
8
+ private var owner: UUID?
9
+
10
+ func acquire() -> UUID? {
11
+ lock.lock(); defer { lock.unlock() }
12
+ guard owner == nil else { return nil }
13
+ let token = UUID()
14
+ owner = token
15
+ return token
16
+ }
17
+
18
+ func release(_ token: UUID) {
19
+ lock.lock(); defer { lock.unlock() }
20
+ if owner == token { owner = nil }
21
+ }
22
+ }
@@ -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
+ }