@mentra/bluetooth-sdk 0.1.21-beta.3 → 3.1.0-dev.10
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 +56 -9
- package/android/build.gradle +7 -4
- package/android/lc3Lib/build.gradle +18 -4
- package/android/silero/build.gradle +9 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +16 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +35 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +216 -23
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +2 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +11 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +112 -8
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +93 -12
- package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +5 -7
- package/android/src/main/java/com/mentra/bluetoothsdk/ScopedNetworkReadiness.kt +17 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +15 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +24 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/net/LocalIpv4.kt +57 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServer.kt +304 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/MentraOtaServerModule.kt +120 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +2 -49
- package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +61 -30
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/BesOtaHeartbeatGuard.kt +49 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapper.kt +27 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +1344 -284
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +54 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +17 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +12 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +74 -79
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +3 -3
- package/android/src/test/java/com/mentra/bluetoothsdk/OtaStartResponsePolicyTest.kt +30 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/ScopedNetworkReadinessTest.kt +39 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServerTest.kt +82 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +69 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaHeartbeatGuardTest.kt +73 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapperTest.kt +67 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +75 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
- package/build/BluetoothSdk.types.d.ts +58 -2
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_internal.d.ts +2 -0
- package/build/_internal.d.ts.map +1 -1
- package/build/_internal.js +2 -0
- package/build/_internal.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +4 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/MentraLocalNetworkModule.d.ts +1 -0
- package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
- package/build/_private/MentraLocalNetworkModule.js.map +1 -1
- package/build/_private/sdkOtaManifest.d.ts +3 -12
- package/build/_private/sdkOtaManifest.d.ts.map +1 -1
- package/build/_private/sdkOtaManifest.js +5 -17
- package/build/_private/sdkOtaManifest.js.map +1 -1
- package/build/generated/releaseMetadata.d.ts +12 -0
- package/build/generated/releaseMetadata.d.ts.map +1 -0
- package/build/generated/releaseMetadata.js +10 -0
- package/build/generated/releaseMetadata.js.map +1 -0
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +10 -2
- package/build/index.js.map +1 -1
- package/build/ota-server/MentraOtaServer.types.d.ts +14 -0
- package/build/ota-server/MentraOtaServer.types.d.ts.map +1 -0
- package/build/ota-server/MentraOtaServer.types.js +2 -0
- package/build/ota-server/MentraOtaServer.types.js.map +1 -0
- package/build/ota-server/MentraOtaServerModule.d.ts +21 -0
- package/build/ota-server/MentraOtaServerModule.d.ts.map +1 -0
- package/build/ota-server/MentraOtaServerModule.js +3 -0
- package/build/ota-server/MentraOtaServerModule.js.map +1 -0
- package/build/ota-server/MentraOtaServerModule.web.d.ts +10 -0
- package/build/ota-server/MentraOtaServerModule.web.d.ts.map +1 -0
- package/build/ota-server/MentraOtaServerModule.web.js +12 -0
- package/build/ota-server/MentraOtaServerModule.web.js.map +1 -0
- package/build/ota-server/index.d.ts +3 -0
- package/build/ota-server/index.d.ts.map +1 -0
- package/build/ota-server/index.js +3 -0
- package/build/ota-server/index.js.map +1 -0
- package/expo-module.config.json +3 -2
- package/ios/BackgroundOtaArtifactDownloader.swift +125 -0
- package/ios/BluetoothSdkModule.swift +16 -2
- package/ios/LocalIPv4.swift +150 -0
- package/ios/LocalOtaServer.swift +309 -0
- package/ios/MentraBluetoothSDK.podspec +5 -1
- package/ios/MentraOtaServerModule.swift +147 -0
- package/ios/MentraPhotoReceiverModule.swift +1 -81
- package/ios/Source/BluetoothSdkDefaults.swift +4 -1
- package/ios/Source/Bridge.swift +43 -4
- package/ios/Source/DeviceManager.swift +154 -19
- package/ios/Source/DeviceStore.swift +2 -0
- package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
- package/ios/Source/MentraBluetoothSDK.swift +74 -6
- package/ios/Source/OtaManifest.swift +5 -7
- package/ios/Source/audio/AudioModels.swift +17 -0
- package/ios/Source/camera/CameraModels.swift +1 -0
- package/ios/Source/events/BluetoothEvents.swift +27 -0
- package/ios/Source/sgcs/MentraLive.swift +469 -77
- package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +64 -0
- package/ios/Source/sgcs/SGCManager.swift +11 -0
- package/ios/Source/status/DeviceStatus.swift +23 -0
- package/ios/Source/types/DeviceModels.swift +23 -2
- package/ios/Source/utils/BleJsonCompact.swift +73 -71
- package/ios/Tests/MentraLivePairingAdvertisementTests.swift +104 -0
- package/package.json +5 -1
- package/plugin/build/withIos.d.ts +3 -1
- package/plugin/build/withIos.js +22 -10
- package/scripts/verify-release-package.mjs +34 -0
- package/scripts/write-release-metadata.mjs +154 -0
- package/scripts/write-release-metadata.test.mjs +29 -0
- package/src/BluetoothSdk.types.ts +69 -3
- package/src/_internal.ts +2 -0
- package/src/_private/BluetoothSdkModule.ts +5 -5
- package/src/_private/MentraLocalNetworkModule.ts +1 -1
- package/src/_private/sdkOtaManifest.ts +5 -17
- package/src/generated/releaseMetadata.ts +20 -0
- package/src/index.ts +19 -2
- package/src/ota-server/MentraOtaServer.types.ts +10 -0
- package/src/ota-server/MentraOtaServerModule.ts +23 -0
- package/src/ota-server/MentraOtaServerModule.web.ts +17 -0
- package/src/ota-server/index.ts +2 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/ota-server/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,OAAO,EAAC,MAAM,yBAAyB,CAAA;AAC/C,cAAc,yBAAyB,CAAA","sourcesContent":["export {default} from \"./MentraOtaServerModule\"\nexport * from \"./MentraOtaServer.types\"\n"]}
|
package/expo-module.config.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"platforms": ["apple", "android"],
|
|
3
3
|
"apple": {
|
|
4
|
-
"modules": ["BluetoothSdkModule", "MentraPhotoReceiverModule"]
|
|
4
|
+
"modules": ["BluetoothSdkModule", "MentraPhotoReceiverModule", "MentraOtaServerModule"]
|
|
5
5
|
},
|
|
6
6
|
"android": {
|
|
7
7
|
"modules": [
|
|
8
8
|
"com.mentra.bluetoothsdk.BluetoothSdkModule",
|
|
9
9
|
"com.mentra.bluetoothsdk.MentraLocalNetworkModule",
|
|
10
|
-
"com.mentra.bluetoothsdk.photoreceiver.MentraPhotoReceiverModule"
|
|
10
|
+
"com.mentra.bluetoothsdk.photoreceiver.MentraPhotoReceiverModule",
|
|
11
|
+
"com.mentra.bluetoothsdk.otaserver.MentraOtaServerModule"
|
|
11
12
|
]
|
|
12
13
|
}
|
|
13
14
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Background-capable, native artifact staging for hotspot OTA.
|
|
4
|
+
final class BackgroundOtaArtifactDownloader: NSObject, URLSessionDownloadDelegate, URLSessionTaskDelegate {
|
|
5
|
+
typealias ProgressHandler = (_ destination: String, _ written: Int64, _ total: Int64) -> Void
|
|
6
|
+
|
|
7
|
+
private struct Pending {
|
|
8
|
+
let continuation: CheckedContinuation<[String: Any], Error>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
private let lock = NSLock()
|
|
12
|
+
private let onProgress: ProgressHandler
|
|
13
|
+
private var pending: [Int: Pending] = [:]
|
|
14
|
+
private lazy var session: URLSession = {
|
|
15
|
+
let configuration = URLSessionConfiguration.background(
|
|
16
|
+
withIdentifier: "com.mentra.bluetooth-sdk.hotspot-ota-artifacts"
|
|
17
|
+
)
|
|
18
|
+
configuration.sessionSendsLaunchEvents = true
|
|
19
|
+
configuration.isDiscretionary = false
|
|
20
|
+
configuration.waitsForConnectivity = true
|
|
21
|
+
return URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
|
|
22
|
+
}()
|
|
23
|
+
|
|
24
|
+
init(onProgress: @escaping ProgressHandler) {
|
|
25
|
+
self.onProgress = onProgress
|
|
26
|
+
super.init()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func download(from source: String, to destination: String) async throws -> [String: Any] {
|
|
30
|
+
guard let sourceUrl = URL(string: source),
|
|
31
|
+
sourceUrl.scheme == "https" || sourceUrl.scheme == "http"
|
|
32
|
+
else {
|
|
33
|
+
throw OtaServerError("Invalid artifact URL")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if FileManager.default.fileExists(atPath: destination) {
|
|
37
|
+
let size = fileSize(atPath: destination)
|
|
38
|
+
return ["statusCode": 200, "bytesWritten": size]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try FileManager.default.createDirectory(
|
|
42
|
+
at: URL(fileURLWithPath: destination).deletingLastPathComponent(),
|
|
43
|
+
withIntermediateDirectories: true
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return try await withCheckedThrowingContinuation { continuation in
|
|
47
|
+
let task = session.downloadTask(with: sourceUrl)
|
|
48
|
+
task.taskDescription = destination
|
|
49
|
+
lock.lock()
|
|
50
|
+
pending[task.taskIdentifier] = Pending(continuation: continuation)
|
|
51
|
+
lock.unlock()
|
|
52
|
+
task.resume()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func urlSession(
|
|
57
|
+
_: URLSession,
|
|
58
|
+
downloadTask: URLSessionDownloadTask,
|
|
59
|
+
didWriteData _: Int64,
|
|
60
|
+
totalBytesWritten: Int64,
|
|
61
|
+
totalBytesExpectedToWrite: Int64
|
|
62
|
+
) {
|
|
63
|
+
guard let destination = downloadTask.taskDescription else { return }
|
|
64
|
+
onProgress(destination, totalBytesWritten, totalBytesExpectedToWrite)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func urlSession(
|
|
68
|
+
_: URLSession,
|
|
69
|
+
downloadTask: URLSessionDownloadTask,
|
|
70
|
+
didFinishDownloadingTo location: URL
|
|
71
|
+
) {
|
|
72
|
+
guard let destination = downloadTask.taskDescription else { return }
|
|
73
|
+
guard let response = downloadTask.response as? HTTPURLResponse,
|
|
74
|
+
(200 ... 299).contains(response.statusCode)
|
|
75
|
+
else {
|
|
76
|
+
let status = (downloadTask.response as? HTTPURLResponse)?.statusCode ?? 0
|
|
77
|
+
reject(
|
|
78
|
+
taskId: downloadTask.taskIdentifier,
|
|
79
|
+
error: OtaServerError("Artifact download failed with HTTP \(status)")
|
|
80
|
+
)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
let destinationUrl = URL(fileURLWithPath: destination)
|
|
84
|
+
do {
|
|
85
|
+
try? FileManager.default.removeItem(at: destinationUrl)
|
|
86
|
+
try FileManager.default.moveItem(at: location, to: destinationUrl)
|
|
87
|
+
let size = fileSize(atPath: destination)
|
|
88
|
+
resolve(
|
|
89
|
+
taskId: downloadTask.taskIdentifier,
|
|
90
|
+
result: ["statusCode": response.statusCode, "bytesWritten": size]
|
|
91
|
+
)
|
|
92
|
+
} catch {
|
|
93
|
+
reject(taskId: downloadTask.taskIdentifier, error: error)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func urlSession(_: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
|
98
|
+
if let error {
|
|
99
|
+
reject(taskId: task.taskIdentifier, error: error)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private func resolve(taskId: Int, result: [String: Any]) {
|
|
104
|
+
lock.lock()
|
|
105
|
+
let item = pending.removeValue(forKey: taskId)
|
|
106
|
+
lock.unlock()
|
|
107
|
+
item?.continuation.resume(returning: result)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private func reject(taskId: Int, error: Error) {
|
|
111
|
+
lock.lock()
|
|
112
|
+
let item = pending.removeValue(forKey: taskId)
|
|
113
|
+
lock.unlock()
|
|
114
|
+
item?.continuation.resume(throwing: error)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private func fileSize(atPath path: String) -> Int64 {
|
|
118
|
+
guard let value = try? FileManager.default.attributesOfItem(atPath: path)[.size],
|
|
119
|
+
let number = value as? NSNumber
|
|
120
|
+
else {
|
|
121
|
+
return 0
|
|
122
|
+
}
|
|
123
|
+
return number.int64Value
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -46,6 +46,9 @@ public class BluetoothSdkModule: Module, MentraBluetoothSDKDelegate {
|
|
|
46
46
|
"settings_ack",
|
|
47
47
|
"version_info",
|
|
48
48
|
"pair_failure",
|
|
49
|
+
"pairing_info",
|
|
50
|
+
"entering_pairing_mode",
|
|
51
|
+
"owner_replaced",
|
|
49
52
|
"audio_pairing_needed",
|
|
50
53
|
"audio_connected",
|
|
51
54
|
"audio_disconnected",
|
|
@@ -57,6 +60,7 @@ public class BluetoothSdkModule: Module, MentraBluetoothSDKDelegate {
|
|
|
57
60
|
"ws_bin",
|
|
58
61
|
"mic_pcm",
|
|
59
62
|
"mic_lc3",
|
|
63
|
+
"mic_health",
|
|
60
64
|
"stream_status",
|
|
61
65
|
"keep_alive_ack",
|
|
62
66
|
"mtk_update_complete",
|
|
@@ -303,6 +307,11 @@ public class BluetoothSdkModule: Module, MentraBluetoothSDKDelegate {
|
|
|
303
307
|
return try await sdk.setHotspotState(enabled: enabled).values
|
|
304
308
|
}
|
|
305
309
|
|
|
310
|
+
AsyncFunction("setWifiAdbState") { (enabled: Bool) in
|
|
311
|
+
let sdk = await MainActor.run { self.bluetoothSdk() }
|
|
312
|
+
try await sdk.setWifiAdbState(enabled: enabled)
|
|
313
|
+
}
|
|
314
|
+
|
|
306
315
|
AsyncFunction("setSystemTime") { (timestampMs: Double) in
|
|
307
316
|
let maxTimestamp = Double(Int64.max).nextDown
|
|
308
317
|
guard timestampMs.isFinite,
|
|
@@ -562,6 +571,11 @@ public class BluetoothSdkModule: Module, MentraBluetoothSDKDelegate {
|
|
|
562
571
|
).values
|
|
563
572
|
}
|
|
564
573
|
|
|
574
|
+
AsyncFunction("queryVideoRecordingStatus") { (requestId: String) in
|
|
575
|
+
let sdk = await MainActor.run { self.bluetoothSdk() }
|
|
576
|
+
return try await sdk.queryVideoRecordingStatus(requestId: requestId).values
|
|
577
|
+
}
|
|
578
|
+
|
|
565
579
|
// MARK: - Stream Commands
|
|
566
580
|
|
|
567
581
|
AsyncFunction("startStream") { (params: [String: Any]) in
|
|
@@ -812,6 +826,8 @@ public class BluetoothSdkModule: Module, MentraBluetoothSDKDelegate {
|
|
|
812
826
|
sendEvent("voice_activity_detection_status", status.values)
|
|
813
827
|
case let .speakingStatus(status):
|
|
814
828
|
sendEvent("speaking_status", status.values)
|
|
829
|
+
case let .micHealth(health):
|
|
830
|
+
sendEvent("mic_health", health.values)
|
|
815
831
|
case let .wifiStatus(status):
|
|
816
832
|
sendEvent("wifi_status_change", status.values)
|
|
817
833
|
case let .hotspotStatus(status):
|
|
@@ -921,5 +937,3 @@ private extension ConnectOptions {
|
|
|
921
937
|
|
|
922
938
|
|
|
923
939
|
|
|
924
|
-
|
|
925
|
-
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import Darwin
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// Best-effort local IPv4 discovery for phone-hosted servers the glasses reach over WiFi
|
|
5
|
+
/// (photo upload receiver, hotspot-served OTA). Prefers WiFi-ish interfaces carrying RFC1918
|
|
6
|
+
/// addresses — under a glasses-hotspot session en0 holds the 192.168.43.x client address.
|
|
7
|
+
enum LocalIPv4 {
|
|
8
|
+
/// Return the active Wi-Fi address only when it is on the glasses gateway's /24.
|
|
9
|
+
/// iPhone Wi-Fi is `en0`; accepting AWDL, VPN, cellular, or another private
|
|
10
|
+
/// interface can publish an endpoint that the glasses cannot reach.
|
|
11
|
+
static func wifiIPv4Address(matchingGateway gateway: String) -> String? {
|
|
12
|
+
guard let gatewayPrefix = subnet24Prefix(gateway) else {
|
|
13
|
+
return nil
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
var interfaces: UnsafeMutablePointer<ifaddrs>?
|
|
17
|
+
guard getifaddrs(&interfaces) == 0, let first = interfaces else {
|
|
18
|
+
return nil
|
|
19
|
+
}
|
|
20
|
+
defer { freeifaddrs(interfaces) }
|
|
21
|
+
|
|
22
|
+
var cursor: UnsafeMutablePointer<ifaddrs>? = first
|
|
23
|
+
while let current = cursor {
|
|
24
|
+
defer { cursor = current.pointee.ifa_next }
|
|
25
|
+
let interface = current.pointee
|
|
26
|
+
guard String(cString: interface.ifa_name) == "en0",
|
|
27
|
+
let addressPointer = interface.ifa_addr,
|
|
28
|
+
addressPointer.pointee.sa_family == UInt8(AF_INET),
|
|
29
|
+
(interface.ifa_flags & UInt32(IFF_UP)) != 0
|
|
30
|
+
else {
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
var address = addressPointer.pointee
|
|
35
|
+
var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
|
36
|
+
guard getnameinfo(
|
|
37
|
+
&address,
|
|
38
|
+
socklen_t(address.sa_len),
|
|
39
|
+
&hostname,
|
|
40
|
+
socklen_t(hostname.count),
|
|
41
|
+
nil,
|
|
42
|
+
0,
|
|
43
|
+
NI_NUMERICHOST
|
|
44
|
+
) == 0 else {
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let ip = String(cString: hostname)
|
|
49
|
+
if subnet24Prefix(ip) == gatewayPrefix {
|
|
50
|
+
return ip
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return nil
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static func bestLocalIPv4Address() -> String? {
|
|
57
|
+
var interfaces: UnsafeMutablePointer<ifaddrs>?
|
|
58
|
+
guard getifaddrs(&interfaces) == 0, let first = interfaces else {
|
|
59
|
+
return nil
|
|
60
|
+
}
|
|
61
|
+
defer { freeifaddrs(interfaces) }
|
|
62
|
+
|
|
63
|
+
var preferredPrivate: String?
|
|
64
|
+
var preferred: String?
|
|
65
|
+
var privateFallback: String?
|
|
66
|
+
var fallback: String?
|
|
67
|
+
var cursor: UnsafeMutablePointer<ifaddrs>? = first
|
|
68
|
+
while let current = cursor {
|
|
69
|
+
defer { cursor = current.pointee.ifa_next }
|
|
70
|
+
let interface = current.pointee
|
|
71
|
+
guard let addressPointer = interface.ifa_addr,
|
|
72
|
+
addressPointer.pointee.sa_family == UInt8(AF_INET)
|
|
73
|
+
else {
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let name = String(cString: interface.ifa_name)
|
|
78
|
+
let flags = interface.ifa_flags
|
|
79
|
+
guard (flags & UInt32(IFF_UP)) != 0,
|
|
80
|
+
(flags & UInt32(IFF_LOOPBACK)) == 0
|
|
81
|
+
else {
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
var address = addressPointer.pointee
|
|
86
|
+
var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
|
87
|
+
let result = getnameinfo(
|
|
88
|
+
&address,
|
|
89
|
+
socklen_t(address.sa_len),
|
|
90
|
+
&hostname,
|
|
91
|
+
socklen_t(hostname.count),
|
|
92
|
+
nil,
|
|
93
|
+
0,
|
|
94
|
+
NI_NUMERICHOST
|
|
95
|
+
)
|
|
96
|
+
guard result == 0 else {
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let ip = String(cString: hostname)
|
|
101
|
+
guard ip != "127.0.0.1", !isLinkLocalIPv4(ip) else {
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
if isPreferredLocalInterface(name), isPrivateIPv4(ip) {
|
|
105
|
+
preferredPrivate = preferredPrivate ?? ip
|
|
106
|
+
} else if isPreferredLocalInterface(name) {
|
|
107
|
+
preferred = preferred ?? ip
|
|
108
|
+
} else if isPrivateIPv4(ip) {
|
|
109
|
+
privateFallback = privateFallback ?? ip
|
|
110
|
+
} else {
|
|
111
|
+
fallback = fallback ?? ip
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return preferredPrivate ?? preferred ?? privateFallback ?? fallback
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private static func isPreferredLocalInterface(_ name: String) -> Bool {
|
|
119
|
+
name == "en0" ||
|
|
120
|
+
name.hasPrefix("bridge") ||
|
|
121
|
+
name.hasPrefix("ap") ||
|
|
122
|
+
name.hasPrefix("awdl") ||
|
|
123
|
+
name.hasPrefix("llw")
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private static func isPrivateIPv4(_ ip: String) -> Bool {
|
|
127
|
+
if ip.hasPrefix("192.168.") || ip.hasPrefix("10.") {
|
|
128
|
+
return true
|
|
129
|
+
}
|
|
130
|
+
let parts = ip.split(separator: ".").compactMap { Int($0) }
|
|
131
|
+
return parts.count == 4 && parts[0] == 172 && (16 ... 31).contains(parts[1])
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private static func isLinkLocalIPv4(_ ip: String) -> Bool {
|
|
135
|
+
ip.hasPrefix("169.254.")
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private static func subnet24Prefix(_ ip: String) -> String? {
|
|
139
|
+
let parts = ip.split(separator: ".")
|
|
140
|
+
guard parts.count == 4,
|
|
141
|
+
parts.allSatisfy({ part in
|
|
142
|
+
guard let value = Int(part) else { return false }
|
|
143
|
+
return (0 ... 255).contains(value)
|
|
144
|
+
})
|
|
145
|
+
else {
|
|
146
|
+
return nil
|
|
147
|
+
}
|
|
148
|
+
return parts.prefix(3).joined(separator: ".")
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Network
|
|
3
|
+
|
|
4
|
+
struct OtaServerError: LocalizedError {
|
|
5
|
+
let message: String
|
|
6
|
+
|
|
7
|
+
init(_ message: String) {
|
|
8
|
+
self.message = message
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
var errorDescription: String? { message }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/// Static HTTP server for hotspot-served glasses OTA (OS-1676).
|
|
15
|
+
///
|
|
16
|
+
/// Serves the rewritten OTA manifest at /version.json and pre-downloaded artifacts at
|
|
17
|
+
/// /artifacts/<sha256> to the glasses over the glasses-hotspot link. Mirrors
|
|
18
|
+
/// LocalPhotoUploadServer's NWListener structure.
|
|
19
|
+
final class LocalOtaServer {
|
|
20
|
+
private let queue = DispatchQueue(label: "com.mentra.ota-server")
|
|
21
|
+
private let onLog: (String) -> Void
|
|
22
|
+
private var listener: NWListener?
|
|
23
|
+
private var listenerGeneration = 0
|
|
24
|
+
private(set) var activePort: UInt16?
|
|
25
|
+
|
|
26
|
+
private var manifestJson = "{}"
|
|
27
|
+
private var artifacts: [String: URL] = [:]
|
|
28
|
+
|
|
29
|
+
private static let maxHeaderBytes = 64 * 1024
|
|
30
|
+
private static let streamChunkBytes = 256 * 1024
|
|
31
|
+
|
|
32
|
+
init(onLog: @escaping (String) -> Void) {
|
|
33
|
+
self.onLog = onLog
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// Swap the served manifest and artifact set. Safe while the server is running.
|
|
37
|
+
func configure(manifestJson: String, artifacts: [String: URL]) {
|
|
38
|
+
queue.sync {
|
|
39
|
+
self.manifestJson = manifestJson
|
|
40
|
+
self.artifacts = artifacts
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func start(host: String, port: UInt16) throws -> UInt16 {
|
|
45
|
+
if listener != nil, activePort == port {
|
|
46
|
+
onLog("Already listening on \(host):\(port)")
|
|
47
|
+
return port
|
|
48
|
+
}
|
|
49
|
+
stop()
|
|
50
|
+
|
|
51
|
+
let parameters = NWParameters.tcp
|
|
52
|
+
parameters.allowLocalEndpointReuse = true
|
|
53
|
+
guard let endpointPort = NWEndpoint.Port(rawValue: port) else {
|
|
54
|
+
throw OtaServerError("Invalid port \(port)")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host(host), port: endpointPort)
|
|
58
|
+
let listener = try NWListener(using: parameters)
|
|
59
|
+
listenerGeneration += 1
|
|
60
|
+
let generation = listenerGeneration
|
|
61
|
+
let started = DispatchSemaphore(value: 0)
|
|
62
|
+
var startError: Error?
|
|
63
|
+
var isReady = false
|
|
64
|
+
|
|
65
|
+
listener.stateUpdateHandler = { [weak self] state in
|
|
66
|
+
guard let self, self.listenerGeneration == generation else {
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
switch state {
|
|
70
|
+
case .ready:
|
|
71
|
+
isReady = true
|
|
72
|
+
self.activePort = port
|
|
73
|
+
self.onLog("OTA server listening on \(host):\(port)")
|
|
74
|
+
started.signal()
|
|
75
|
+
case .failed(let error):
|
|
76
|
+
startError = error
|
|
77
|
+
self.activePort = nil
|
|
78
|
+
self.listener = nil
|
|
79
|
+
self.onLog("OTA server failed on \(port): \(error)")
|
|
80
|
+
started.signal()
|
|
81
|
+
case .cancelled:
|
|
82
|
+
self.activePort = nil
|
|
83
|
+
if !isReady {
|
|
84
|
+
startError = OtaServerError("Listener cancelled")
|
|
85
|
+
started.signal()
|
|
86
|
+
}
|
|
87
|
+
default:
|
|
88
|
+
break
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
listener.newConnectionHandler = { [weak self] connection in
|
|
93
|
+
self?.handle(connection)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
self.listener = listener
|
|
97
|
+
listener.start(queue: queue)
|
|
98
|
+
|
|
99
|
+
if started.wait(timeout: .now() + 2) == .timedOut {
|
|
100
|
+
self.listener = nil
|
|
101
|
+
listener.cancel()
|
|
102
|
+
throw OtaServerError("Timed out starting listener")
|
|
103
|
+
}
|
|
104
|
+
if let startError {
|
|
105
|
+
self.listener = nil
|
|
106
|
+
listener.cancel()
|
|
107
|
+
throw startError
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return port
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
func stop() {
|
|
114
|
+
listenerGeneration += 1
|
|
115
|
+
listener?.cancel()
|
|
116
|
+
listener = nil
|
|
117
|
+
activePort = nil
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private final class RequestReadState {
|
|
121
|
+
var buffer = Data()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private func handle(_ connection: NWConnection) {
|
|
125
|
+
connection.start(queue: queue)
|
|
126
|
+
receive(connection, state: RequestReadState())
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private func receive(_ connection: NWConnection, state: RequestReadState) {
|
|
130
|
+
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) {
|
|
131
|
+
[weak self] data, _, isComplete, error in
|
|
132
|
+
guard let self else {
|
|
133
|
+
connection.cancel()
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
if let error {
|
|
137
|
+
self.onLog("request failed: \(error.localizedDescription)")
|
|
138
|
+
connection.cancel()
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
if let data, !data.isEmpty {
|
|
142
|
+
state.buffer.append(data)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if let headerRange = state.buffer.range(of: Data([13, 10, 13, 10])) {
|
|
146
|
+
let headerData = state.buffer.subdata(in: state.buffer.startIndex..<headerRange.lowerBound)
|
|
147
|
+
let headerText = String(data: headerData, encoding: .isoLatin1) ?? ""
|
|
148
|
+
self.route(connection, headerText: headerText)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
if state.buffer.count > Self.maxHeaderBytes {
|
|
152
|
+
self.writeJson(connection, status: 400, body: #"{"ok":false,"error":"headers_too_large"}"#)
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
if isComplete {
|
|
156
|
+
connection.cancel()
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
self.receive(connection, state: state)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private func route(_ connection: NWConnection, headerText: String) {
|
|
164
|
+
let lines = headerText.components(separatedBy: "\r\n").filter { !$0.isEmpty }
|
|
165
|
+
let requestParts = lines.first?.components(separatedBy: " ") ?? []
|
|
166
|
+
let method = requestParts.count > 0 ? requestParts[0].uppercased() : ""
|
|
167
|
+
let rawPath = requestParts.count > 1 ? requestParts[1] : ""
|
|
168
|
+
let path = rawPath.components(separatedBy: "?")[0]
|
|
169
|
+
onLog("\(method) \(path)")
|
|
170
|
+
|
|
171
|
+
guard method == "GET" else {
|
|
172
|
+
writeJson(connection, status: 405, body: #"{"ok":false,"error":"method_not_allowed"}"#)
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if path == "/version.json" {
|
|
177
|
+
writeBody(
|
|
178
|
+
connection,
|
|
179
|
+
status: 200,
|
|
180
|
+
contentType: "application/json",
|
|
181
|
+
body: Data(manifestJson.utf8)
|
|
182
|
+
)
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if path.hasPrefix("/artifacts/") {
|
|
187
|
+
let key = String(path.dropFirst("/artifacts/".count))
|
|
188
|
+
guard let fileUrl = artifacts[key],
|
|
189
|
+
let fileSize = try? fileUrl.resourceValues(forKeys: [.fileSizeKey]).fileSize
|
|
190
|
+
else {
|
|
191
|
+
writeJson(connection, status: 404, body: #"{"ok":false,"error":"artifact_not_found"}"#)
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
sendFile(connection, fileUrl: fileUrl, fileLength: Int64(fileSize))
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
writeJson(connection, status: 404, body: #"{"ok":false,"error":"not_found"}"#)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private func sendFile(
|
|
202
|
+
_ connection: NWConnection,
|
|
203
|
+
fileUrl: URL,
|
|
204
|
+
fileLength: Int64
|
|
205
|
+
) {
|
|
206
|
+
let header =
|
|
207
|
+
"HTTP/1.1 200 OK\r\n" +
|
|
208
|
+
"Content-Type: application/octet-stream\r\n" +
|
|
209
|
+
"Content-Length: \(fileLength)\r\n" +
|
|
210
|
+
"Connection: close\r\n\r\n"
|
|
211
|
+
|
|
212
|
+
guard let fileHandle = try? FileHandle(forReadingFrom: fileUrl) else {
|
|
213
|
+
writeJson(connection, status: 500, body: #"{"ok":false,"error":"artifact_unreadable"}"#)
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
connection.send(content: Data(header.utf8), completion: .contentProcessed { [weak self] error in
|
|
218
|
+
guard let self, error == nil else {
|
|
219
|
+
try? fileHandle.close()
|
|
220
|
+
connection.cancel()
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
self.streamChunks(connection, fileHandle: fileHandle, remaining: fileLength)
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private func streamChunks(_ connection: NWConnection, fileHandle: FileHandle, remaining: Int64) {
|
|
228
|
+
if remaining <= 0 {
|
|
229
|
+
try? fileHandle.close()
|
|
230
|
+
connection.send(content: nil, contentContext: .finalMessage, isComplete: true, completion: .contentProcessed { _ in
|
|
231
|
+
connection.cancel()
|
|
232
|
+
})
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
let chunkSize = Int(min(Int64(Self.streamChunkBytes), remaining))
|
|
236
|
+
let chunk: Data
|
|
237
|
+
do {
|
|
238
|
+
guard let data = try fileHandle.read(upToCount: chunkSize), !data.isEmpty else {
|
|
239
|
+
throw OtaServerError("artifact truncated while streaming")
|
|
240
|
+
}
|
|
241
|
+
chunk = data
|
|
242
|
+
} catch {
|
|
243
|
+
onLog("stream failed: \(error.localizedDescription)")
|
|
244
|
+
try? fileHandle.close()
|
|
245
|
+
connection.cancel()
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
connection.send(content: chunk, completion: .contentProcessed { [weak self] error in
|
|
249
|
+
guard let self else {
|
|
250
|
+
try? fileHandle.close()
|
|
251
|
+
connection.cancel()
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
if let error {
|
|
255
|
+
self.onLog("stream failed: \(error.localizedDescription)")
|
|
256
|
+
try? fileHandle.close()
|
|
257
|
+
connection.cancel()
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
self.streamChunks(connection, fileHandle: fileHandle, remaining: remaining - Int64(chunk.count))
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private func writeJson(
|
|
265
|
+
_ connection: NWConnection,
|
|
266
|
+
status: Int,
|
|
267
|
+
body: String
|
|
268
|
+
) {
|
|
269
|
+
writeBody(
|
|
270
|
+
connection,
|
|
271
|
+
status: status,
|
|
272
|
+
contentType: "application/json",
|
|
273
|
+
body: Data(body.utf8)
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private func writeBody(
|
|
278
|
+
_ connection: NWConnection,
|
|
279
|
+
status: Int,
|
|
280
|
+
contentType: String,
|
|
281
|
+
body: Data
|
|
282
|
+
) {
|
|
283
|
+
let reason: String
|
|
284
|
+
switch status {
|
|
285
|
+
case 200:
|
|
286
|
+
reason = "OK"
|
|
287
|
+
case 400:
|
|
288
|
+
reason = "Bad Request"
|
|
289
|
+
case 404:
|
|
290
|
+
reason = "Not Found"
|
|
291
|
+
case 405:
|
|
292
|
+
reason = "Method Not Allowed"
|
|
293
|
+
default:
|
|
294
|
+
reason = "Internal Server Error"
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let header =
|
|
298
|
+
"HTTP/1.1 \(status) \(reason)\r\n" +
|
|
299
|
+
"Content-Type: \(contentType)\r\n" +
|
|
300
|
+
"Content-Length: \(body.count)\r\n" +
|
|
301
|
+
"Connection: close\r\n" +
|
|
302
|
+
"\r\n"
|
|
303
|
+
var response = Data(header.utf8)
|
|
304
|
+
response.append(body)
|
|
305
|
+
connection.send(content: response, completion: .contentProcessed { _ in
|
|
306
|
+
connection.cancel()
|
|
307
|
+
})
|
|
308
|
+
}
|
|
309
|
+
}
|
|
@@ -63,7 +63,11 @@ Pod::Spec.new do |s|
|
|
|
63
63
|
native_source_files.concat([
|
|
64
64
|
"BluetoothSdkModule.swift",
|
|
65
65
|
"LocalPhotoUploadServer.swift",
|
|
66
|
-
"MentraPhotoReceiverModule.swift"
|
|
66
|
+
"MentraPhotoReceiverModule.swift",
|
|
67
|
+
"LocalIPv4.swift",
|
|
68
|
+
"LocalOtaServer.swift",
|
|
69
|
+
"BackgroundOtaArtifactDownloader.swift",
|
|
70
|
+
"MentraOtaServerModule.swift"
|
|
67
71
|
]) if include_expo_adapter
|
|
68
72
|
s.source_files = native_source_files
|
|
69
73
|
|