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.
- package/Frameworks/.gitkeep +0 -0
- package/Frameworks/libping_ffi.a +0 -0
- package/README.md +64 -0
- package/ios/.gitkeep +0 -0
- package/ios/Generated.swift +3239 -0
- package/ios/JSObserverBridge.swift +35 -0
- package/ios/JSStorageBridge.swift +135 -0
- package/ios/JSTransportBridge.swift +126 -0
- package/ios/PingNativeModule.m +143 -0
- package/ios/PingNativeModule.swift +656 -0
- package/ios/TypeBridge.swift +297 -0
- package/ios/module.modulemap +4 -0
- package/ios/pingFFI.h +990 -0
- package/package.json +34 -0
- package/ping-openmls-sdk-react-native-macos.podspec +54 -0
- package/src/MessagingClient.ts +488 -0
- package/src/NativePing.ts +162 -0
- package/src/WebSocketTransport.ts +118 -0
- package/src/clipboard.ts +37 -0
- package/src/index.ts +112 -0
- package/src/storage-bridge.ts +124 -0
- package/src/transport-bridge.ts +89 -0
|
@@ -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
|
+
}
|