ping-openmls-sdk-react-native-macos 0.2.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.
@@ -0,0 +1,656 @@
1
+ // Stage 3 entry point for the ping-openmls-sdk-react-native-macos native module.
2
+ //
3
+ // Stage 2 provided a static smoke test (`getWireContractVersion()`).
4
+ // Stage 3 adds the JS↔Rust callback bridge. The hard problem: UniFFI generates Swift
5
+ // `Storage` and `Transport` protocols that the Rust core calls into asynchronously. Our
6
+ // implementations of those protocols must reach into JS — which lives on a different
7
+ // thread, behind RN's bridge. We can't block. We can't synchronously call JS.
8
+ //
9
+ // Solution: per-call UUIDs + Swift continuations + RCTEventEmitter events.
10
+ //
11
+ // 1. Rust calls our Swift Storage.get(ns, key)
12
+ // 2. Swift wraps in `withCheckedThrowingContinuation`, stores keyed by UUID
13
+ // 3. Swift emits "PingStorageCall" event with { id, method, args } payload
14
+ // 4. JS event listener receives it, invokes the user-supplied storage object
15
+ // 5. JS calls back into native: PingNative.resolveStorageCall(id, value) (or reject)
16
+ // 6. Swift looks up the continuation by id, resumes it with the result
17
+ // 7. Rust gets its return value
18
+ //
19
+ // Stage 4 builds on this to wire the full MessagingClient surface.
20
+ //
21
+ // Spec: docs/RN_NATIVE_BINDINGS.md (stage 3).
22
+
23
+ import AppKit
24
+ import Foundation
25
+ import React
26
+
27
+ @objc(PingNative)
28
+ public final class PingNative: RCTEventEmitter {
29
+
30
+ /// Singleton-ish instance reference. RCTEventEmitter is instantiated by RN's bridge;
31
+ /// the bridge module hooks below need to find the live instance to dispatch events.
32
+ /// We keep a weak reference because RN owns the lifecycle.
33
+ private static weak var shared: PingNative?
34
+
35
+ /// Bridges live for the lifetime of the module instance. Stage 4 will tie their
36
+ /// lifecycle to MessagingClient.init/close.
37
+ private lazy var storageBridge = JSStorageBridge { [weak self] id, method, args in
38
+ self?.sendEvent(withName: "PingStorageCall", body: [
39
+ "id": id, "method": method, "args": args,
40
+ ])
41
+ }
42
+ private lazy var transportBridge = JSTransportBridge { [weak self] id, method, args in
43
+ self?.sendEvent(withName: "PingTransportCall", body: [
44
+ "id": id, "method": method, "args": args,
45
+ ])
46
+ }
47
+
48
+ /// The active MessagingClient. One per native module instance (one per app process).
49
+ /// Stage 4b populates this in `initClient`; subsequent stage 4c/4d/4e/4f methods
50
+ /// dispatch through it.
51
+ private var client: MessagingClient?
52
+
53
+ /// Observer bridge — held strongly here because the UniFFI client only stores a
54
+ /// foreign-callback handle; the actual Swift instance is ours to keep alive.
55
+ private var observerBridge: JSObserverBridge?
56
+
57
+ // MARK: - RCTEventEmitter overrides
58
+
59
+ public override init() {
60
+ super.init()
61
+ PingNative.shared = self
62
+ }
63
+
64
+ @objc public override static func requiresMainQueueSetup() -> Bool { false }
65
+
66
+ public override func supportedEvents() -> [String] {
67
+ [
68
+ "PingStorageCall", // stage 3 — Storage callbacks
69
+ "PingTransportCall", // stage 3 — Transport callbacks
70
+ "PingApplicationMessage", // stage 4e — observer: incoming application messages
71
+ "PingConversationUpdated", // stage 4e — observer: conversation epoch changes
72
+ ]
73
+ }
74
+
75
+ /// RN warns if observers aren't attached when events fire. Override these to
76
+ /// silence warnings + skip dispatch when no JS side is listening.
77
+ public override func startObserving() {}
78
+ public override func stopObserving() {}
79
+
80
+ // MARK: - Smoke test (stage 2 carryover)
81
+
82
+ @objc(getWireContractVersion:rejecter:)
83
+ public func getWireContractVersion(
84
+ _ resolve: @escaping RCTPromiseResolveBlock,
85
+ rejecter reject: @escaping RCTPromiseRejectBlock
86
+ ) {
87
+ // Stage 4 replaces with `Generated.swift`'s `WIRE_VERSION`.
88
+ resolve(1)
89
+ }
90
+
91
+ // MARK: - Storage round-trip resolution (called from JS)
92
+
93
+ /// JS calls this when its storage handler has finished. `value` is `null` when the
94
+ /// storage operation returned no data (e.g. `get` on a missing key, or `put`/`del`
95
+ /// which return void).
96
+ @objc(resolveStorageCall:value:resolver:rejecter:)
97
+ public func resolveStorageCall(
98
+ _ id: String,
99
+ value: Any?,
100
+ resolver resolve: @escaping RCTPromiseResolveBlock,
101
+ rejecter reject: @escaping RCTPromiseRejectBlock
102
+ ) {
103
+ storageBridge.resolve(id: id, value: value)
104
+ resolve(NSNull())
105
+ }
106
+
107
+ /// JS calls this when its storage handler threw. The string becomes a `PingError`
108
+ /// on the Rust side.
109
+ @objc(rejectStorageCall:message:resolver:rejecter:)
110
+ public func rejectStorageCall(
111
+ _ id: String,
112
+ message: String,
113
+ resolver resolve: @escaping RCTPromiseResolveBlock,
114
+ rejecter reject: @escaping RCTPromiseRejectBlock
115
+ ) {
116
+ storageBridge.reject(id: id, message: message)
117
+ resolve(NSNull())
118
+ }
119
+
120
+ // MARK: - Transport round-trip resolution (called from JS)
121
+
122
+ @objc(resolveTransportCall:value:resolver:rejecter:)
123
+ public func resolveTransportCall(
124
+ _ id: String,
125
+ value: Any?,
126
+ resolver resolve: @escaping RCTPromiseResolveBlock,
127
+ rejecter reject: @escaping RCTPromiseRejectBlock
128
+ ) {
129
+ transportBridge.resolve(id: id, value: value)
130
+ resolve(NSNull())
131
+ }
132
+
133
+ @objc(rejectTransportCall:message:resolver:rejecter:)
134
+ public func rejectTransportCall(
135
+ _ id: String,
136
+ message: String,
137
+ resolver resolve: @escaping RCTPromiseResolveBlock,
138
+ rejecter reject: @escaping RCTPromiseRejectBlock
139
+ ) {
140
+ transportBridge.reject(id: id, message: message)
141
+ resolve(NSNull())
142
+ }
143
+
144
+ // MARK: - Storage round-trip self-test
145
+
146
+ /// Stage 3 acceptance test. Exercises the full Swift→JS→Swift round-trip without
147
+ /// involving Rust: calls bridge.put then bridge.get and verifies the values match.
148
+ /// Returns true on success.
149
+ ///
150
+ /// Stage 4 replaces this with calls into the real MessagingClient that flow through
151
+ /// the bridge automatically.
152
+ @objc(testStorageRoundtrip:rejecter:)
153
+ public func testStorageRoundtrip(
154
+ _ resolve: @escaping RCTPromiseResolveBlock,
155
+ rejecter reject: @escaping RCTPromiseRejectBlock
156
+ ) {
157
+ Task {
158
+ do {
159
+ let probe = "stage3-probe-\(UUID().uuidString)".data(using: .utf8)!
160
+ try await storageBridge.put(namespace: "test", key: "probe", value: probe)
161
+ let got = try await storageBridge.get(namespace: "test", key: "probe")
162
+ guard got == probe else {
163
+ reject("RoundtripMismatch", "stored bytes differed from retrieved bytes", nil)
164
+ return
165
+ }
166
+ let keys = try await storageBridge.listKeys(namespace: "test", prefix: "")
167
+ guard keys.contains("probe") else {
168
+ reject("RoundtripMismatch", "listKeys did not include probe", nil)
169
+ return
170
+ }
171
+ try await storageBridge.delete(namespace: "test", key: "probe")
172
+ let after = try await storageBridge.get(namespace: "test", key: "probe")
173
+ guard after == nil else {
174
+ reject("RoundtripMismatch", "probe still present after delete", nil)
175
+ return
176
+ }
177
+ resolve(true)
178
+ } catch {
179
+ reject("RoundtripError", String(describing: error), error)
180
+ }
181
+ }
182
+ }
183
+
184
+ // MARK: - Identity helper (stage 4b — UDL addition)
185
+
186
+ /// Generate a fresh identity export. Returns the bytes as a base64 string for safe
187
+ /// transit across the bridge. Hosts persist the bytes and pass them back to
188
+ /// `initClient(identityB64:...)` on subsequent launches.
189
+ @objc(generateIdentityExport:rejecter:)
190
+ public func generateIdentityExportNative(
191
+ _ resolve: @escaping RCTPromiseResolveBlock,
192
+ rejecter reject: @escaping RCTPromiseRejectBlock
193
+ ) {
194
+ // `generate_identity_export` is a UniFFI top-level free function. UniFFI's
195
+ // Swift output exposes it under camelCase: `generateIdentityExport()`.
196
+ let bytes = generateIdentityExport()
197
+ let b64 = Data(bytes).base64EncodedString()
198
+ resolve(b64)
199
+ }
200
+
201
+ // MARK: - MessagingClient lifecycle (stage 4b)
202
+
203
+ /// Construct a real `MessagingClient` via UniFFI, wired to our JS storage + transport
204
+ /// bridges. Caller should `connectStorageBridge(s)` and `connectTransportBridge(t)` on
205
+ /// the JS side before calling this so storage operations during init can resolve.
206
+ ///
207
+ /// `identityB64` is the base64-encoded identity export bytes. The desktop example
208
+ /// generates these via `crypto.getRandomValues` for v0.2 demo purposes; production
209
+ /// consumers will source persisted identity from secure storage.
210
+ ///
211
+ /// `nowMs` is the wall-clock at the call site (Hermes can't pass UInt64 across the
212
+ /// bridge precisely so we accept Double and truncate; valid for the next ~285,000
213
+ /// years of Unix time).
214
+ @objc(initClient:deviceLabel:nowMs:resolver:rejecter:)
215
+ public func initClient(
216
+ _ identityB64: String,
217
+ deviceLabel: String,
218
+ nowMs: Double,
219
+ resolver resolve: @escaping RCTPromiseResolveBlock,
220
+ rejecter reject: @escaping RCTPromiseRejectBlock
221
+ ) {
222
+ Task {
223
+ do {
224
+ guard let identityData = Data(base64Encoded: identityB64) else {
225
+ reject("InvalidIdentity", "identity bytes failed base64 decode", nil)
226
+ return
227
+ }
228
+ // UniFFI's `[Name=init]` constructor generates a static factory named
229
+ // `init` (backquoted because Swift reserves the bare identifier for
230
+ // designated initializers).
231
+ let c = try await MessagingClient.`init`(
232
+ identityExport: identityData,
233
+ deviceLabel: deviceLabel,
234
+ storage: self.storageBridge,
235
+ transport: self.transportBridge,
236
+ nowMs: UInt64(nowMs)
237
+ )
238
+ self.client = c
239
+ resolve(true)
240
+ } catch {
241
+ reject("InitFailed", String(describing: error), error)
242
+ }
243
+ }
244
+ }
245
+
246
+ /// Returns the user_id of the active client as a hex string. JS callers receive a
247
+ /// hex string and decode it themselves (avoids byte-array marshalling overhead for
248
+ /// a method that's likely called once at startup).
249
+ @objc(getUserId:rejecter:)
250
+ public func getUserId(
251
+ _ resolve: @escaping RCTPromiseResolveBlock,
252
+ rejecter reject: @escaping RCTPromiseRejectBlock
253
+ ) {
254
+ guard let client = self.client else {
255
+ reject("NotInitialised", "MessagingClient not initialised", nil)
256
+ return
257
+ }
258
+ let uid = client.userId()
259
+ resolve(uid.value.map { String(format: "%02x", $0) }.joined())
260
+ }
261
+
262
+ @objc(getDeviceId:rejecter:)
263
+ public func getDeviceId(
264
+ _ resolve: @escaping RCTPromiseResolveBlock,
265
+ rejecter reject: @escaping RCTPromiseRejectBlock
266
+ ) {
267
+ guard let client = self.client else {
268
+ reject("NotInitialised", "MessagingClient not initialised", nil)
269
+ return
270
+ }
271
+ let did = client.deviceId()
272
+ resolve(did.value.map { String(format: "%02x", $0) }.joined())
273
+ }
274
+
275
+ // MARK: - Messaging methods (stage 4c)
276
+
277
+ /// Generate a fresh KeyPackage that peers can use to add this device to their groups.
278
+ /// Synchronous on the Rust side but we wrap as a Promise for JS consistency.
279
+ @objc(freshKeyPackage:rejecter:)
280
+ public func freshKeyPackageNative(
281
+ _ resolve: @escaping RCTPromiseResolveBlock,
282
+ rejecter reject: @escaping RCTPromiseRejectBlock
283
+ ) {
284
+ guard let client = self.client else {
285
+ reject("NotInitialised", "MessagingClient not initialised", nil)
286
+ return
287
+ }
288
+ do {
289
+ let kp = try client.freshKeyPackage()
290
+ resolve(TypeBridge.encodeBytes(kp))
291
+ } catch {
292
+ reject("FreshKeyPackageFailed", String(describing: error), error)
293
+ }
294
+ }
295
+
296
+ /// Create a new conversation with `name` (optional). Returns the conversation id as
297
+ /// a `[Int]` byte array (16 bytes, ULID-encoded).
298
+ @objc(createConversation:nowMs:resolver:rejecter:)
299
+ public func createConversationNative(
300
+ _ name: NSString?,
301
+ nowMs: Double,
302
+ resolver resolve: @escaping RCTPromiseResolveBlock,
303
+ rejecter reject: @escaping RCTPromiseRejectBlock
304
+ ) {
305
+ Task {
306
+ guard let client = self.client else {
307
+ reject("NotInitialised", "MessagingClient not initialised", nil)
308
+ return
309
+ }
310
+ do {
311
+ let convName = name as String?
312
+ let convId = try await client.createConversation(name: convName, nowMs: UInt64(nowMs))
313
+ resolve(TypeBridge.encodeConversationId(convId))
314
+ } catch {
315
+ reject("CreateFailed", String(describing: error), error)
316
+ }
317
+ }
318
+ }
319
+
320
+ /// Join a conversation from a Welcome envelope. The relay broadcasts Welcomes to
321
+ /// every WS client; each client tries to join, and only the device whose KeyPackage
322
+ /// was used succeeds. Other devices fail with an MLS-level error which the caller
323
+ /// silently drops.
324
+ @objc(joinConversation:nowMs:resolver:rejecter:)
325
+ public func joinConversationNative(
326
+ _ welcomeDict: NSDictionary,
327
+ nowMs: Double,
328
+ resolver resolve: @escaping RCTPromiseResolveBlock,
329
+ rejecter reject: @escaping RCTPromiseRejectBlock
330
+ ) {
331
+ Task {
332
+ guard let client = self.client else {
333
+ reject("NotInitialised", "MessagingClient not initialised", nil)
334
+ return
335
+ }
336
+ do {
337
+ let welcome = try TypeBridge.decodeEnvelope(welcomeDict)
338
+ let convId = try await client.joinConversation(welcome: welcome, nowMs: UInt64(nowMs))
339
+ resolve(TypeBridge.encodeConversationId(convId))
340
+ } catch {
341
+ reject("JoinFailed", String(describing: error), error)
342
+ }
343
+ }
344
+ }
345
+
346
+ /// Send an application message in the given conversation. Returns the wire envelope
347
+ /// as a JSON-shaped dict (see TypeBridge.encodeEnvelope).
348
+ @objc(sendMessage:plaintext:nowMs:resolver:rejecter:)
349
+ public func sendMessageNative(
350
+ _ conversationIdBytes: NSArray,
351
+ plaintext plaintextBytes: NSArray,
352
+ nowMs: Double,
353
+ resolver resolve: @escaping RCTPromiseResolveBlock,
354
+ rejecter reject: @escaping RCTPromiseRejectBlock
355
+ ) {
356
+ Task {
357
+ guard let client = self.client else {
358
+ reject("NotInitialised", "MessagingClient not initialised", nil)
359
+ return
360
+ }
361
+ do {
362
+ let convId = try TypeBridge.decodeConversationId(conversationIdBytes)
363
+ let plaintext = try TypeBridge.decodeBytesOrThrow(plaintextBytes, field: "plaintext")
364
+ let env = try await client.send(
365
+ conversationId: convId,
366
+ plaintext: plaintext,
367
+ nowMs: UInt64(nowMs)
368
+ )
369
+ resolve(TypeBridge.encodeEnvelope(env))
370
+ } catch {
371
+ reject("SendFailed", String(describing: error), error)
372
+ }
373
+ }
374
+ }
375
+
376
+ /// Add members to an existing conversation. `keyPackages` is an array of byte arrays
377
+ /// (each one a peer's KeyPackage from `freshKeyPackage`).
378
+ @objc(addMembers:keyPackages:nowMs:resolver:rejecter:)
379
+ public func addMembersNative(
380
+ _ conversationIdBytes: NSArray,
381
+ keyPackages keyPackagesArr: NSArray,
382
+ nowMs: Double,
383
+ resolver resolve: @escaping RCTPromiseResolveBlock,
384
+ rejecter reject: @escaping RCTPromiseRejectBlock
385
+ ) {
386
+ Task {
387
+ guard let client = self.client else {
388
+ reject("NotInitialised", "MessagingClient not initialised", nil)
389
+ return
390
+ }
391
+ do {
392
+ let convId = try TypeBridge.decodeConversationId(conversationIdBytes)
393
+ var kps: [Data] = []
394
+ for kp in keyPackagesArr {
395
+ kps.append(try TypeBridge.decodeBytesOrThrow(kp, field: "keyPackage"))
396
+ }
397
+ try await client.addMembers(
398
+ conversationId: convId,
399
+ keyPackages: kps,
400
+ nowMs: UInt64(nowMs)
401
+ )
402
+ resolve(NSNull())
403
+ } catch {
404
+ reject("AddMembersFailed", String(describing: error), error)
405
+ }
406
+ }
407
+ }
408
+
409
+ /// Remove members by leaf index. Leaf indexes come from `MlsGroup::members()` in
410
+ /// stage 4e's `listConversations` result; for now they're caller-supplied integers.
411
+ @objc(removeMembers:leafIndexes:nowMs:resolver:rejecter:)
412
+ public func removeMembersNative(
413
+ _ conversationIdBytes: NSArray,
414
+ leafIndexes indexes: NSArray,
415
+ nowMs: Double,
416
+ resolver resolve: @escaping RCTPromiseResolveBlock,
417
+ rejecter reject: @escaping RCTPromiseRejectBlock
418
+ ) {
419
+ Task {
420
+ guard let client = self.client else {
421
+ reject("NotInitialised", "MessagingClient not initialised", nil)
422
+ return
423
+ }
424
+ do {
425
+ let convId = try TypeBridge.decodeConversationId(conversationIdBytes)
426
+ var leaves: [UInt32] = []
427
+ for n in indexes {
428
+ if let v = n as? NSNumber { leaves.append(v.uint32Value) }
429
+ }
430
+ try await client.removeMembers(
431
+ conversationId: convId,
432
+ leafIndexes: leaves,
433
+ nowMs: UInt64(nowMs)
434
+ )
435
+ resolve(NSNull())
436
+ } catch {
437
+ reject("RemoveMembersFailed", String(describing: error), error)
438
+ }
439
+ }
440
+ }
441
+
442
+ // MARK: - Sync methods (stage 4d)
443
+
444
+ /// Process a single incoming envelope from the wire. Returns the decrypted
445
+ /// `IncomingMessage` for `Application` kind envelopes, or `null` for handshake
446
+ /// kinds (Commit/Welcome/Proposal — those advance state without producing a
447
+ /// user-visible message).
448
+ ///
449
+ /// JS callers typically don't invoke this directly — `subscribe` on the transport
450
+ /// fans incoming envelopes here automatically. Exposed for completeness and tests.
451
+ @objc(processEnvelope:nowMs:resolver:rejecter:)
452
+ public func processEnvelopeNative(
453
+ _ envelopeDict: NSDictionary,
454
+ nowMs: Double,
455
+ resolver resolve: @escaping RCTPromiseResolveBlock,
456
+ rejecter reject: @escaping RCTPromiseRejectBlock
457
+ ) {
458
+ Task {
459
+ guard let client = self.client else {
460
+ reject("NotInitialised", "MessagingClient not initialised", nil)
461
+ return
462
+ }
463
+ do {
464
+ let env = try TypeBridge.decodeEnvelope(envelopeDict)
465
+ let result = try await client.processEnvelope(envelope: env, nowMs: UInt64(nowMs))
466
+ if let msg = result {
467
+ resolve(TypeBridge.encodeIncomingMessage(msg))
468
+ } else {
469
+ resolve(NSNull())
470
+ }
471
+ } catch {
472
+ reject("ProcessFailed", String(describing: error), error)
473
+ }
474
+ }
475
+ }
476
+
477
+ /// Catch-up sync. For each open conversation, calls the transport's `fetchSince`
478
+ /// (via the bridge), processes each returned envelope, and emits decrypted
479
+ /// `IncomingMessage`s. Returns the messages decoded since the last sync.
480
+ @objc(syncConversations:resolver:rejecter:)
481
+ public func syncConversationsNative(
482
+ _ nowMs: Double,
483
+ resolver resolve: @escaping RCTPromiseResolveBlock,
484
+ rejecter reject: @escaping RCTPromiseRejectBlock
485
+ ) {
486
+ Task {
487
+ guard let client = self.client else {
488
+ reject("NotInitialised", "MessagingClient not initialised", nil)
489
+ return
490
+ }
491
+ do {
492
+ let messages = try await client.syncConversations(nowMs: UInt64(nowMs))
493
+ resolve(TypeBridge.encodeIncomingMessageArray(messages))
494
+ } catch {
495
+ reject("SyncFailed", String(describing: error), error)
496
+ }
497
+ }
498
+ }
499
+
500
+ // MARK: - Discovery + observer (stage 4e)
501
+
502
+ /// Returns metadata for every conversation the client knows about.
503
+ @objc(listConversations:rejecter:)
504
+ public func listConversationsNative(
505
+ _ resolve: @escaping RCTPromiseResolveBlock,
506
+ rejecter reject: @escaping RCTPromiseRejectBlock
507
+ ) {
508
+ guard let client = self.client else {
509
+ reject("NotInitialised", "MessagingClient not initialised", nil)
510
+ return
511
+ }
512
+ let metas = client.listConversations()
513
+ resolve(TypeBridge.encodeConversationMetaArray(metas))
514
+ }
515
+
516
+ /// Returns metadata for every device the client knows about (for v0.1, just the
517
+ /// local device — see ping-ffi/lib.rs note).
518
+ @objc(listDevices:rejecter:)
519
+ public func listDevicesNative(
520
+ _ resolve: @escaping RCTPromiseResolveBlock,
521
+ rejecter reject: @escaping RCTPromiseRejectBlock
522
+ ) {
523
+ guard let client = self.client else {
524
+ reject("NotInitialised", "MessagingClient not initialised", nil)
525
+ return
526
+ }
527
+ let devs = client.listDevices()
528
+ resolve(TypeBridge.encodeDeviceInfoArray(devs))
529
+ }
530
+
531
+ /// Install the observer bridge. After this, the client emits `PingApplicationMessage`
532
+ /// and `PingConversationUpdated` events as new state arrives. JS subscribes via
533
+ /// `client.onMessage` / `client.onConversationUpdated`.
534
+ ///
535
+ /// Idempotent — calling twice replaces the existing observer.
536
+ @objc(setObserver:rejecter:)
537
+ public func setObserverNative(
538
+ _ resolve: @escaping RCTPromiseResolveBlock,
539
+ rejecter reject: @escaping RCTPromiseRejectBlock
540
+ ) {
541
+ guard let client = self.client else {
542
+ reject("NotInitialised", "MessagingClient not initialised", nil)
543
+ return
544
+ }
545
+ let observer = JSObserverBridge { [weak self] name, body in
546
+ self?.sendEvent(withName: name, body: body)
547
+ }
548
+ self.observerBridge = observer
549
+ client.setObserver(observer: observer)
550
+ resolve(NSNull())
551
+ }
552
+
553
+ // MARK: - Linking + revocation (stage 4f)
554
+
555
+ /// Build a linking ticket on the existing device (E) for a new device (N) that
556
+ /// just published its KeyPackage. The ticket bundles a Welcome to E's DeviceGroup
557
+ /// plus a catch-up snapshot. N consumes the ticket via `consumeLinkingTicket`.
558
+ @objc(buildLinkingTicket:newDeviceKp:nowMs:resolver:rejecter:)
559
+ public func buildLinkingTicketNative(
560
+ _ newDeviceIdBytes: NSArray,
561
+ newDeviceKp newDeviceKpBytes: NSArray,
562
+ nowMs: Double,
563
+ resolver resolve: @escaping RCTPromiseResolveBlock,
564
+ rejecter reject: @escaping RCTPromiseRejectBlock
565
+ ) {
566
+ Task {
567
+ guard let client = self.client else {
568
+ reject("NotInitialised", "MessagingClient not initialised", nil)
569
+ return
570
+ }
571
+ do {
572
+ let newDeviceId = try TypeBridge.decodeDeviceId(newDeviceIdBytes)
573
+ let newKp = try TypeBridge.decodeBytesOrThrow(newDeviceKpBytes, field: "newDeviceKp")
574
+ let ticket = try await client.buildLinkingTicket(
575
+ newDeviceId: newDeviceId,
576
+ newDeviceKp: newKp,
577
+ nowMs: UInt64(nowMs)
578
+ )
579
+ resolve(TypeBridge.encodeLinkingTicket(ticket))
580
+ } catch {
581
+ reject("LinkingFailed", String(describing: error), error)
582
+ }
583
+ }
584
+ }
585
+
586
+ /// Consume a linking ticket on the new device (N). Joins the user's DeviceGroup
587
+ /// using the embedded Welcome and applies the catch-up snapshot.
588
+ @objc(consumeLinkingTicket:nowMs:resolver:rejecter:)
589
+ public func consumeLinkingTicketNative(
590
+ _ ticketDict: NSDictionary,
591
+ nowMs: Double,
592
+ resolver resolve: @escaping RCTPromiseResolveBlock,
593
+ rejecter reject: @escaping RCTPromiseRejectBlock
594
+ ) {
595
+ Task {
596
+ guard let client = self.client else {
597
+ reject("NotInitialised", "MessagingClient not initialised", nil)
598
+ return
599
+ }
600
+ do {
601
+ let ticket = try TypeBridge.decodeLinkingTicket(ticketDict)
602
+ try await client.consumeLinkingTicket(
603
+ ticket: ticket,
604
+ nowMs: UInt64(nowMs)
605
+ )
606
+ resolve(NSNull())
607
+ } catch {
608
+ reject("ConsumeLinkingFailed", String(describing: error), error)
609
+ }
610
+ }
611
+ }
612
+
613
+ /// Revoke a device — issues Remove proposals in the DeviceGroup and every external
614
+ /// conversation the device participates in. Post-revocation, the target device
615
+ /// can't decrypt new traffic (PCS).
616
+ @objc(revokeDevice:nowMs:resolver:rejecter:)
617
+ public func revokeDeviceNative(
618
+ _ deviceIdBytes: NSArray,
619
+ nowMs: Double,
620
+ resolver resolve: @escaping RCTPromiseResolveBlock,
621
+ rejecter reject: @escaping RCTPromiseRejectBlock
622
+ ) {
623
+ Task {
624
+ guard let client = self.client else {
625
+ reject("NotInitialised", "MessagingClient not initialised", nil)
626
+ return
627
+ }
628
+ do {
629
+ let deviceId = try TypeBridge.decodeDeviceId(deviceIdBytes)
630
+ try await client.revokeDevice(deviceId: deviceId, nowMs: UInt64(nowMs))
631
+ resolve(NSNull())
632
+ } catch {
633
+ reject("RevokeFailed", String(describing: error), error)
634
+ }
635
+ }
636
+ }
637
+
638
+ // MARK: - macOS clipboard helper (stage 5 polish)
639
+
640
+ /// Write a string to the macOS pasteboard. Hermes doesn't ship `navigator.clipboard`
641
+ /// out of the box, so the desktop example polyfills it via this method.
642
+ /// Pasteboard access must run on the main queue.
643
+ @objc(setClipboard:resolver:rejecter:)
644
+ public func setClipboardNative(
645
+ _ text: NSString,
646
+ resolver resolve: @escaping RCTPromiseResolveBlock,
647
+ rejecter reject: @escaping RCTPromiseRejectBlock
648
+ ) {
649
+ DispatchQueue.main.async {
650
+ let pasteboard = NSPasteboard.general
651
+ pasteboard.clearContents()
652
+ pasteboard.setString(text as String, forType: .string)
653
+ resolve(NSNull())
654
+ }
655
+ }
656
+ }