@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.
- package/README.md +35 -0
- package/android/build.gradle +84 -0
- package/android/src/main/AndroidManifest.xml +6 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/InternetHold.kt +501 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/Ipv4Prefix.kt +51 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/NetworkFacts.kt +81 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkChangeDetector.kt +355 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkError.kt +109 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkObserver.kt +160 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkReadiness.kt +103 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkRequestSpec.kt +53 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/ScopedNetworkState.kt +127 -0
- package/android/src/main/java/com/mentra/glassesmedia/network/ScopedSoftApNetwork.kt +573 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/BitmapFont.kt +99 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/CloudflareWhepSource.kt +487 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/DecodedTrackRelay.kt +147 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/FirstFrameGate.kt +45 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/GlassesMediaSource.kt +143 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/LocalWhipIngestSource.kt +772 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/MediaListeners.kt +57 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/SdpAdapter.kt +17 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/SelectedIcePair.kt +139 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/SoftApIceDiagnosis.kt +144 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/SoftApIcePolicy.kt +46 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/SoftApSdpGuard.kt +160 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/SyntheticFrameFactory.kt +205 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/SyntheticI420Source.kt +214 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/TrackRegistry.kt +33 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/VideoSourceArm.kt +110 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/WhipIngestProtocol.kt +207 -0
- package/android/src/main/java/com/mentra/glassesmedia/source/WhipIngestServer.kt +356 -0
- package/android/src/main/java/com/mentra/glassesmedia/telemetry/AvSyncProbe.kt +155 -0
- package/android/src/main/java/com/mentra/glassesmedia/telemetry/ChromaProbe.kt +78 -0
- package/android/src/main/java/com/mentra/glassesmedia/telemetry/PipelineStats.kt +372 -0
- package/android/src/main/java/com/mentra/glassesmedia/telemetry/PipelineTicker.kt +79 -0
- package/android/src/main/java/com/mentra/glassesmedia/telemetry/ProcessCpu.kt +15 -0
- package/android/src/main/java/com/mentra/glassesmedia/telemetry/RingPercentile.kt +48 -0
- package/android/src/main/java/com/mentra/glassesmedia/telemetry/VideoQuality.kt +178 -0
- package/android/src/main/java/com/mentra/glassesmedia/telemetry/VideoRateVerdict.kt +145 -0
- package/android/src/main/java/com/mentra/glassesmedia/trace/SoftApTrace.kt +143 -0
- package/android/src/main/java/com/mentra/glassesmedia/video/FrameGeometry.kt +22 -0
- package/android/src/main/java/com/mentra/glassesmedia/video/I420Packer.kt +72 -0
- package/expo-module.config.json +12 -0
- package/index.js +1 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/GlassesMediaSource.swift +104 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/LocalMediaPolicy.swift +51 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/WhipIngestServer.swift +145 -0
- package/ios/CoreKit/Sources/GlassesMediaCore/WhipRequest.swift +59 -0
- package/ios/DecodedGlassesMediaSource.swift +9 -0
- package/ios/DecodedPcm.swift +33 -0
- package/ios/GlassesHotspotNetwork.swift +185 -0
- package/ios/GlassesMedia.podspec +18 -0
- package/ios/LocalWhipIngestSource.swift +325 -0
- package/ios/ReceiveOnlyAudioDevice.swift +106 -0
- package/ios/WhepVideoSource.swift +332 -0
- 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
|