@annadata/capacitor-mqtt-quic 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +399 -0
  2. package/android/NGTCP2_BUILD_INSTRUCTIONS.md +319 -0
  3. package/android/build-nghttp3.sh +182 -0
  4. package/android/build-ngtcp2.sh +289 -0
  5. package/android/build-openssl.sh +302 -0
  6. package/android/build.gradle +75 -0
  7. package/android/gradle.properties +3 -0
  8. package/android/proguard-rules.pro +2 -0
  9. package/android/settings.gradle +1 -0
  10. package/android/src/main/AndroidManifest.xml +1 -0
  11. package/android/src/main/assets/mqttquic_ca.pem +5 -0
  12. package/android/src/main/cpp/CMakeLists.txt +157 -0
  13. package/android/src/main/cpp/ngtcp2_jni.cpp +928 -0
  14. package/android/src/main/kotlin/ai/annadata/mqttquic/MqttQuicPlugin.kt +232 -0
  15. package/android/src/main/kotlin/ai/annadata/mqttquic/client/MQTTClient.kt +339 -0
  16. package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTT5Properties.kt +250 -0
  17. package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTT5Protocol.kt +281 -0
  18. package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTT5ReasonCodes.kt +109 -0
  19. package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTTProtocol.kt +249 -0
  20. package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTTTypes.kt +47 -0
  21. package/android/src/main/kotlin/ai/annadata/mqttquic/quic/NGTCP2Client.kt +184 -0
  22. package/android/src/main/kotlin/ai/annadata/mqttquic/quic/QuicClientStub.kt +54 -0
  23. package/android/src/main/kotlin/ai/annadata/mqttquic/quic/QuicTypes.kt +21 -0
  24. package/android/src/main/kotlin/ai/annadata/mqttquic/transport/QUICStreamAdapter.kt +37 -0
  25. package/android/src/main/kotlin/ai/annadata/mqttquic/transport/StreamTransport.kt +91 -0
  26. package/android/src/test/kotlin/ai/annadata/mqttquic/mqtt/MQTTProtocolTest.kt +92 -0
  27. package/dist/esm/definitions.d.ts +66 -0
  28. package/dist/esm/definitions.d.ts.map +1 -0
  29. package/dist/esm/definitions.js +2 -0
  30. package/dist/esm/definitions.js.map +1 -0
  31. package/dist/esm/index.d.ts +5 -0
  32. package/dist/esm/index.d.ts.map +1 -0
  33. package/dist/esm/index.js +7 -0
  34. package/dist/esm/index.js.map +1 -0
  35. package/dist/esm/web.d.ts +28 -0
  36. package/dist/esm/web.d.ts.map +1 -0
  37. package/dist/esm/web.js +183 -0
  38. package/dist/esm/web.js.map +1 -0
  39. package/dist/plugin.cjs.js +217 -0
  40. package/dist/plugin.cjs.js.map +1 -0
  41. package/dist/plugin.js +215 -0
  42. package/dist/plugin.js.map +1 -0
  43. package/ios/MqttQuicPlugin.podspec +34 -0
  44. package/ios/NGTCP2_BUILD_INSTRUCTIONS.md +302 -0
  45. package/ios/Sources/MqttQuicPlugin/Client/MQTTClient.swift +343 -0
  46. package/ios/Sources/MqttQuicPlugin/MQTT/MQTT5Properties.swift +280 -0
  47. package/ios/Sources/MqttQuicPlugin/MQTT/MQTT5Protocol.swift +333 -0
  48. package/ios/Sources/MqttQuicPlugin/MQTT/MQTT5ReasonCodes.swift +113 -0
  49. package/ios/Sources/MqttQuicPlugin/MQTT/MQTTProtocol.swift +322 -0
  50. package/ios/Sources/MqttQuicPlugin/MQTT/MQTTTypes.swift +54 -0
  51. package/ios/Sources/MqttQuicPlugin/MqttQuicPlugin.swift +229 -0
  52. package/ios/Sources/MqttQuicPlugin/QUIC/NGTCP2Bridge.h +29 -0
  53. package/ios/Sources/MqttQuicPlugin/QUIC/NGTCP2Bridge.mm +865 -0
  54. package/ios/Sources/MqttQuicPlugin/QUIC/NGTCP2Client.swift +262 -0
  55. package/ios/Sources/MqttQuicPlugin/QUIC/QuicClientStub.swift +66 -0
  56. package/ios/Sources/MqttQuicPlugin/QUIC/QuicTypes.swift +23 -0
  57. package/ios/Sources/MqttQuicPlugin/Resources/mqttquic_ca.pem +5 -0
  58. package/ios/Sources/MqttQuicPlugin/Transport/QUICStreamAdapter.swift +50 -0
  59. package/ios/Sources/MqttQuicPlugin/Transport/StreamTransport.swift +105 -0
  60. package/ios/Tests/MQTTProtocolTests.swift +82 -0
  61. package/ios/build-nghttp3.sh +173 -0
  62. package/ios/build-ngtcp2.sh +278 -0
  63. package/ios/build-openssl.sh +405 -0
  64. package/package.json +63 -0
@@ -0,0 +1,34 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'MqttQuicPlugin'
7
+ s.version = package['version']
8
+ s.summary = 'MQTT-over-QUIC Capacitor plugin (iOS)'
9
+ s.license = package['license']
10
+ s.homepage = 'https://github.com/annadata/capacitor-mqtt-quic'
11
+ s.author = 'Annadata'
12
+ s.source = { :git => 'https://github.com/annadata/capacitor-mqtt-quic', :tag => s.version.to_s }
13
+ s.source_files = 'Sources/**/*.{swift,h,m,c,cc,mm,cpp}'
14
+ s.resources = ['Sources/MqttQuicPlugin/Resources/*.pem']
15
+ s.ios.deployment_target = '15.0'
16
+ s.dependency 'Capacitor'
17
+ s.swift_version = '5.1'
18
+ s.vendored_libraries = [
19
+ 'libs/libngtcp2.a',
20
+ 'libs/libngtcp2_crypto_quictls.a',
21
+ 'libs/libnghttp3.a',
22
+ 'libs/libssl.a',
23
+ 'libs/libcrypto.a'
24
+ ]
25
+ s.public_header_files = 'Sources/**/*.h', 'include/**/*.h'
26
+ s.private_header_files = 'Sources/**/*.h', 'include/**/*.h'
27
+ s.header_mappings_dir = 'Sources'
28
+ s.pod_target_xcconfig = {
29
+ 'HEADER_SEARCH_PATHS' => '$(PODS_ROOT)/MqttQuicPlugin/include/ngtcp2 $(PODS_ROOT)/MqttQuicPlugin/include/nghttp3 $(PODS_ROOT)/MqttQuicPlugin/include/openssl',
30
+ 'LIBRARY_SEARCH_PATHS' => '$(PODS_ROOT)/MqttQuicPlugin/libs',
31
+ 'OTHER_LDFLAGS' => '-lngtcp2 -lngtcp2_crypto_quictls -lnghttp3 -lssl -lcrypto',
32
+ 'SWIFT_ACTIVE_COMPILATION_CONDITIONS' => 'NGTCP2_ENABLED NGHTTP3_ENABLED'
33
+ }
34
+ end
@@ -0,0 +1,302 @@
1
+ # Building ngtcp2 for iOS
2
+
3
+ This document provides instructions for building ngtcp2 and OpenSSL for iOS to enable real QUIC transport in the MQTT client.
4
+
5
+ ## Prerequisites
6
+
7
+ - **Xcode 14+** (for iOS 15+)
8
+ - **CMake 3.20+**
9
+ - **iOS SDK 15.0+**
10
+ - **Git** (for cloning repositories)
11
+
12
+ **Source layout:** set `PROJECT_DIR` to the `capacitor-mqtt-quic` repo root.
13
+ Build scripts then expect dependencies under:
14
+ - `$PROJECT_DIR/ref-code/ngtcp2`
15
+ - `$PROJECT_DIR/ref-code/nghttp3`
16
+ - `$PROJECT_DIR/ref-code/openssl` (or `$PROJECT_DIR/ref-code.openssl`)
17
+
18
+ ```bash
19
+ export PROJECT_DIR="/Users/annadata/Project_A/annadata-production/ref-code/capacitor-mqtt-quic"
20
+ ```
21
+
22
+ Override with `NGTCP2_SOURCE_DIR`, `NGHTTP3_SOURCE_DIR`, `OPENSSL_SOURCE_DIR`
23
+ if you store sources elsewhere.
24
+
25
+ ## Quick Start
26
+
27
+ ### Option 1: Use Pre-built Libraries (Recommended for Development)
28
+
29
+ If you have access to pre-built ngtcp2, nghttp3, and OpenSSL libraries:
30
+
31
+ 1. Place `libngtcp2.a` in `ios/libs/`
32
+ 2. Place `libnghttp3.a` in `ios/libs/`
33
+ 3. Place OpenSSL libraries (`libssl.a`, `libcrypto.a`) in `ios/libs/`
34
+ 4. Place headers in `ios/include/ngtcp2/`, `ios/include/nghttp3/`, and `ios/include/openssl/`
35
+ 5. Update `MqttQuicPlugin.podspec` to link against these libraries
36
+
37
+ ### Option 2: Build from Source
38
+
39
+ #### Step 1: Build OpenSSL for iOS (QUIC TLS)
40
+
41
+ ```bash
42
+ cd ios
43
+ ./build-openssl.sh --arch arm64 --sdk iphoneos --quictls
44
+ ```
45
+
46
+ This will:
47
+ - Clone quictls (OpenSSL fork with QUIC API) if not present
48
+ - Build static libraries for iOS
49
+ - Install to `ios/install/openssl-ios/`
50
+ - Sync `libssl.a`, `libcrypto.a` to `ios/libs/` and headers to `ios/include/openssl/`
51
+
52
+ **Note:** For simulator builds, use:
53
+ ```bash
54
+ ./build-openssl.sh --arch x86_64 --sdk iphonesimulator
55
+ ```
56
+
57
+ #### Step 2: Build nghttp3 for iOS
58
+
59
+ ```bash
60
+ cd ios
61
+ ./build-nghttp3.sh \
62
+ --arch arm64 \
63
+ --sdk iphoneos
64
+ ```
65
+
66
+ This will:
67
+ - Build nghttp3 static library
68
+ - Install to `ios/build/nghttp3-ios-arm64/install/`
69
+ - Sync `libnghttp3.a` to `ios/libs/` and headers to `ios/include/nghttp3/`
70
+
71
+ #### Step 3: Build ngtcp2 for iOS
72
+
73
+ ```bash
74
+ cd ios
75
+ ./build-ngtcp2.sh \
76
+ --openssl-path ./install/openssl-ios \
77
+ --arch arm64 \
78
+ --sdk iphoneos \
79
+ --quictls
80
+ ```
81
+
82
+ This will:
83
+ - Build ngtcp2 static library
84
+ - Link against OpenSSL (quictls)
85
+ - Install to `ios/build/ios-arm64/install/`
86
+ - Sync `libngtcp2.a` and `libngtcp2_crypto_quictls.a` to `ios/libs/` and headers to `ios/include/ngtcp2/`
87
+
88
+ #### Step 4: Build for Multiple Architectures
89
+
90
+ For a universal library (arm64 + x86_64 for simulator):
91
+
92
+ ```bash
93
+ # Build for device (arm64)
94
+ ./build-openssl.sh --arch arm64 --sdk iphoneos --quictls
95
+ ./build-nghttp3.sh --arch arm64 --sdk iphoneos
96
+ ./build-ngtcp2.sh --openssl-path ./install/openssl-ios --arch arm64 --sdk iphoneos --quictls
97
+
98
+ # Build for simulator (x86_64)
99
+ ./build-openssl.sh --arch x86_64 --sdk iphonesimulator --quictls
100
+ ./build-nghttp3.sh --arch x86_64 --sdk iphonesimulator
101
+ ./build-ngtcp2.sh --openssl-path ./install/openssl-ios --arch x86_64 --sdk iphonesimulator --quictls
102
+
103
+ # Create universal library
104
+ lipo -create \
105
+ build/ios-arm64/install/lib/libngtcp2.a \
106
+ build/ios-x86_64/install/lib/libngtcp2.a \
107
+ -output libs/libngtcp2-universal.a
108
+
109
+ # Create universal nghttp3 library
110
+ lipo -create \
111
+ build/nghttp3-ios-arm64/install/lib/libnghttp3.a \
112
+ build/nghttp3-ios-x86_64/install/lib/libnghttp3.a \
113
+ -output libs/libnghttp3-universal.a
114
+
115
+ # Create universal ngtcp2_crypto_quictls library
116
+ lipo -create \
117
+ build/ios-arm64/install/lib/libngtcp2_crypto_quictls.a \
118
+ build/ios-x86_64/install/lib/libngtcp2_crypto_quictls.a \
119
+ -output libs/libngtcp2_crypto_quictls-universal.a
120
+ ```
121
+
122
+ ## Integration into Xcode Project
123
+
124
+ ### Option A: CocoaPods (Recommended)
125
+
126
+ Update `ios/MqttQuicPlugin.podspec`:
127
+
128
+ ```ruby
129
+ Pod::Spec.new do |s|
130
+ # ... existing configuration ...
131
+
132
+ # ngtcp2 static library
133
+ s.vendored_libraries = 'libs/libngtcp2.a', 'libs/libngtcp2_crypto_quictls.a', 'libs/libnghttp3.a', 'libs/libssl.a', 'libs/libcrypto.a'
134
+
135
+ # Header search paths
136
+ s.public_header_files = 'Sources/**/*.h'
137
+ s.private_header_files = 'Sources/**/*.h'
138
+ s.header_mappings_dir = 'Sources'
139
+
140
+ # Include ngtcp2/nghttp3 headers
141
+ s.xcconfig = {
142
+ 'HEADER_SEARCH_PATHS' => '$(PODS_ROOT)/MqttQuicPlugin/include/ngtcp2 $(PODS_ROOT)/MqttQuicPlugin/include/nghttp3 $(PODS_ROOT)/MqttQuicPlugin/include/openssl',
143
+ 'LIBRARY_SEARCH_PATHS' => '$(PODS_ROOT)/MqttQuicPlugin/libs',
144
+ 'OTHER_LDFLAGS' => '-lngtcp2 -lngtcp2_crypto_quictls -lnghttp3 -lssl -lcrypto'
145
+ }
146
+
147
+ # OpenSSL dependency (if using CocoaPods)
148
+ s.dependency 'OpenSSL-Universal', '~> 3.0'
149
+ end
150
+ ```
151
+
152
+ ### Option B: Manual Integration
153
+
154
+ 1. Add `libngtcp2.a` and `libnghttp3.a` to Xcode project
155
+ 2. Add header search paths:
156
+ - `$(SRCROOT)/../ios/include/ngtcp2`
157
+ - `$(SRCROOT)/../ios/include/nghttp3`
158
+ - `$(SRCROOT)/../ios/include/openssl`
159
+ 3. Link against:
160
+ - `libngtcp2.a`
161
+ - `libnghttp3.a`
162
+ - `libssl.a` (OpenSSL)
163
+ - `libcrypto.a` (OpenSSL)
164
+
165
+ ## TLS Certificate Verification (QUIC)
166
+
167
+ QUIC requires TLS 1.3 and certificate verification is **enabled by default**.
168
+ You can bundle a CA PEM and it will be loaded automatically:
169
+
170
+ - `ios/Sources/MqttQuicPlugin/Resources/mqttquic_ca.pem`
171
+
172
+ You can also override per call:
173
+
174
+ ```ts
175
+ await MqttQuic.connect({
176
+ host: 'mqtt.example.com',
177
+ port: 1884,
178
+ clientId: 'my-client-id',
179
+ caFile: '/path/to/ca-bundle.pem',
180
+ // or caPath: '/path/to/ca-directory'
181
+ });
182
+ ```
183
+
184
+ ### How to generate certificates
185
+
186
+ **Option A: Public CA (Let’s Encrypt)**
187
+ You do not bundle `mqttquic_ca.pem` (the OS already trusts public CAs).
188
+
189
+ ```bash
190
+ sudo apt-get update
191
+ sudo apt-get install -y certbot
192
+ sudo certbot certonly --standalone -d mqtt.example.com
193
+ ```
194
+
195
+ Use on server:
196
+ - Cert: `/etc/letsencrypt/live/mqtt.example.com/fullchain.pem`
197
+ - Key: `/etc/letsencrypt/live/mqtt.example.com/privkey.pem`
198
+
199
+ **Option B: Private CA (dev/internal)**
200
+ Generate your own CA, sign the server cert, and bundle the CA PEM.
201
+
202
+ ```bash
203
+ mkdir -p certs && cd certs
204
+
205
+ # 1) Create CA (one-time)
206
+ openssl genrsa -out ca.key 4096
207
+ openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 -out ca.pem \
208
+ -subj "/C=US/ST=CA/L=SF/O=Annadata/OU=MQTT/CN=Annadata-Root-CA"
209
+
210
+ # 2) Create server key + CSR
211
+ openssl genrsa -out server.key 2048
212
+ openssl req -new -key server.key -out server.csr \
213
+ -subj "/C=US/ST=CA/L=SF/O=Annadata/OU=MQTT/CN=mqtt.example.com"
214
+
215
+ # 3) Add SANs (edit DNS/IP)
216
+ cat > server_ext.cnf <<EOF
217
+ subjectAltName = DNS:mqtt.example.com,IP:YOUR.SERVER.IP
218
+ extendedKeyUsage = serverAuth
219
+ EOF
220
+
221
+ # 4) Sign server cert
222
+ openssl x509 -req -in server.csr -CA ca.pem -CAkey ca.key -CAcreateserial \
223
+ -out server.pem -days 365 -sha256 -extfile server_ext.cnf
224
+ ```
225
+
226
+ Bundle the CA cert (never ship `ca.key`):
227
+ - iOS: `ios/Sources/MqttQuicPlugin/Resources/mqttquic_ca.pem` (use `ca.pem`)
228
+
229
+ ## Test Harness (QUIC Smoke Test)
230
+
231
+ This runs: connect → subscribe → publish → disconnect.
232
+
233
+ ```ts
234
+ await MqttQuic.testHarness({
235
+ host: 'mqtt.example.com',
236
+ port: 1884,
237
+ clientId: 'mqttquic_test_client',
238
+ topic: 'test/topic',
239
+ payload: 'Hello QUIC!',
240
+ // optional CA override
241
+ caFile: '/path/to/ca-bundle.pem'
242
+ });
243
+ ```
244
+
245
+ ## Using Pre-built Libraries
246
+
247
+ If you prefer to use pre-built libraries:
248
+
249
+ ### OpenSSL
250
+
251
+ Download from:
252
+ - https://github.com/x2on/OpenSSL-for-iPhone
253
+ - https://github.com/krzyzanowskim/OpenSSL
254
+
255
+ ### ngtcp2
256
+
257
+ Currently, there are no widely available pre-built ngtcp2 libraries for iOS. You'll need to build from source.
258
+
259
+ ### nghttp3
260
+
261
+ Currently, there are no widely available pre-built nghttp3 libraries for iOS. You'll need to build from source. Make sure you clone with submodules:
262
+
263
+ ```bash
264
+ git clone --recurse-submodules https://github.com/ngtcp2/nghttp3.git
265
+ ```
266
+
267
+ ## Troubleshooting
268
+
269
+ ### CMake Not Found
270
+
271
+ ```bash
272
+ # Install via Homebrew
273
+ brew install cmake
274
+ ```
275
+
276
+ ### OpenSSL Build Fails
277
+
278
+ - Ensure you're using OpenSSL 3.0+ (required for TLS 1.3)
279
+ - Check that Xcode command line tools are installed: `xcode-select --install`
280
+ - Verify SDK path: `xcrun --sdk iphoneos --show-sdk-path`
281
+
282
+ ### ngtcp2 Build Fails
283
+
284
+ - Verify OpenSSL is built and path is correct
285
+ - Check CMake version: `cmake --version` (must be 3.20+)
286
+ - Ensure iOS deployment target matches (15.0+)
287
+
288
+ ### Link Errors
289
+
290
+ - Verify all libraries are built for the same architecture
291
+ - Check that header search paths are correct
292
+ - Ensure OpenSSL and ngtcp2 are linked in the correct order
293
+
294
+ ## Next Steps
295
+
296
+ After building ngtcp2:
297
+
298
+ 1. Update `NGTCP2Client.swift` to implement the TODO sections
299
+ 2. Replace `QuicClientStub` with `NGTCP2Client` in `MQTTClient.swift`
300
+ 3. Test connection to MQTT server over QUIC
301
+
302
+ See `NGTCP2_INTEGRATION_PLAN.md` for detailed implementation guide.
@@ -0,0 +1,343 @@
1
+ //
2
+ // MQTTClient.swift
3
+ // MqttQuicPlugin
4
+ //
5
+ // High-level MQTT client: connect, publish, subscribe, disconnect.
6
+ // Uses QuicClient + stream adapters + MQTT protocol.
7
+ //
8
+
9
+ import Foundation
10
+
11
+ public final class MQTTClient {
12
+
13
+ public enum State {
14
+ case disconnected
15
+ case connecting
16
+ case connected
17
+ case error(String)
18
+ }
19
+
20
+ public enum ProtocolVersion {
21
+ case v311
22
+ case v5
23
+ case auto // Try 5.0 first, fallback to 3.1.1
24
+ }
25
+
26
+ private var state: State = .disconnected
27
+ private var protocolVersion: ProtocolVersion = .auto
28
+ private var activeProtocolVersion: UInt8 = 0 // 0x04 or 0x05
29
+ private var quicClient: QuicClientProtocol?
30
+ private var stream: QuicStreamProtocol?
31
+ private var reader: MQTTStreamReaderProtocol?
32
+ private var writer: MQTTStreamWriterProtocol?
33
+ private var messageLoopTask: Task<Void, Error>?
34
+ private var nextPacketId: UInt16 = 1
35
+ private var subscribedTopics: [String: (Data) -> Void] = [:]
36
+ private let lock = NSLock()
37
+
38
+ public init(protocolVersion: ProtocolVersion = .auto) {
39
+ self.protocolVersion = protocolVersion
40
+ }
41
+
42
+ public func getState() -> State {
43
+ lock.lock()
44
+ defer { lock.unlock() }
45
+ return state
46
+ }
47
+
48
+ public func connect(host: String, port: UInt16, clientId: String, username: String?, password: String?, cleanSession: Bool, keepalive: UInt16, sessionExpiryInterval: UInt32? = nil) async throws {
49
+ lock.lock()
50
+ if case .connecting = state {
51
+ lock.unlock()
52
+ throw MQTTProtocolError.insufficientData("already connecting")
53
+ }
54
+ state = .connecting
55
+ lock.unlock()
56
+
57
+ do {
58
+ // Determine protocol version
59
+ let useV5 = protocolVersion == .v5 || (protocolVersion == .auto)
60
+
61
+ let quic: QuicClientProtocol
62
+ #if NGTCP2_ENABLED
63
+ quic = NGTCP2Client()
64
+ #else
65
+ // Build CONNACK stub (used when ngtcp2 is not linked)
66
+ let connack: Data
67
+ if useV5 {
68
+ connack = try MQTT5Protocol.buildConnackV5(reasonCode: .success, sessionPresent: false)
69
+ } else {
70
+ connack = MQTTProtocol.buildConnack(returnCode: MQTTConnAckCode.accepted.rawValue)
71
+ }
72
+ quic = QuicClientStub(initialReadData: connack)
73
+ #endif
74
+ try await quic.connect(host: host, port: port)
75
+ let s = try await quic.openStream()
76
+ let r = QUICStreamReader(stream: s)
77
+ let w = QUICStreamWriter(stream: s)
78
+
79
+ lock.lock()
80
+ quicClient = quic
81
+ stream = s
82
+ reader = r
83
+ writer = w
84
+ lock.unlock()
85
+
86
+ // Build CONNECT
87
+ let connectData: Data
88
+ if useV5 {
89
+ connectData = try MQTT5Protocol.buildConnectV5(
90
+ clientId: clientId,
91
+ username: username,
92
+ password: password,
93
+ keepalive: keepalive,
94
+ cleanStart: cleanSession,
95
+ sessionExpiryInterval: sessionExpiryInterval
96
+ )
97
+ activeProtocolVersion = MQTTProtocolLevel.v5
98
+ } else {
99
+ connectData = try MQTTProtocol.buildConnect(
100
+ clientId: clientId,
101
+ username: username,
102
+ password: password,
103
+ keepalive: keepalive,
104
+ cleanSession: cleanSession
105
+ )
106
+ activeProtocolVersion = MQTTProtocolLevel.v311
107
+ }
108
+
109
+ try await w.write(connectData)
110
+ try await w.drain()
111
+
112
+ // Read CONNACK
113
+ let fixed = try await r.readexactly(2)
114
+ let (msgType, remLen, hdrLen) = try MQTTProtocol.parseFixedHeader(Data(fixed))
115
+ let rest = try await r.readexactly(remLen)
116
+ var full = Data(fixed)
117
+ full.append(rest)
118
+
119
+ if msgType != MQTTMessageType.CONNACK.rawValue {
120
+ lock.lock()
121
+ state = .error("expected CONNACK, got \(msgType)")
122
+ lock.unlock()
123
+ throw MQTTProtocolError.insufficientData("expected CONNACK")
124
+ }
125
+
126
+ // Parse CONNACK based on protocol version
127
+ if activeProtocolVersion == MQTTProtocolLevel.v5 {
128
+ let (_, reasonCode, _, _) = try MQTT5Protocol.parseConnackV5(full, offset: hdrLen)
129
+ if reasonCode != .success {
130
+ lock.lock()
131
+ state = .error("CONNACK refused: \(reasonCode)")
132
+ lock.unlock()
133
+ throw MQTTProtocolError.insufficientData("CONNACK refused: \(reasonCode)")
134
+ }
135
+ } else {
136
+ let (_, returnCode) = try MQTTProtocol.parseConnack(full, offset: hdrLen)
137
+ if returnCode != MQTTConnAckCode.accepted.rawValue {
138
+ lock.lock()
139
+ state = .error("CONNACK refused: \(returnCode)")
140
+ lock.unlock()
141
+ throw MQTTProtocolError.insufficientData("CONNACK refused")
142
+ }
143
+ }
144
+
145
+ lock.lock()
146
+ state = .connected
147
+ lock.unlock()
148
+
149
+ startMessageLoop()
150
+ } catch {
151
+ lock.lock()
152
+ let w = writer
153
+ quicClient = nil
154
+ stream = nil
155
+ reader = nil
156
+ writer = nil
157
+ state = .error("\(error)")
158
+ lock.unlock()
159
+ try? await w?.close()
160
+ throw error
161
+ }
162
+ }
163
+
164
+ public func publish(topic: String, payload: Data, qos: UInt8, properties: [UInt8: Any]? = nil) async throws {
165
+ guard case .connected = getState() else { throw MQTTProtocolError.insufficientData("not connected") }
166
+ lock.lock()
167
+ let w = writer
168
+ let version = activeProtocolVersion
169
+ lock.unlock()
170
+ guard let w = w else { throw MQTTProtocolError.insufficientData("no writer") }
171
+
172
+ let pid: UInt16? = qos > 0 ? nextPacketIdUsed() : nil
173
+ let data: Data
174
+ if version == MQTTProtocolLevel.v5 {
175
+ data = try MQTT5Protocol.buildPublishV5(topic: topic, payload: payload, packetId: pid, qos: qos, retain: false, properties: properties)
176
+ } else {
177
+ data = try MQTTProtocol.buildPublish(topic: topic, payload: payload, packetId: pid, qos: qos, retain: false)
178
+ }
179
+ try await w.write(data)
180
+ try await w.drain()
181
+ }
182
+
183
+ public func subscribe(topic: String, qos: UInt8, subscriptionIdentifier: Int? = nil) async throws {
184
+ guard case .connected = getState() else { throw MQTTProtocolError.insufficientData("not connected") }
185
+ lock.lock()
186
+ let r = reader, w = writer
187
+ let version = activeProtocolVersion
188
+ lock.unlock()
189
+ guard let r = r, let w = w else { throw MQTTProtocolError.insufficientData("no reader/writer") }
190
+
191
+ let pid = nextPacketIdUsed()
192
+ let data: Data
193
+ if version == MQTTProtocolLevel.v5 {
194
+ data = try MQTT5Protocol.buildSubscribeV5(packetId: pid, topic: topic, qos: qos, subscriptionIdentifier: subscriptionIdentifier)
195
+ } else {
196
+ data = try MQTTProtocol.buildSubscribe(packetId: pid, topic: topic, qos: qos)
197
+ }
198
+ try await w.write(data)
199
+ try await w.drain()
200
+
201
+ let fixed = try await r.readexactly(2)
202
+ let (_, remLen, hdrLen) = try MQTTProtocol.parseFixedHeader(Data(fixed))
203
+ let rest = try await r.readexactly(remLen)
204
+ var full = Data(fixed)
205
+ full.append(rest)
206
+
207
+ if version == MQTTProtocolLevel.v5 {
208
+ let (_, reasonCodes, _, _) = try MQTT5Protocol.parseSubackV5(full, offset: hdrLen)
209
+ if let firstRC = reasonCodes.first, firstRC != .grantedQoS0 && firstRC != .grantedQoS1 && firstRC != .grantedQoS2 {
210
+ throw MQTTProtocolError.insufficientData("SUBACK error \(firstRC)")
211
+ }
212
+ } else {
213
+ let (_, rc, _) = try MQTTProtocol.parseSuback(full, offset: hdrLen)
214
+ if rc > 0x02 { throw MQTTProtocolError.insufficientData("SUBACK error \(rc)") }
215
+ }
216
+ }
217
+
218
+ public func unsubscribe(topic: String) async throws {
219
+ guard case .connected = getState() else { throw MQTTProtocolError.insufficientData("not connected") }
220
+ lock.lock()
221
+ let r = reader, w = writer
222
+ let version = activeProtocolVersion
223
+ subscribedTopics.removeValue(forKey: topic)
224
+ lock.unlock()
225
+ guard let r = r, let w = w else { throw MQTTProtocolError.insufficientData("no reader/writer") }
226
+
227
+ let pid = nextPacketIdUsed()
228
+ let data: Data
229
+ if version == MQTTProtocolLevel.v5 {
230
+ data = try MQTT5Protocol.buildUnsubscribeV5(packetId: pid, topics: [topic])
231
+ } else {
232
+ data = try MQTTProtocol.buildUnsubscribe(packetId: pid, topics: [topic])
233
+ }
234
+ try await w.write(data)
235
+ try await w.drain()
236
+
237
+ let fixed = try await r.readexactly(2)
238
+ let (_, remLen, _) = try MQTTProtocol.parseFixedHeader(Data(fixed))
239
+ _ = try await r.readexactly(remLen)
240
+ }
241
+
242
+ public func disconnect() async throws {
243
+ let task = messageLoopTask
244
+ messageLoopTask = nil
245
+ task?.cancel()
246
+ _ = try? await task?.value
247
+
248
+ lock.lock()
249
+ let w = writer
250
+ let version = activeProtocolVersion
251
+ quicClient = nil
252
+ stream = nil
253
+ reader = nil
254
+ writer = nil
255
+ state = .disconnected
256
+ activeProtocolVersion = 0
257
+ lock.unlock()
258
+
259
+ if let w = w {
260
+ let data: Data
261
+ if version == MQTTProtocolLevel.v5 {
262
+ data = try MQTT5Protocol.buildDisconnectV5(reasonCode: .normalDisconnectionDisc)
263
+ } else {
264
+ data = MQTTProtocol.buildDisconnect()
265
+ }
266
+ try? await w.write(data)
267
+ try? await w.drain()
268
+ try? await w.close()
269
+ }
270
+ }
271
+
272
+ public func onMessage(_ topic: String, _ callback: @escaping (Data) -> Void) {
273
+ lock.lock()
274
+ subscribedTopics[topic] = callback
275
+ lock.unlock()
276
+ }
277
+
278
+ private func nextPacketIdUsed() -> UInt16 {
279
+ lock.lock()
280
+ defer { lock.unlock() }
281
+ let pid = nextPacketId
282
+ nextPacketId = nextPacketId &+ 1
283
+ if nextPacketId == 0 { nextPacketId = 1 }
284
+ return pid
285
+ }
286
+
287
+ private func startMessageLoop() {
288
+ messageLoopTask = Task { [weak self] in
289
+ guard let self = self else { return }
290
+ while !Task.isCancelled {
291
+ let r: MQTTStreamReaderProtocol?
292
+ self.lock.lock()
293
+ r = self.reader
294
+ self.lock.unlock()
295
+ guard let r = r else { break }
296
+
297
+ do {
298
+ let fixed = try await r.readexactly(2)
299
+ let (msgType, remLen, _) = try MQTTProtocol.parseFixedHeader(Data(fixed))
300
+ let rest = try await r.readexactly(remLen)
301
+ let type = msgType & 0xF0
302
+
303
+ self.lock.lock()
304
+ let version = self.activeProtocolVersion
305
+ let w = self.writer
306
+ self.lock.unlock()
307
+
308
+ switch type {
309
+ case MQTTMessageType.PINGREQ.rawValue:
310
+ if let w = w {
311
+ let pr = MQTTProtocol.buildPingresp()
312
+ try await w.write(Data(pr))
313
+ try await w.drain()
314
+ }
315
+ case MQTTMessageType.PUBLISH.rawValue:
316
+ let qos = (msgType >> 1) & 0x03
317
+ let (topic, packetId, payload, _) = try MQTTProtocol.parsePublish(Data(rest), offset: 0, qos: qos)
318
+
319
+ self.lock.lock()
320
+ let cb = self.subscribedTopics[topic]
321
+ self.lock.unlock()
322
+ cb?(payload)
323
+
324
+ if qos >= 1, let pid = packetId {
325
+ self.lock.lock()
326
+ let wPuback = self.writer
327
+ self.lock.unlock()
328
+ if let wPuback = wPuback {
329
+ let puback = MQTTProtocol.buildPuback(packetId: pid)
330
+ try await wPuback.write(Data(puback))
331
+ try await wPuback.drain()
332
+ }
333
+ }
334
+ default:
335
+ break
336
+ }
337
+ } catch {
338
+ if !Task.isCancelled { break }
339
+ }
340
+ }
341
+ }
342
+ }
343
+ }