@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.
- package/README.md +14 -2
- package/android/src/main/java/com/mentra/glassesmedia/network/InternetHold.kt +8 -7
- package/android/src/main/java/com/mentra/glassesmedia/publisher/PhoneWhipPublisher.kt +11 -8
- package/android/src/main/java/com/mentra/glassesmedia/source/DecodedFrameTap.kt +224 -0
- 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/DecodedFrameTapCore.swift +204 -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/DecodedFrameTap.swift +64 -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,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,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
|
+
}
|