@annadata/capacitor-mqtt-quic 0.2.0 → 0.2.2

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.
@@ -233,6 +233,20 @@ class QuicClient {
233
233
  state.recv_buf.insert(state.recv_buf.end(), data, data + datalen);
234
234
  LOGI("recv stream data stream_id=%" PRId64 " len=%zu recv_buf_total=%zu",
235
235
  (int64_t)stream_id, datalen, state.recv_buf.size());
236
+ if (datalen > 2 && datalen <= 32) {
237
+ char hex[128];
238
+ size_t n = datalen < 16 ? datalen : 16u;
239
+ char *p = hex;
240
+ for (size_t i = 0; i < n && p < hex + sizeof(hex) - 4; i++)
241
+ p += snprintf(p, (size_t)(hex + sizeof(hex) - p), "%02x", data[i]);
242
+ LOGI("recv first bytes (type 0x%02x = %s) hex=%s",
243
+ (unsigned)data[0],
244
+ (data[0] & 0xF0) == 0x30 ? "PUBLISH" : (data[0] == (uint8_t)0xd0 ? "PINGRESP" : "other"),
245
+ hex);
246
+ } else if (datalen > 32) {
247
+ LOGI("recv large chunk len=%zu first_byte=0x%02x (PUBLISH=0x30)",
248
+ datalen, (unsigned)data[0]);
249
+ }
236
250
  if (flags & NGTCP2_STREAM_DATA_FLAG_FIN) {
237
251
  state.fin_received = true;
238
252
  }
@@ -121,7 +121,10 @@ class MqttQuicPlugin : Plugin() {
121
121
  } catch (_: Exception) {
122
122
  Base64.encodeToString(payload, Base64.NO_WRAP)
123
123
  }
124
- val data = JSObject().put("topic", topic).put("payload", payloadStr)
124
+ // Ensure non-null strings so Capacitor bridge never receives undefined
125
+ val safeTopic = topic ?: ""
126
+ val safePayload = payloadStr ?: ""
127
+ val data = JSObject().put("topic", safeTopic).put("payload", safePayload)
125
128
  Handler(Looper.getMainLooper()).post {
126
129
  notifyListeners("message", data)
127
130
  }
@@ -613,30 +613,35 @@ class MQTTClient {
613
613
  }
614
614
  MQTTMessageType.PUBLISH -> {
615
615
  val qos = (msgType.toInt() shr 1) and 0x03
616
- val (topic, packetId, payload) = lock.withLock {
617
- if (activeProtocolVersion == MQTTProtocolLevel.V5) {
618
- MQTT5Protocol.parsePublishV5(rest, 0, qos, topicAliasMap)
619
- } else {
620
- MQTTProtocol.parsePublish(rest, 0, qos)
621
- }
622
- }
623
-
624
- val (cb, globalCb) = lock.withLock {
625
- subscribedTopics[topic] to onPublish
626
- }
627
- globalCb?.invoke(topic, payload)
628
- cb?.invoke(payload)
629
-
630
- if (qos >= 1 && packetId != null) {
631
- val w = lock.withLock { writer }
632
- w?.let {
633
- if (qos == 1) {
634
- it.write(MQTTProtocol.buildPuback(packetId))
616
+ try {
617
+ val (topic, packetId, payload) = lock.withLock {
618
+ if (activeProtocolVersion == MQTTProtocolLevel.V5) {
619
+ MQTT5Protocol.parsePublishV5(rest, 0, qos, topicAliasMap)
635
620
  } else {
636
- it.write(MQTTProtocol.buildPubrec(packetId))
621
+ MQTTProtocol.parsePublish(rest, 0, qos)
622
+ }
623
+ }
624
+ val (cb, globalCb) = lock.withLock {
625
+ subscribedTopics[topic] to onPublish
626
+ }
627
+ globalCb?.invoke(topic, payload)
628
+ cb?.invoke(payload)
629
+
630
+ if (qos >= 1 && packetId != null) {
631
+ val w = lock.withLock { writer }
632
+ w?.let {
633
+ if (qos == 1) {
634
+ it.write(MQTTProtocol.buildPuback(packetId))
635
+ } else {
636
+ it.write(MQTTProtocol.buildPubrec(packetId))
637
+ }
638
+ it.drain()
637
639
  }
638
- it.drain()
639
640
  }
641
+ } catch (e: Exception) {
642
+ // PUBLISH parse failed: log and skip this message (don't disconnect)
643
+ val hex = rest.take(64).joinToString("") { "%02x".format(it) }
644
+ Log.w("MQTTClient", "PUBLISH parse failed: ${e.message} restLen=${rest.size} hex=$hex", e)
640
645
  }
641
646
  }
642
647
  MQTTMessageType.PUBREL -> {
@@ -46,6 +46,8 @@ public final class MQTTClient {
46
46
  private var subackContinuation: CheckedContinuation<Data, Error>?
47
47
  /// Handoff from message loop to unsubscribe() for UNSUBACK.
48
48
  private var unsubackContinuation: CheckedContinuation<Data, Error>?
49
+ /// Pending PINGRESP for sendMqttPing(). Message loop completes when PINGRESP is read.
50
+ private var pendingPingresp: CheckedContinuation<Void, Error>?
49
51
  private let lock = NSLock()
50
52
 
51
53
  public init(protocolVersion: ProtocolVersion = .auto) {
@@ -345,6 +347,8 @@ public final class MQTTClient {
345
347
  subackContinuation = nil
346
348
  let unsubCont = unsubackContinuation
347
349
  unsubackContinuation = nil
350
+ let pingCont = pendingPingresp
351
+ pendingPingresp = nil
348
352
  let w = writer
349
353
  let version = activeProtocolVersion
350
354
  quicClient = nil
@@ -358,6 +362,7 @@ public final class MQTTClient {
358
362
  lock.unlock()
359
363
  subCont?.resume(throwing: MQTTProtocolError.insufficientData("disconnected"))
360
364
  unsubCont?.resume(throwing: MQTTProtocolError.insufficientData("disconnected"))
365
+ pingCont?.resume(throwing: MQTTProtocolError.insufficientData("disconnected"))
361
366
 
362
367
  if let w = w {
363
368
  let data: Data
@@ -403,6 +408,55 @@ public final class MQTTClient {
403
408
  return (fixed[0], rem, fixed)
404
409
  }
405
410
 
411
+ /// Send MQTT PINGREQ and wait for PINGRESP. Resets server's idle timer. Returns true if PINGRESP received within timeout.
412
+ public func sendMqttPing(timeoutMs: UInt64 = 5000) async -> Bool {
413
+ guard case .connected = getState() else { return false }
414
+ lock.lock()
415
+ if pendingPingresp != nil {
416
+ lock.unlock()
417
+ return false
418
+ }
419
+ let w = writer
420
+ lock.unlock()
421
+ do {
422
+ try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
423
+ lock.lock()
424
+ pendingPingresp = cont
425
+ lock.unlock()
426
+ Task {
427
+ try? await Task.sleep(nanoseconds: timeoutMs * 1_000_000)
428
+ self.lock.lock()
429
+ if let c = self.pendingPingresp {
430
+ self.pendingPingresp = nil
431
+ c.resume(throwing: MQTTProtocolError.insufficientData("PINGRESP timeout"))
432
+ }
433
+ self.lock.unlock()
434
+ }
435
+ if let w = w {
436
+ Task {
437
+ do {
438
+ try await w.write(MQTTProtocol.buildPingreq())
439
+ try await w.drain()
440
+ } catch {
441
+ self.lock.lock()
442
+ if let c = self.pendingPingresp {
443
+ self.pendingPingresp = nil
444
+ c.resume(throwing: error)
445
+ }
446
+ self.lock.unlock()
447
+ }
448
+ }
449
+ }
450
+ }
451
+ return true
452
+ } catch {
453
+ lock.lock()
454
+ pendingPingresp = nil
455
+ lock.unlock()
456
+ return false
457
+ }
458
+ }
459
+
406
460
  /// Send PINGREQ at effectiveKeepalive interval so server sees activity and does not close (idle/keepalive). [MQTT-3.1.2-20]
407
461
  private func startKeepaliveLoop() {
408
462
  keepaliveTask?.cancel()
@@ -493,44 +547,56 @@ public final class MQTTClient {
493
547
  try await w.write(Data(pr))
494
548
  try await w.drain()
495
549
  }
550
+ case MQTTMessageType.PINGRESP.rawValue:
551
+ self.lock.lock()
552
+ let pingCont = self.pendingPingresp
553
+ self.pendingPingresp = nil
554
+ self.lock.unlock()
555
+ pingCont?.resume()
496
556
  case MQTTMessageType.PUBLISH.rawValue:
497
557
  let qos = (msgType >> 1) & 0x03
498
- let topic: String
499
- let packetId: UInt16?
500
- let payload: Data
501
- self.lock.lock()
502
- let version = self.activeProtocolVersion
503
- if version == MQTTProtocolLevel.v5 {
504
- var map = self.topicAliasMap
505
- self.lock.unlock()
506
- (topic, packetId, payload) = try MQTT5Protocol.parsePublishV5(Data(rest), offset: 0, qos: UInt8(qos), topicAliasMap: &map)
558
+ do {
559
+ let topic: String
560
+ let packetId: UInt16?
561
+ let payload: Data
507
562
  self.lock.lock()
508
- self.topicAliasMap = map
509
- self.lock.unlock()
510
- } else {
511
- self.lock.unlock()
512
- let (t, pid, pl, _) = try MQTTProtocol.parsePublish(Data(rest), offset: 0, qos: UInt8(qos))
513
- topic = t
514
- packetId = pid
515
- payload = pl
516
- }
517
-
518
- self.lock.lock()
519
- let cb = self.subscribedTopics[topic]
520
- let globalCb = self.onPublish
521
- self.lock.unlock()
522
- globalCb?(topic, payload)
523
- cb?(payload)
563
+ let version = self.activeProtocolVersion
564
+ if version == MQTTProtocolLevel.v5 {
565
+ var map = self.topicAliasMap
566
+ self.lock.unlock()
567
+ (topic, packetId, payload) = try MQTT5Protocol.parsePublishV5(Data(rest), offset: 0, qos: UInt8(qos), topicAliasMap: &map)
568
+ self.lock.lock()
569
+ self.topicAliasMap = map
570
+ self.lock.unlock()
571
+ } else {
572
+ self.lock.unlock()
573
+ let (t, pid, pl, _) = try MQTTProtocol.parsePublish(Data(rest), offset: 0, qos: UInt8(qos))
574
+ topic = t
575
+ packetId = pid
576
+ payload = pl
577
+ }
524
578
 
525
- if qos >= 1, let pid = packetId {
526
579
  self.lock.lock()
527
- let wPuback = self.writer
580
+ let cb = self.subscribedTopics[topic]
581
+ let globalCb = self.onPublish
528
582
  self.lock.unlock()
529
- if let wPuback = wPuback {
530
- let puback = MQTTProtocol.buildPuback(packetId: pid)
531
- try await wPuback.write(Data(puback))
532
- try await wPuback.drain()
583
+ globalCb?(topic, payload)
584
+ cb?(payload)
585
+
586
+ if qos >= 1, let pid = packetId {
587
+ self.lock.lock()
588
+ let wPuback = self.writer
589
+ self.lock.unlock()
590
+ if let wPuback = wPuback {
591
+ let puback = MQTTProtocol.buildPuback(packetId: pid)
592
+ try await wPuback.write(Data(puback))
593
+ try await wPuback.drain()
594
+ }
533
595
  }
596
+ } catch {
597
+ // PUBLISH parse failed: log and skip this message (don't disconnect)
598
+ let hex = rest.prefix(64).map { String(format: "%02x", $0) }.joined()
599
+ print("[MqttQuic] PUBLISH parse failed: \(error) restLen=\(rest.count) hex=\(hex)")
534
600
  }
535
601
  default:
536
602
  break
@@ -550,6 +616,8 @@ public final class MQTTClient {
550
616
  subackContinuation = nil
551
617
  unsubackContinuation?.resume(throwing: error)
552
618
  unsubackContinuation = nil
619
+ pendingPingresp?.resume(throwing: error)
620
+ pendingPingresp = nil
553
621
  state = .disconnected
554
622
  lock.unlock()
555
623
  }
@@ -15,6 +15,7 @@ public class MqttQuicPlugin: CAPPlugin, CAPBridgedPlugin {
15
15
  public let jsName = "MqttQuic"
16
16
  public let pluginMethods: [CAPPluginMethod] = [
17
17
  CAPPluginMethod(name: "ping", returnType: CAPPluginReturnPromise),
18
+ CAPPluginMethod(name: "sendKeepalive", returnType: CAPPluginReturnPromise),
18
19
  CAPPluginMethod(name: "connect", returnType: CAPPluginReturnPromise),
19
20
  CAPPluginMethod(name: "disconnect", returnType: CAPPluginReturnPromise),
20
21
  CAPPluginMethod(name: "publish", returnType: CAPPluginReturnPromise),
@@ -27,6 +28,19 @@ public class MqttQuicPlugin: CAPPlugin, CAPBridgedPlugin {
27
28
 
28
29
  @objc override public func load() {}
29
30
 
31
+ @objc func sendKeepalive(_ call: CAPPluginCall) {
32
+ let timeoutMs = UInt64(call.getInt("timeoutMs") ?? 5000)
33
+ let clamped = min(max(timeoutMs, 1000), 15000)
34
+ Task {
35
+ do {
36
+ let ok = await client.sendMqttPing(timeoutMs: clamped)
37
+ DispatchQueue.main.async { call.resolve(["ok": ok]) }
38
+ } catch {
39
+ DispatchQueue.main.async { call.reject("\(error)") }
40
+ }
41
+ }
42
+ }
43
+
30
44
  @objc func ping(_ call: CAPPluginCall) {
31
45
  let host = call.getString("host") ?? ""
32
46
  let port = call.getInt("port") ?? 1884
@@ -120,12 +134,10 @@ public class MqttQuicPlugin: CAPPlugin, CAPBridgedPlugin {
120
134
  // Forward every incoming PUBLISH to JS so addListener('message', ...) receives topic + payload (matches Android)
121
135
  client.onPublish = { [weak self] topic, payload in
122
136
  guard let self = self else { return }
123
- let payloadStr: String
124
- if let str = String(data: payload, encoding: .utf8) {
125
- payloadStr = str
126
- } else {
127
- payloadStr = payload.base64EncodedString()
128
- }
137
+ let payloadStr: String = {
138
+ if let str = String(data: payload, encoding: .utf8) { return str }
139
+ return payload.base64EncodedString()
140
+ }()
129
141
  DispatchQueue.main.async {
130
142
  self.notifyListeners("message", data: ["topic": topic, "payload": payloadStr])
131
143
  }
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@annadata/capacitor-mqtt-quic",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "description": "MQTT-over-QUIC client for Capacitor (iOS, Android, Web). Native: ngtcp2+WolfSSL; Web: MQTT over WebSocket (WSS), same API.",
6
6
  "main": "dist/plugin.cjs.js",