@mentra/bluetooth-sdk 0.1.21-beta.5 → 3.1.0-dev.7
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 +38 -2
- 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 +39 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +198 -17
- 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/MentraLive.kt +1174 -191
- 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/MentraLiveGattCharacteristicsTest.kt +39 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
- package/build/BluetoothSdk.types.d.ts +61 -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 +47 -3
- package/ios/Source/DeviceManager.swift +135 -10
- 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 +439 -30
- 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/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 +72 -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,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
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// Expo surface for the hotspot-served OTA file server (OS-1676).
|
|
5
|
+
///
|
|
6
|
+
/// JS hands over the rewritten manifest body and a map of sha256 -> local file path, and gets
|
|
7
|
+
/// back the base URL the glasses should be pointed at via ota_start. The optional host
|
|
8
|
+
/// argument lets the caller pass an authoritative address; otherwise interface scanning is
|
|
9
|
+
/// used (under a glasses-hotspot session en0 holds the hotspot client address).
|
|
10
|
+
public class MentraOtaServerModule: Module {
|
|
11
|
+
private let serverLock = NSLock()
|
|
12
|
+
private var otaServer: LocalOtaServer?
|
|
13
|
+
private lazy var artifactDownloader = BackgroundOtaArtifactDownloader { [weak self] destination, written, total in
|
|
14
|
+
self?.sendEvent("artifactDownloadProgress", [
|
|
15
|
+
"destination": destination,
|
|
16
|
+
"bytesWritten": written,
|
|
17
|
+
"contentLength": total,
|
|
18
|
+
])
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public func definition() -> ModuleDefinition {
|
|
22
|
+
Name("MentraOtaServer")
|
|
23
|
+
|
|
24
|
+
Events("artifactDownloadProgress")
|
|
25
|
+
|
|
26
|
+
AsyncFunction("startOtaServer") { (manifestJson: String, artifactPaths: [String: String], host: String?) -> [String: Any] in
|
|
27
|
+
try self.startOtaServer(manifestJson: manifestJson, artifactPaths: artifactPaths, hostOverride: host)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
AsyncFunction("stopOtaServer") {
|
|
31
|
+
self.stopOtaServerInternal()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
AsyncFunction("waitForWifiAddress") { (gateway: String, timeoutMs: Int) -> String in
|
|
35
|
+
try await self.waitForWifiAddress(gateway: gateway, timeoutMs: timeoutMs)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
AsyncFunction("downloadArtifact") { (source: String, destination: String) in
|
|
39
|
+
try await self.artifactDownloader.download(from: source, to: destination)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
OnDestroy {
|
|
43
|
+
self.stopOtaServerInternal()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private func startOtaServer(
|
|
48
|
+
manifestJson: String,
|
|
49
|
+
artifactPaths: [String: String],
|
|
50
|
+
hostOverride: String?
|
|
51
|
+
) throws -> [String: Any] {
|
|
52
|
+
serverLock.lock()
|
|
53
|
+
defer { serverLock.unlock() }
|
|
54
|
+
|
|
55
|
+
var artifacts: [String: URL] = [:]
|
|
56
|
+
for (key, path) in artifactPaths {
|
|
57
|
+
let url = path.hasPrefix("file://")
|
|
58
|
+
? (URL(string: path) ?? URL(fileURLWithPath: String(path.dropFirst("file://".count))))
|
|
59
|
+
: URL(fileURLWithPath: path)
|
|
60
|
+
guard FileManager.default.fileExists(atPath: url.path) else {
|
|
61
|
+
throw OtaServerModuleError("Artifact \(key) not found at \(url.path)")
|
|
62
|
+
}
|
|
63
|
+
artifacts[key] = url
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
guard let host = hostOverride?.isEmpty == false ? hostOverride : LocalIPv4.bestLocalIPv4Address() else {
|
|
67
|
+
throw OtaServerModuleError("No Wi-Fi/LAN IPv4 address found for this phone.")
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let server = otaServer ?? LocalOtaServer(
|
|
71
|
+
onLog: { [weak self] message in
|
|
72
|
+
self?.emitStatus(message: message)
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
otaServer = server
|
|
76
|
+
server.configure(manifestJson: manifestJson, artifacts: artifacts)
|
|
77
|
+
|
|
78
|
+
if let activePort = server.activePort {
|
|
79
|
+
emitStatus(message: "OTA server ready at http://\(host):\(activePort)/version.json")
|
|
80
|
+
return serverResult(host: host, port: activePort)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
var lastError: Error?
|
|
84
|
+
for port in otaPorts {
|
|
85
|
+
do {
|
|
86
|
+
let actualPort = try server.start(host: host, port: UInt16(port))
|
|
87
|
+
emitStatus(message: "OTA server ready at http://\(host):\(actualPort)/version.json")
|
|
88
|
+
return serverResult(host: host, port: actualPort)
|
|
89
|
+
} catch {
|
|
90
|
+
lastError = error
|
|
91
|
+
emitStatus(message: "Port \(port) unavailable: \(error.localizedDescription)")
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
throw OtaServerModuleError(
|
|
96
|
+
"Could not start OTA server: \(lastError?.localizedDescription ?? "all ports unavailable")"
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private func waitForWifiAddress(gateway: String, timeoutMs: Int) async throws -> String {
|
|
101
|
+
let deadline = Date().addingTimeInterval(Double(max(timeoutMs, 0)) / 1000.0)
|
|
102
|
+
repeat {
|
|
103
|
+
if let address = LocalIPv4.wifiIPv4Address(matchingGateway: gateway) {
|
|
104
|
+
return address
|
|
105
|
+
}
|
|
106
|
+
try await Task.sleep(nanoseconds: 250_000_000)
|
|
107
|
+
} while Date() < deadline
|
|
108
|
+
|
|
109
|
+
throw OtaServerModuleError("The iPhone did not join the glasses hotspot subnet.")
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private func stopOtaServerInternal() {
|
|
113
|
+
serverLock.lock()
|
|
114
|
+
defer { serverLock.unlock() }
|
|
115
|
+
|
|
116
|
+
otaServer?.stop()
|
|
117
|
+
emitStatus(message: "OTA server stopped")
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private func emitStatus(message: String) {
|
|
121
|
+
NSLog("[MentraOtaServer] %@", message)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private func serverResult(host: String, port: UInt16) -> [String: Any] {
|
|
125
|
+
[
|
|
126
|
+
"baseUrl": "http://\(host):\(port)",
|
|
127
|
+
"manifestUrl": "http://\(host):\(port)/version.json",
|
|
128
|
+
"host": host,
|
|
129
|
+
"port": port,
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private let otaPorts = [8791, 8792, 8793, 8794]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
final class OtaServerModuleError: Exception, @unchecked Sendable {
|
|
137
|
+
private let message: String
|
|
138
|
+
|
|
139
|
+
init(_ message: String) {
|
|
140
|
+
self.message = message
|
|
141
|
+
super.init()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
override var reason: String {
|
|
145
|
+
message
|
|
146
|
+
}
|
|
147
|
+
}
|