@mentra/glasses-media 3.2.0-dev.225

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.
Files changed (56) hide show
  1. package/README.md +35 -0
  2. package/android/build.gradle +84 -0
  3. package/android/src/main/AndroidManifest.xml +6 -0
  4. package/android/src/main/java/com/mentra/glassesmedia/network/InternetHold.kt +501 -0
  5. package/android/src/main/java/com/mentra/glassesmedia/network/Ipv4Prefix.kt +51 -0
  6. package/android/src/main/java/com/mentra/glassesmedia/network/NetworkFacts.kt +81 -0
  7. package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkChangeDetector.kt +355 -0
  8. package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkError.kt +109 -0
  9. package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkObserver.kt +160 -0
  10. package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkReadiness.kt +103 -0
  11. package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkRequestSpec.kt +53 -0
  12. package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkState.kt +127 -0
  13. package/android/src/main/java/com/mentra/glassesmedia/network/ScopedSoftApNetwork.kt +573 -0
  14. package/android/src/main/java/com/mentra/glassesmedia/source/BitmapFont.kt +99 -0
  15. package/android/src/main/java/com/mentra/glassesmedia/source/CloudflareWhepSource.kt +487 -0
  16. package/android/src/main/java/com/mentra/glassesmedia/source/DecodedTrackRelay.kt +147 -0
  17. package/android/src/main/java/com/mentra/glassesmedia/source/FirstFrameGate.kt +45 -0
  18. package/android/src/main/java/com/mentra/glassesmedia/source/GlassesMediaSource.kt +143 -0
  19. package/android/src/main/java/com/mentra/glassesmedia/source/LocalWhipIngestSource.kt +772 -0
  20. package/android/src/main/java/com/mentra/glassesmedia/source/MediaListeners.kt +57 -0
  21. package/android/src/main/java/com/mentra/glassesmedia/source/SdpAdapter.kt +17 -0
  22. package/android/src/main/java/com/mentra/glassesmedia/source/SelectedIcePair.kt +139 -0
  23. package/android/src/main/java/com/mentra/glassesmedia/source/SoftApIceDiagnosis.kt +144 -0
  24. package/android/src/main/java/com/mentra/glassesmedia/source/SoftApIcePolicy.kt +46 -0
  25. package/android/src/main/java/com/mentra/glassesmedia/source/SoftApSdpGuard.kt +160 -0
  26. package/android/src/main/java/com/mentra/glassesmedia/source/SyntheticFrameFactory.kt +205 -0
  27. package/android/src/main/java/com/mentra/glassesmedia/source/SyntheticI420Source.kt +214 -0
  28. package/android/src/main/java/com/mentra/glassesmedia/source/TrackRegistry.kt +33 -0
  29. package/android/src/main/java/com/mentra/glassesmedia/source/VideoSourceArm.kt +110 -0
  30. package/android/src/main/java/com/mentra/glassesmedia/source/WhipIngestProtocol.kt +207 -0
  31. package/android/src/main/java/com/mentra/glassesmedia/source/WhipIngestServer.kt +356 -0
  32. package/android/src/main/java/com/mentra/glassesmedia/telemetry/AvSyncProbe.kt +155 -0
  33. package/android/src/main/java/com/mentra/glassesmedia/telemetry/ChromaProbe.kt +78 -0
  34. package/android/src/main/java/com/mentra/glassesmedia/telemetry/PipelineStats.kt +372 -0
  35. package/android/src/main/java/com/mentra/glassesmedia/telemetry/PipelineTicker.kt +79 -0
  36. package/android/src/main/java/com/mentra/glassesmedia/telemetry/ProcessCpu.kt +15 -0
  37. package/android/src/main/java/com/mentra/glassesmedia/telemetry/RingPercentile.kt +48 -0
  38. package/android/src/main/java/com/mentra/glassesmedia/telemetry/VideoQuality.kt +178 -0
  39. package/android/src/main/java/com/mentra/glassesmedia/telemetry/VideoRateVerdict.kt +145 -0
  40. package/android/src/main/java/com/mentra/glassesmedia/trace/SoftApTrace.kt +143 -0
  41. package/android/src/main/java/com/mentra/glassesmedia/video/FrameGeometry.kt +22 -0
  42. package/android/src/main/java/com/mentra/glassesmedia/video/I420Packer.kt +72 -0
  43. package/expo-module.config.json +12 -0
  44. package/index.js +1 -0
  45. package/ios/CoreKit/Sources/GlassesMediaCore/GlassesMediaSource.swift +104 -0
  46. package/ios/CoreKit/Sources/GlassesMediaCore/LocalMediaPolicy.swift +51 -0
  47. package/ios/CoreKit/Sources/GlassesMediaCore/WhipIngestServer.swift +145 -0
  48. package/ios/CoreKit/Sources/GlassesMediaCore/WhipRequest.swift +59 -0
  49. package/ios/DecodedGlassesMediaSource.swift +9 -0
  50. package/ios/DecodedPcm.swift +33 -0
  51. package/ios/GlassesHotspotNetwork.swift +185 -0
  52. package/ios/GlassesMedia.podspec +18 -0
  53. package/ios/LocalWhipIngestSource.swift +325 -0
  54. package/ios/ReceiveOnlyAudioDevice.swift +106 -0
  55. package/ios/WhepVideoSource.swift +332 -0
  56. package/package.json +27 -0
@@ -0,0 +1,104 @@
1
+ import Foundation
2
+
3
+ public enum SourceKind: String, Sendable {
4
+ case whep
5
+ case direct
6
+ case softap
7
+ }
8
+
9
+ public enum SourceState: String, Sendable {
10
+ case idle
11
+ case connecting
12
+ case live
13
+ case failed
14
+ }
15
+
16
+ public struct SourceConfig: Equatable, Sendable {
17
+ public var url: String
18
+ public var kind: SourceKind
19
+ public var bindAddress: String?
20
+
21
+ public init(url: String, kind: SourceKind = .whep, bindAddress: String? = nil) {
22
+ self.url = url
23
+ self.kind = kind
24
+ self.bindAddress = bindAddress
25
+ }
26
+ }
27
+
28
+ public protocol GlassesMediaSource: AnyObject {
29
+ var state: SourceState { get }
30
+ var ingestUrl: String? { get }
31
+ func forceRestart()
32
+ func start(config: SourceConfig)
33
+ func restart(config: SourceConfig)
34
+ func stop()
35
+ func setPcmDeliveryEnabled(_ enabled: Bool)
36
+ }
37
+
38
+ public final class GlassesMediaController {
39
+ private let factory: () -> GlassesMediaSource
40
+ private var source: GlassesMediaSource?
41
+
42
+ public init(factory: @escaping () -> GlassesMediaSource) {
43
+ self.factory = factory
44
+ }
45
+
46
+ public var state: SourceState {
47
+ source?.state ?? .idle
48
+ }
49
+
50
+ public func attach(config: SourceConfig) {
51
+ source?.stop()
52
+ let next = factory()
53
+ source = next
54
+ next.start(config: config)
55
+ }
56
+
57
+ public func restart(config: SourceConfig) {
58
+ source?.restart(config: config)
59
+ }
60
+
61
+ public func stop() {
62
+ source?.stop()
63
+ source = nil
64
+ }
65
+
66
+ public func setPcmDeliveryEnabled(_ enabled: Bool) {
67
+ source?.setPcmDeliveryEnabled(enabled)
68
+ }
69
+ }
70
+
71
+ public extension GlassesMediaSource {
72
+ var ingestUrl: String? {
73
+ nil
74
+ }
75
+
76
+ func forceRestart() {}
77
+ }
78
+
79
+ public extension SourceConfig {
80
+ /// Validate the host bridge before creating a peer or claiming resources. Preserve the
81
+ /// legacy WHEP field while requiring an explicit bound address for local reception.
82
+ static func fromBridge(_ source: [String: Any]?, legacyWhepUrl: String?) throws -> SourceConfig {
83
+ func nonempty(_ value: Any?) -> String? {
84
+ guard let value = value as? String else { return nil }
85
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
86
+ return trimmed.isEmpty ? nil : trimmed
87
+ }
88
+ guard let source else {
89
+ guard let url = nonempty(legacyWhepUrl) else { throw LocalMediaError("whepUrl is required") }
90
+ return SourceConfig(url: url)
91
+ }
92
+ switch nonempty(source["type"])?.lowercased() {
93
+ case "whep":
94
+ guard let url = nonempty(source["url"]) ?? nonempty(legacyWhepUrl) else { throw LocalMediaError("videoSource.url is required") }
95
+ return SourceConfig(url: url)
96
+ case "softap":
97
+ guard let address = nonempty(source["bindAddress"]), LocalMediaPolicy.isPrivate(address) else {
98
+ throw LocalMediaError("videoSource.bindAddress must be the phone's hotspot IPv4 address")
99
+ }
100
+ return SourceConfig(url: "", kind: .softap, bindAddress: address)
101
+ default: throw LocalMediaError("Unsupported videoSource.type")
102
+ }
103
+ }
104
+ }
@@ -0,0 +1,51 @@
1
+ import Foundation
2
+
3
+ public struct LocalMediaError: LocalizedError {
4
+ public let message: String
5
+ public init(_ message: String) {
6
+ self.message = message
7
+ }
8
+
9
+ public var errorDescription: String? {
10
+ message
11
+ }
12
+ }
13
+
14
+ /// The glasses' DHCP server uses a /24. Never substitute an unrelated private interface.
15
+ public enum LocalMediaPolicy {
16
+ public static func ipv4(_ value: String) -> [UInt8]? {
17
+ let parts = value.split(separator: ".", omittingEmptySubsequences: false)
18
+ guard parts.count == 4 else { return nil }
19
+ let bytes = parts.compactMap { UInt8($0) }
20
+ return bytes.count == 4 ? bytes : nil
21
+ }
22
+
23
+ public static func sameSubnet(_ lhs: String, _ rhs: String) -> Bool {
24
+ guard let a = ipv4(lhs), let b = ipv4(rhs) else { return false }
25
+ return a.prefix(3) == b.prefix(3)
26
+ }
27
+
28
+ public static func isPrivate(_ address: String) -> Bool {
29
+ guard let bytes = ipv4(address) else { return false }
30
+ return bytes[0] == 10 || (bytes[0] == 172 && (16 ... 31).contains(bytes[1])) ||
31
+ (bytes[0] == 192 && bytes[1] == 168)
32
+ }
33
+
34
+ /// Keep only concrete host candidates on the local link. Never rewrite a cellular socket's
35
+ /// advertised address: a candidate must already belong to the interface it claims.
36
+ public static func localSdp(_ sdp: String, address: String, answer: Bool) throws -> String {
37
+ guard isPrivate(address) else { throw LocalMediaError("Invalid hotspot address") }
38
+ var candidates = 0
39
+ let lines = sdp.components(separatedBy: .newlines).filter { !$0.isEmpty }.filter { line in
40
+ guard line.hasPrefix("a=candidate:") else { return true }
41
+ let fields = line.split(separator: " ")
42
+ guard fields.count >= 8, fields[2].lowercased() == "udp", fields[6] == "typ", fields[7] == "host" else { return false }
43
+ let host = String(fields[4])
44
+ guard answer ? host == address : sameSubnet(host, address) else { return false }
45
+ candidates += 1
46
+ return true
47
+ }
48
+ guard candidates > 0 else { throw LocalMediaError("No host ICE candidate on the glasses hotspot") }
49
+ return lines.joined(separator: "\r\n") + "\r\n"
50
+ }
51
+ }
@@ -0,0 +1,145 @@
1
+ import Foundation
2
+ import Network
3
+
4
+ /// One local publisher, bounded requests, and no HTTP/media work on the UI thread.
5
+ /// The consumer owns negotiation; this server never imports WebRTC or a publisher SDK.
6
+ public final class WhipIngestServer {
7
+ public typealias Negotiator = (String, @escaping (Result<String, Error>) -> Void) -> Void
8
+ private let queue = DispatchQueue(label: "com.mentra.glassesmedia.whip.http")
9
+ private let negotiate: Negotiator
10
+ private let terminate: () -> Void
11
+ private let publisherFailed: () -> Bool
12
+ private var listener: NWListener?
13
+ private var connections: [UUID: NWConnection] = [:]
14
+ private var session: String?
15
+ private var endpoint = ""
16
+ private var generation = 0
17
+ private var ready: ((Result<String, Error>) -> Void)?
18
+
19
+ public init(negotiate: @escaping Negotiator, terminate: @escaping () -> Void, publisherFailed: @escaping () -> Bool) {
20
+ self.negotiate = negotiate
21
+ self.terminate = terminate
22
+ self.publisherFailed = publisherFailed
23
+ }
24
+
25
+ public func start(address: String, wifiOnly: Bool = true, completion: @escaping (Result<String, Error>) -> Void) {
26
+ queue.async {
27
+ guard self.listener == nil else { completion(.failure(LocalMediaError("Listener already started"))); return }
28
+ do {
29
+ let parameters = NWParameters.tcp
30
+ if wifiOnly { parameters.requiredInterfaceType = .wifi }
31
+ parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host(address), port: .any)
32
+ let listener = try NWListener(using: parameters)
33
+ self.generation += 1
34
+ let gen = self.generation
35
+ self.listener = listener
36
+ self.ready = completion
37
+ listener.stateUpdateHandler = { [weak self] state in
38
+ guard let self, gen == self.generation else { return }
39
+ switch state {
40
+ case .ready:
41
+ guard let port = listener.port else { return }
42
+ self.endpoint = "http://\(address):\(port.rawValue)/whip"
43
+ self.finishReady(.success(self.endpoint))
44
+ case let .failed(error):
45
+ self.finishReady(.failure(error))
46
+ self.stopLocked()
47
+ default: break
48
+ }
49
+ }
50
+ listener.newConnectionHandler = { [weak self] connection in self?.accept(connection, generation: gen) }
51
+ listener.start(queue: self.queue)
52
+ self.queue.asyncAfter(deadline: .now() + 10) { [weak self] in
53
+ guard let self, gen == self.generation, self.ready != nil else { return }
54
+ self.finishReady(.failure(LocalMediaError("Local listener startup timed out")))
55
+ self.stopLocked()
56
+ }
57
+ } catch { completion(.failure(error)) }
58
+ }
59
+ }
60
+
61
+ /// Completion is a barrier: no pending offer can install a peer after this point.
62
+ public func stop(completion: @escaping () -> Void = {}) {
63
+ queue.async { self.stopLocked(); completion() }
64
+ }
65
+
66
+ private func finishReady(_ result: Result<String, Error>) {
67
+ let callback = ready
68
+ ready = nil
69
+ callback?(result)
70
+ }
71
+
72
+ private func stopLocked() {
73
+ generation += 1
74
+ finishReady(.failure(LocalMediaError("Listener cancelled")))
75
+ listener?.stateUpdateHandler = nil
76
+ listener?.newConnectionHandler = nil
77
+ listener?.cancel()
78
+ listener = nil
79
+ session = nil
80
+ endpoint = ""
81
+ for id in Array(connections.keys) {
82
+ reply(id, status: 410)
83
+ }
84
+ terminate()
85
+ }
86
+
87
+ private func accept(_ connection: NWConnection, generation gen: Int) {
88
+ guard gen == generation, listener != nil, connections.count < 4 else { connection.cancel(); return }
89
+ let id = UUID()
90
+ connections[id] = connection
91
+ connection.start(queue: queue)
92
+ queue.asyncAfter(deadline: .now() + 12) { [weak self] in self?.reply(id, status: 408) }
93
+ receive(id, data: Data(), generation: gen)
94
+ }
95
+
96
+ private func receive(_ id: UUID, data: Data, generation gen: Int) {
97
+ guard let connection = connections[id] else { return }
98
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 16384) { [weak self] bytes, _, complete, error in
99
+ guard let self, gen == self.generation, self.connections[id] != nil else { return }
100
+ var data = data
101
+ if let bytes { data.append(bytes) }
102
+ do {
103
+ if let request = try WhipRequest.parse(data) {
104
+ self.handle(id, request: request, generation: gen)
105
+ } else if complete || error != nil {
106
+ self.reply(id, status: 400)
107
+ } else { self.receive(id, data: data, generation: gen) }
108
+ } catch { self.reply(id, status: 400, body: error.localizedDescription) }
109
+ }
110
+ }
111
+
112
+ private func handle(_ id: UUID, request: WhipRequest, generation gen: Int) {
113
+ if request.path == "/whip" || request.path == "/whip/" {
114
+ guard request.method == "POST" else { reply(id, status: 405, headers: ["Allow": "POST"]); return }
115
+ guard request.contentType == "application/sdp" else { reply(id, status: 415); return }
116
+ guard !request.body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { reply(id, status: 400); return }
117
+ if session != nil, publisherFailed() { session = nil; terminate() }
118
+ guard session == nil else { reply(id, status: 409); return }
119
+ let next = UUID().uuidString
120
+ session = next
121
+ negotiate(request.body) { [weak self] result in
122
+ self?.queue.async {
123
+ guard let self, gen == self.generation, self.session == next else { return }
124
+ switch result {
125
+ case let .success(answer):
126
+ self.reply(id, status: 201, body: answer, headers: ["Content-Type": "application/sdp", "Location": "\(self.endpoint)/\(next)"])
127
+ case let .failure(error):
128
+ self.session = nil
129
+ self.reply(id, status: 500, body: error.localizedDescription)
130
+ }
131
+ }
132
+ }
133
+ } else if let session, request.path == "/whip/\(session)" {
134
+ guard request.method == "DELETE" else { reply(id, status: 405, headers: ["Allow": "DELETE"]); return }
135
+ self.session = nil
136
+ terminate()
137
+ reply(id, status: 204)
138
+ } else { reply(id, status: 404) }
139
+ }
140
+
141
+ private func reply(_ id: UUID, status: Int, body: String = "", headers: [String: String] = [:]) {
142
+ guard let connection = connections.removeValue(forKey: id) else { return }
143
+ connection.send(content: WhipRequest.response(status, body: body, headers: headers), completion: .contentProcessed { _ in connection.cancel() })
144
+ }
145
+ }
@@ -0,0 +1,59 @@
1
+ import Foundation
2
+
3
+ public struct WhipRequest {
4
+ public let method: String
5
+ public let path: String
6
+ public let body: String
7
+ public let contentType: String
8
+
9
+ public static let maxHeaderBytes = 8192
10
+ public static let maxBodyBytes = 256 * 1024
11
+
12
+ /// nil means more bytes are needed. Reject ambiguous framing before reading the body.
13
+ public static func parse(_ data: Data) throws -> WhipRequest? {
14
+ guard let boundary = data.range(of: Data("\r\n\r\n".utf8)) else {
15
+ guard data.count <= maxHeaderBytes else { throw LocalMediaError("HTTP headers too large") }
16
+ return nil
17
+ }
18
+ guard boundary.lowerBound <= maxHeaderBytes,
19
+ let head = String(data: data[..<boundary.lowerBound], encoding: .utf8)
20
+ else {
21
+ throw LocalMediaError("Invalid HTTP headers")
22
+ }
23
+ let lines = head.components(separatedBy: "\r\n")
24
+ let first = lines[0].split(separator: " ")
25
+ guard first.count == 3, first[2] == "HTTP/1.1", first[1].hasPrefix("/") else {
26
+ throw LocalMediaError("Invalid HTTP request")
27
+ }
28
+ var headers: [String: String] = [:]
29
+ for line in lines.dropFirst() {
30
+ guard let colon = line.firstIndex(of: ":") else { throw LocalMediaError("Invalid HTTP header") }
31
+ let name = line[..<colon].lowercased()
32
+ guard headers[name] == nil else { throw LocalMediaError("Duplicate HTTP header") }
33
+ headers[name] = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
34
+ }
35
+ guard headers["transfer-encoding"] == nil else { throw LocalMediaError("Chunked offers are unsupported") }
36
+ let method = String(first[0])
37
+ guard method != "POST" || headers["content-length"] != nil else { throw LocalMediaError("Content-Length required") }
38
+ guard let length = Int(headers["content-length"] ?? "0"), length >= 0, length <= maxBodyBytes else {
39
+ throw LocalMediaError("Invalid or oversized HTTP body")
40
+ }
41
+ let end = boundary.upperBound + length
42
+ guard data.count >= end else { return nil }
43
+ guard data.count == end, let body = String(data: data[boundary.upperBound ..< end], encoding: .utf8) else {
44
+ throw LocalMediaError("Invalid HTTP body")
45
+ }
46
+ return WhipRequest(method: method, path: String(first[1].split(separator: "?", maxSplits: 1)[0]), body: body,
47
+ contentType: (headers["content-type"] ?? "").split(separator: ";").first.map(String.init)?.lowercased() ?? "")
48
+ }
49
+
50
+ public static func response(_ status: Int, body: String = "", headers: [String: String] = [:]) -> Data {
51
+ let reasons = [201: "Created", 204: "No Content", 400: "Bad Request", 404: "Not Found", 405: "Method Not Allowed", 408: "Request Timeout", 409: "Conflict", 410: "Gone", 415: "Unsupported Media Type", 500: "Internal Server Error", 503: "Service Unavailable"]
52
+ var value = "HTTP/1.1 \(status) \(reasons[status] ?? "Error")\r\n"
53
+ for (name, content) in headers {
54
+ value += "\(name): \(content)\r\n"
55
+ }
56
+ value += "Content-Length: \(body.utf8.count)\r\nConnection: close\r\n\r\n\(body)"
57
+ return Data(value.utf8)
58
+ }
59
+ }
@@ -0,0 +1,9 @@
1
+ import CoreVideo
2
+ import Foundation
3
+
4
+ /// Publishers consume native buffers. No bridge serialization or dependency on ACS.
5
+ public protocol DecodedGlassesMediaSource: GlassesMediaSource {
6
+ var onFrame: ((CVPixelBuffer) -> Void)? { get set }
7
+ var onPcm: ((Data, Int, Int) -> Void)? { get set }
8
+ var onStateChange: ((SourceState, String) -> Void)? { get set }
9
+ }
@@ -0,0 +1,33 @@
1
+ import AVFoundation
2
+ import Foundation
3
+
4
+ enum DecodedPcm {
5
+ static func pcm16(_ buffer: AVAudioPCMBuffer) -> Data? {
6
+ let channels = Int(buffer.format.channelCount)
7
+ let frames = Int(buffer.frameLength)
8
+ guard frames > 0, channels > 0 else { return nil }
9
+ let interleaved = buffer.format.isInterleaved
10
+ var data = Data(count: frames * channels * 2)
11
+ if let source = buffer.int16ChannelData {
12
+ data.withUnsafeMutableBytes { raw in
13
+ let output = raw.bindMemory(to: Int16.self)
14
+ for frame in 0 ..< frames {
15
+ for channel in 0 ..< channels {
16
+ output[frame * channels + channel] = interleaved ? source[0][frame * channels + channel] : source[channel][frame]
17
+ }
18
+ }
19
+ }
20
+ } else if let source = buffer.floatChannelData {
21
+ data.withUnsafeMutableBytes { raw in
22
+ let output = raw.bindMemory(to: Int16.self)
23
+ for frame in 0 ..< frames {
24
+ for channel in 0 ..< channels {
25
+ let sample = interleaved ? source[0][frame * channels + channel] : source[channel][frame]
26
+ output[frame * channels + channel] = Int16(max(-1, min(1, sample.isFinite ? sample : 0)) * Float(Int16.max))
27
+ }
28
+ }
29
+ }
30
+ } else { return nil }
31
+ return data
32
+ }
33
+ }
@@ -0,0 +1,185 @@
1
+ import Darwin
2
+ import Foundation
3
+ import Network
4
+ import NetworkExtension
5
+
6
+ /// Same persistent hotspot join as gallery (`joinOnce=false`). Only local traffic uses Wi-Fi.
7
+ public final class GlassesHotspotNetwork {
8
+ private let queue = DispatchQueue(label: "com.mentra.glassesmedia.hotspot")
9
+ private var ssid: String?
10
+ private var localAddress: String?
11
+ private var generation = 0
12
+ private var applying = false
13
+ private var cancelled = false
14
+ private var joinReply: ((Result<String, Error>) -> Void)?
15
+ private var leaveReplies: [() -> Void] = []
16
+ private var monitor: NWPathMonitor?
17
+ public var onLost: ((String) -> Void)?
18
+ public init() {}
19
+
20
+ public func join(ssid: String, passphrase: String, completion: @escaping (Result<String, Error>) -> Void) {
21
+ queue.async {
22
+ guard self.ssid == nil, !self.applying else { completion(.failure(LocalMediaError("Previous hotspot session has not finished cleaning up"))); return }
23
+ self.generation += 1
24
+ let gen = self.generation
25
+ self.ssid = ssid
26
+ self.cancelled = false
27
+ self.applying = true
28
+ self.joinReply = completion
29
+ let config = NEHotspotConfiguration(ssid: ssid, passphrase: passphrase, isWEP: false)
30
+ config.joinOnce = false
31
+ NEHotspotConfigurationManager.shared.apply(config) { error in
32
+ self.queue.async {
33
+ guard gen == self.generation else { return }
34
+ self.applying = false
35
+ if self.cancelled { self.finishLeave(); return }
36
+ if let error, (error as NSError).code != NEHotspotConfigurationError.alreadyAssociated.rawValue {
37
+ self.finishJoin(.failure(error)); self.finishLeave(); return
38
+ }
39
+ self.waitForAddress(ssid: ssid, generation: gen, remaining: 60)
40
+ }
41
+ }
42
+ self.queue.asyncAfter(deadline: .now() + 60) {
43
+ guard gen == self.generation, self.joinReply != nil else { return }
44
+ self.cancelled = true
45
+ self.finishJoin(.failure(LocalMediaError("Hotspot join timed out")))
46
+ // apply() cannot be cancelled. Retain the reservation until its callback and remove the
47
+ // late configuration before another call is permitted to acquire the network.
48
+ NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid)
49
+ if !self.applying { self.finishLeave() }
50
+ }
51
+ }
52
+ }
53
+
54
+ public func leave(completion: @escaping () -> Void) {
55
+ queue.async {
56
+ self.cancelled = true
57
+ self.leaveReplies.append(completion)
58
+ self.finishJoin(.failure(LocalMediaError("Hotspot join cancelled")))
59
+ if let ssid = self.ssid { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
60
+ if !self.applying { self.finishLeave() }
61
+ }
62
+ }
63
+
64
+ public func info(completion: @escaping ([String: Any]) -> Void) {
65
+ queue.async {
66
+ var value: [String: Any] = ["available": self.localAddress != nil]
67
+ if let address = self.localAddress {
68
+ value["localIpv4"] = address
69
+ value["prefix"] = address.split(separator: ".").prefix(3).joined(separator: ".") + ".0/24"
70
+ }
71
+ completion(value)
72
+ }
73
+ }
74
+
75
+ public func probeGateway(completion: @escaping (Bool, String) -> Void) {
76
+ queue.async {
77
+ guard let address = self.localAddress else { completion(false, "No joined hotspot"); return }
78
+ let gateway = address.split(separator: ".").prefix(3).joined(separator: ".") + ".1"
79
+ let parameters = NWParameters.tcp
80
+ parameters.requiredInterfaceType = .wifi
81
+ let connection = NWConnection(host: NWEndpoint.Host(gateway), port: 8089, using: parameters)
82
+ var finished = false
83
+ let finish: (Bool, String) -> Void = { reachable, detail in
84
+ guard !finished else { return }
85
+ finished = true
86
+ connection.stateUpdateHandler = nil
87
+ connection.cancel()
88
+ completion(reachable, detail)
89
+ }
90
+ connection.stateUpdateHandler = { state in
91
+ switch state {
92
+ case .ready: finish(true, "\(gateway):8089")
93
+ case let .failed(error): finish(false, error.localizedDescription)
94
+ default: break
95
+ }
96
+ }
97
+ connection.start(queue: self.queue)
98
+ self.queue.asyncAfter(deadline: .now() + 3) { finish(false, "Gateway probe timed out") }
99
+ }
100
+ }
101
+
102
+ public func awaitInternet(completion: @escaping (Bool, String) -> Void) {
103
+ queue.async {
104
+ let monitor = NWPathMonitor()
105
+ var finished = false
106
+ let finish: (Bool, String) -> Void = { usable, detail in
107
+ guard !finished else { return }
108
+ finished = true
109
+ monitor.cancel()
110
+ completion(usable, detail)
111
+ }
112
+ monitor.pathUpdateHandler = { path in
113
+ if path.status == .satisfied, path.usesInterfaceType(.cellular) { finish(true, "cellular") }
114
+ }
115
+ monitor.start(queue: self.queue)
116
+ self.queue.asyncAfter(deadline: .now() + 15) { finish(false, "Cellular internet did not become the default route") }
117
+ }
118
+ }
119
+
120
+ private func waitForAddress(ssid: String, generation gen: Int, remaining: Int) {
121
+ guard gen == generation, !cancelled else { return }
122
+ NEHotspotNetwork.fetchCurrent { [weak self] network in
123
+ self?.queue.async {
124
+ guard let self, gen == self.generation, !self.cancelled else { return }
125
+ if network?.ssid == ssid, let address = Self.wifiAddress() {
126
+ self.localAddress = address
127
+ self.startMonitor(generation: gen)
128
+ self.finishJoin(.success(address))
129
+ } else if remaining > 0 {
130
+ self.queue.asyncAfter(deadline: .now() + 0.5) { self.waitForAddress(ssid: ssid, generation: gen, remaining: remaining - 1) }
131
+ } else {
132
+ self.finishJoin(.failure(LocalMediaError("Glasses hotspot has no verified Wi-Fi address")))
133
+ self.finishLeave()
134
+ }
135
+ }
136
+ }
137
+ }
138
+
139
+ private func startMonitor(generation gen: Int) {
140
+ let monitor = NWPathMonitor(requiredInterfaceType: .wifi)
141
+ self.monitor = monitor
142
+ monitor.pathUpdateHandler = { [weak self] _ in
143
+ guard let self, gen == self.generation, !self.cancelled else { return }
144
+ // This AP intentionally has no internet. Loss of its default internet path is not
145
+ // loss of the local link; use the actual interface address instead.
146
+ if Self.wifiAddress() != self.localAddress { self.onLost?("Glasses hotspot connection was lost") }
147
+ }
148
+ monitor.start(queue: queue)
149
+ }
150
+
151
+ private func finishJoin(_ result: Result<String, Error>) {
152
+ let reply = joinReply
153
+ joinReply = nil
154
+ reply?(result)
155
+ }
156
+
157
+ private func finishLeave() {
158
+ generation += 1
159
+ monitor?.cancel(); monitor = nil
160
+ if let ssid { NEHotspotConfigurationManager.shared.removeConfiguration(forSSID: ssid) }
161
+ ssid = nil
162
+ localAddress = nil
163
+ let replies = leaveReplies
164
+ leaveReplies.removeAll()
165
+ replies.forEach { $0() }
166
+ }
167
+
168
+ public static func wifiAddress() -> String? {
169
+ var interfaces: UnsafeMutablePointer<ifaddrs>?
170
+ guard getifaddrs(&interfaces) == 0 else { return nil }
171
+ defer { freeifaddrs(interfaces) }
172
+ var cursor = interfaces
173
+ while let item = cursor {
174
+ defer { cursor = item.pointee.ifa_next }
175
+ let value = item.pointee
176
+ guard String(cString: value.ifa_name) == "en0", value.ifa_flags & UInt32(IFF_UP) != 0,
177
+ let address = value.ifa_addr, address.pointee.sa_family == UInt8(AF_INET) else { continue }
178
+ var host = [CChar](repeating: 0, count: Int(NI_MAXHOST))
179
+ guard getnameinfo(address, socklen_t(address.pointee.sa_len), &host, socklen_t(host.count), nil, 0, NI_NUMERICHOST) == 0 else { continue }
180
+ let ip = String(cString: host)
181
+ if LocalMediaPolicy.isPrivate(ip) { return ip }
182
+ }
183
+ return nil
184
+ }
185
+ }
@@ -0,0 +1,18 @@
1
+ require 'json'
2
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
3
+ Pod::Spec.new do |s|
4
+ s.name = 'GlassesMedia'
5
+ s.version = package['version']
6
+ s.summary = package['description']
7
+ s.homepage = package['homepage']
8
+ s.license = package['license']
9
+ s.author = package['author']
10
+ s.source = { git: 'https://github.com/Mentra-Community/MentraOS.git' }
11
+ s.platforms = { :ios => '15.1' }
12
+ s.swift_version = '5.9'
13
+ s.static_framework = true
14
+ s.dependency 'WebRTC-SDK', '137.7151.09'
15
+ s.frameworks = 'AVFoundation', 'CoreMedia', 'CoreVideo', 'Network', 'NetworkExtension'
16
+ s.source_files = '*.{swift,h,m,mm}', 'CoreKit/Sources/GlassesMediaCore/*.swift'
17
+ s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
18
+ end