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,162 @@
1
+ // TurboModule spec for `ping-openmls-sdk-react-native-macos`.
2
+ //
3
+ // react-native-codegen reads this file (declared in package.json `codegenConfig.jsSrcsDir`)
4
+ // and generates the C++/Swift host-object wiring at build time. The interface below is
5
+ // the authoritative contract between JS and the native module — adding a method here is
6
+ // a separate step from implementing it natively, but both are required for it to work.
7
+ //
8
+ // `TurboModuleRegistry.getEnforcing` returns a JSI-backed host object on new arch and
9
+ // falls through to the legacy `NativeModules.PingNative` proxy on old arch.
10
+ //
11
+ // Spec: docs/RN_NATIVE_BINDINGS.md (stage 2).
12
+
13
+ import type { TurboModule } from "react-native";
14
+ import { TurboModuleRegistry } from "react-native";
15
+
16
+ export interface Spec extends TurboModule {
17
+ /**
18
+ * Stage 2 smoke test. Returns the integer 1 (the current ping-wire-contract version).
19
+ * Replaced in stage 4 with a call into the UniFFI-generated `WIRE_VERSION` constant.
20
+ */
21
+ getWireContractVersion(): Promise<number>;
22
+
23
+ /**
24
+ * Stage 3 acceptance test. Exercises the full Storage round-trip
25
+ * (Swift → JS → Swift) without involving Rust: put → get → listKeys → delete → get.
26
+ * Returns true on success; rejects with a `RoundtripMismatch` / `RoundtripError`
27
+ * code on failure.
28
+ */
29
+ testStorageRoundtrip(): Promise<boolean>;
30
+
31
+ /**
32
+ * Stage 3 internal: JS handlers call this to resolve a pending storage call.
33
+ * `value` shape depends on the method (see storage-bridge.ts).
34
+ * Consumers don't call this directly — `connectStorageBridge()` does.
35
+ */
36
+ resolveStorageCall(id: string, value: unknown): Promise<void>;
37
+ rejectStorageCall(id: string, message: string): Promise<void>;
38
+
39
+ /** Stage 3 internal: same shape as resolveStorageCall, but for the transport bridge. */
40
+ resolveTransportCall(id: string, value: unknown): Promise<void>;
41
+ rejectTransportCall(id: string, message: string): Promise<void>;
42
+
43
+ /**
44
+ * Stage 4b: generate a fresh identity. Returns the export bytes as base64. Hosts
45
+ * persist this and pass it back as `identityB64` on subsequent `initClient` calls.
46
+ */
47
+ generateIdentityExport(): Promise<string>;
48
+
49
+ /**
50
+ * Stage 4b: construct a real MessagingClient. The caller must have connected the
51
+ * storage + transport bridges via `connectStorageBridge` / `connectTransportBridge`
52
+ * before this so storage operations during init can resolve.
53
+ *
54
+ * - `identityB64` — base64 of the identity export bytes
55
+ * - `deviceLabel` — human-readable label (e.g. "MacBook")
56
+ * - `nowMs` — wall clock; pass `Date.now()`
57
+ *
58
+ * Resolves to `true` on success, rejects with `InitFailed` (Rust error) or
59
+ * `InvalidIdentity` (base64 decode) on failure.
60
+ */
61
+ initClient(identityB64: string, deviceLabel: string, nowMs: number): Promise<boolean>;
62
+
63
+ /** Returns the active client's user_id as a hex string. Rejects if not initialised. */
64
+ getUserId(): Promise<string>;
65
+
66
+ /** Returns the active client's device_id as a hex string. Rejects if not initialised. */
67
+ getDeviceId(): Promise<string>;
68
+
69
+ // ----- Stage 4c: messaging methods -----
70
+ // Byte arrays cross the bridge as `number[]` to match RN's NSArray<NSNumber> marshalling.
71
+
72
+ /** Fresh KeyPackage that peers can use to add this device to their groups. */
73
+ freshKeyPackage(): Promise<number[]>;
74
+
75
+ /** Create a new conversation. Returns the 16-byte conversation id. */
76
+ createConversation(name: string | null, nowMs: number): Promise<number[]>;
77
+
78
+ /**
79
+ * Join a conversation from a Welcome envelope. Returns the joined conversation id.
80
+ * Errors if the Welcome was for a different device's KeyPackage (the standard
81
+ * way to drop broadcast Welcomes on this device).
82
+ */
83
+ joinConversation(welcome: Record<string, unknown>, nowMs: number): Promise<number[]>;
84
+
85
+ /** Send an application message. Returns the wire `MessageEnvelope` as a JSON object. */
86
+ sendMessage(
87
+ conversationId: number[],
88
+ plaintext: number[],
89
+ nowMs: number,
90
+ ): Promise<Record<string, unknown>>;
91
+
92
+ /** Add members by their KeyPackage bytes. Resolves to null on success. */
93
+ addMembers(
94
+ conversationId: number[],
95
+ keyPackages: number[][],
96
+ nowMs: number,
97
+ ): Promise<null>;
98
+
99
+ /** Remove members by leaf index in the conversation's ratchet tree. */
100
+ removeMembers(
101
+ conversationId: number[],
102
+ leafIndexes: number[],
103
+ nowMs: number,
104
+ ): Promise<null>;
105
+
106
+ // ----- Stage 4d: sync methods -----
107
+
108
+ /**
109
+ * Process an incoming envelope. Returns the decrypted `IncomingMessage` JSON dict
110
+ * for Application envelopes, or `null` for handshake kinds (Commit/Welcome/Proposal).
111
+ */
112
+ processEnvelope(
113
+ envelope: Record<string, unknown>,
114
+ nowMs: number,
115
+ ): Promise<Record<string, unknown> | null>;
116
+
117
+ /**
118
+ * Catch-up sync — pulls envelopes from the transport for every open conversation,
119
+ * applies them in order, returns the decrypted application messages.
120
+ */
121
+ syncConversations(nowMs: number): Promise<Record<string, unknown>[]>;
122
+
123
+ // ----- Stage 4e: discovery + observer -----
124
+
125
+ /** Metadata for every open conversation. */
126
+ listConversations(): Promise<Record<string, unknown>[]>;
127
+
128
+ /** Metadata for every known device (v0.1: just the local device). */
129
+ listDevices(): Promise<Record<string, unknown>[]>;
130
+
131
+ /**
132
+ * Install the observer bridge — after this, native fires `PingApplicationMessage`
133
+ * and `PingConversationUpdated` events. JS subscribes via `NativeEventEmitter`
134
+ * (see MessagingClient.onMessage / onConversationUpdated).
135
+ */
136
+ setObserver(): Promise<null>;
137
+
138
+ // ----- Stage 4f: linking + revocation -----
139
+
140
+ /** Build a linking ticket for a new device (caller is the existing device E). */
141
+ buildLinkingTicket(
142
+ newDeviceId: number[],
143
+ newDeviceKp: number[],
144
+ nowMs: number,
145
+ ): Promise<Record<string, unknown>>;
146
+
147
+ /** Consume a linking ticket (caller is the new device N). */
148
+ consumeLinkingTicket(
149
+ ticket: Record<string, unknown>,
150
+ nowMs: number,
151
+ ): Promise<null>;
152
+
153
+ /** Revoke a device — Remove proposals in DeviceGroup + every conversation. */
154
+ revokeDevice(deviceId: number[], nowMs: number): Promise<null>;
155
+
156
+ // ----- macOS-platform helpers -----
157
+
158
+ /** Write to the macOS pasteboard. Used by `installClipboardPolyfill()`. */
159
+ setClipboard(text: string): Promise<null>;
160
+ }
161
+
162
+ export default TurboModuleRegistry.getEnforcing<Spec>("PingNative");
@@ -0,0 +1,118 @@
1
+ // Reference WebSocket transport for `ping-openmls-sdk-react-native-macos`. Mirrors the
2
+ // shape of the web SDK's `WebSocketTransport` so consumers can swap implementations
3
+ // with a single import-line change.
4
+ //
5
+ // The relay we talk to (see `examples/relay-server`) accepts the JSON projection of
6
+ // `MessageEnvelope` (byte fields wrapped as `[number]` arrays). The bridge marshals
7
+ // in/out of this shape via the same projection — see `bindings/react-native-macos/ios/TypeBridge.swift`.
8
+ //
9
+ // Spec: docs/RN_NATIVE_BINDINGS.md (stage 5).
10
+
11
+ import type { Transport } from "./transport-bridge";
12
+
13
+ export interface WsTransportConfig {
14
+ /** http[s]:// base URL of the relay. Switched to ws[s]:// for the live stream. */
15
+ baseUrl: string;
16
+ /** Optional bearer token; sent as `Authorization` header on REST requests. */
17
+ authHeader?: string;
18
+ /** Override `globalThis.fetch` (test/SSR). RN ships a usable global by default. */
19
+ fetchImpl?: typeof fetch;
20
+ }
21
+
22
+ export class WebSocketTransport implements Transport {
23
+ private readonly fetchImpl: typeof fetch;
24
+ private ws: WebSocket | null = null;
25
+ private liveHandlers = new Set<(env: Record<string, unknown>) => void>();
26
+ private closed = false;
27
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
28
+
29
+ constructor(private readonly cfg: WsTransportConfig) {
30
+ this.fetchImpl = cfg.fetchImpl ?? globalThis.fetch.bind(globalThis);
31
+ this.openSocket();
32
+ }
33
+
34
+ private openSocket(): void {
35
+ if (typeof WebSocket === "undefined") return;
36
+ const url = `${this.cfg.baseUrl.replace(/^http/, "ws")}/stream`;
37
+ const ws = new WebSocket(url);
38
+ this.ws = ws;
39
+ ws.onmessage = (ev: { data: string | ArrayBuffer }) => {
40
+ const text = typeof ev.data === "string"
41
+ ? ev.data
42
+ : (() => { try { return new TextDecoder().decode(ev.data as ArrayBuffer); } catch { return ""; } })();
43
+ try {
44
+ const env = JSON.parse(text) as Record<string, unknown>;
45
+ for (const h of this.liveHandlers) h(env);
46
+ } catch {
47
+ // ignore malformed frames
48
+ }
49
+ };
50
+ ws.onclose = () => {
51
+ this.ws = null;
52
+ if (this.closed) return;
53
+ this.reconnectTimer = setTimeout(() => this.openSocket(), 1000);
54
+ };
55
+ ws.onerror = () => { /* onclose follows */ };
56
+ }
57
+
58
+ // ----- Transport interface -----
59
+
60
+ async send(envelope: Record<string, unknown>): Promise<void> {
61
+ const res = await this.fetchImpl(`${this.cfg.baseUrl}/send`, {
62
+ method: "POST",
63
+ headers: {
64
+ "content-type": "application/json",
65
+ ...(this.cfg.authHeader ? { authorization: this.cfg.authHeader } : {}),
66
+ },
67
+ body: JSON.stringify(envelope),
68
+ });
69
+ if (res.status === 409) throw new Error("EpochOccupied");
70
+ if (!res.ok && res.status !== 204) {
71
+ throw new Error(`transport send failed: ${res.status}`);
72
+ }
73
+ }
74
+
75
+ async fetchSince(
76
+ conversationIdHex: string,
77
+ cursorBase64: string,
78
+ limit: number,
79
+ ): Promise<Record<string, unknown>[]> {
80
+ // Hermes doesn't fully implement `URL` / `URLSearchParams` (set is missing on RN
81
+ // 0.73's polyfill), so build the query string manually. encodeURIComponent is
82
+ // available on every JS engine.
83
+ const qs = `conv=${encodeURIComponent(conversationIdHex)}` +
84
+ `&cursor=${encodeURIComponent(cursorBase64)}` +
85
+ `&limit=${limit}`;
86
+ const res = await this.fetchImpl(`${this.cfg.baseUrl}/sync?${qs}`, {
87
+ headers: this.cfg.authHeader ? { authorization: this.cfg.authHeader } : {},
88
+ });
89
+ if (!res.ok) throw new Error(`transport fetch failed: ${res.status}`);
90
+ const body = (await res.json()) as Record<string, unknown>[];
91
+ return Array.isArray(body) ? body : [];
92
+ }
93
+
94
+ async discoverDevices(userIdHex: string): Promise<Record<string, unknown>[]> {
95
+ const res = await this.fetchImpl(`${this.cfg.baseUrl}/devices/${userIdHex}`, {
96
+ headers: this.cfg.authHeader ? { authorization: this.cfg.authHeader } : {},
97
+ });
98
+ if (!res.ok) throw new Error(`device discovery failed: ${res.status}`);
99
+ const body = (await res.json()) as Record<string, unknown>[];
100
+ return Array.isArray(body) ? body : [];
101
+ }
102
+
103
+ subscribe(onEvent: (env: Record<string, unknown>) => void): { unsubscribe(): void } {
104
+ this.liveHandlers.add(onEvent);
105
+ return {
106
+ unsubscribe: () => { this.liveHandlers.delete(onEvent); },
107
+ };
108
+ }
109
+
110
+ /** Close the WebSocket and stop reconnecting. */
111
+ close(): void {
112
+ this.closed = true;
113
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
114
+ this.ws?.close();
115
+ this.ws = null;
116
+ this.liveHandlers.clear();
117
+ }
118
+ }
@@ -0,0 +1,37 @@
1
+ // Clipboard helpers for the macOS desktop. Hermes (RN's JS engine) doesn't expose
2
+ // `navigator.clipboard.writeText` — this module bridges to AppKit's NSPasteboard via
3
+ // the native module.
4
+ //
5
+ // Spec: docs/RN_NATIVE_BINDINGS.md (stage 5).
6
+
7
+ import NativePing from "./NativePing";
8
+
9
+ /** Write a string to the macOS pasteboard. */
10
+ export async function setClipboard(text: string): Promise<void> {
11
+ await NativePing.setClipboard(text);
12
+ }
13
+
14
+ /**
15
+ * Install `globalThis.navigator.clipboard.writeText` so libraries that target the
16
+ * browser API (like the shared `<ChatScreen>` component's copy button) work on
17
+ * RN-macOS without any per-call site changes.
18
+ *
19
+ * Idempotent — calling multiple times is fine. Returns the polyfilled function.
20
+ */
21
+ export function installClipboardPolyfill(): (text: string) => Promise<void> {
22
+ const writeText = (text: string) => setClipboard(text);
23
+ const g = globalThis as unknown as {
24
+ navigator?: { clipboard?: { writeText?: (text: string) => Promise<void> } };
25
+ };
26
+ if (!g.navigator) {
27
+ (globalThis as unknown as { navigator: object }).navigator = {};
28
+ }
29
+ if (!g.navigator!.clipboard) {
30
+ g.navigator!.clipboard = {};
31
+ }
32
+ // Don't overwrite an existing clipboard impl (e.g. another polyfill already ran).
33
+ if (!g.navigator!.clipboard!.writeText) {
34
+ g.navigator!.clipboard!.writeText = writeText;
35
+ }
36
+ return writeText;
37
+ }
package/src/index.ts ADDED
@@ -0,0 +1,112 @@
1
+ // Public API for `ping-openmls-sdk-react-native-macos`.
2
+ //
3
+ // Drop-in replaceable for `ping-openmls-sdk` (the WASM-based web SDK) — a desktop app
4
+ // can swap between the two with a single import-line change. The class shapes,
5
+ // method signatures, and event semantics are mirrored 1:1.
6
+ //
7
+ // What's exported:
8
+ //
9
+ // Core API:
10
+ // • `MessagingClient` — the top-level handle. `MessagingClient.init({...})` returns one.
11
+ // • `Conversation` — scoped handle returned from `client.createConversation` /
12
+ // `client.getConversation`. Carries `send`, `addMembers`, `removeMembers`.
13
+ //
14
+ // Types:
15
+ // • `ClientConfig` — shape of `MessagingClient.init`'s argument
16
+ // • `IncomingMessage` — decrypted application message
17
+ // • `MessageEnvelope` — wire-format envelope (the substrate; subscribers to the
18
+ // transport's live stream feed these to `client.processEnvelope`)
19
+ // • `ConversationMeta` — return shape of `client.listConversations`
20
+ // • `DeviceInfo` — return shape of `client.listDevices`
21
+ // • `LinkingTicket` — return shape of `client.buildLinkingTicket`
22
+ // • `Storage`, `Transport` — interfaces hosts implement and pass to `MessagingClient.init`
23
+ //
24
+ // Storage helpers:
25
+ // • `InMemoryStorage` — Map-backed Storage; useful for tests + the smoke flow
26
+ // • `connectStorageBridge` — low-level — `MessagingClient.init` calls this internally;
27
+ // exposed for advanced multi-client setups
28
+ // • `connectTransportBridge` — same pattern for Transport
29
+ //
30
+ // Bridge health checks:
31
+ // • `getWireContractVersion()` — JS↔Swift round-trip smoke test
32
+ // • `testStorageRoundtrip()` — Rust→Swift→JS→Swift→Rust round-trip self-test
33
+ //
34
+ // Spec: docs/RN_NATIVE_BINDINGS.md.
35
+
36
+ import NativePing from "./NativePing";
37
+
38
+ export {
39
+ connectStorageBridge,
40
+ InMemoryStorage,
41
+ type Storage,
42
+ } from "./storage-bridge";
43
+
44
+ export {
45
+ connectTransportBridge,
46
+ type Transport,
47
+ } from "./transport-bridge";
48
+
49
+ export {
50
+ WebSocketTransport,
51
+ type WsTransportConfig,
52
+ } from "./WebSocketTransport";
53
+
54
+ export {
55
+ setClipboard,
56
+ installClipboardPolyfill,
57
+ } from "./clipboard";
58
+
59
+ export {
60
+ MessagingClient,
61
+ Conversation,
62
+ type ClientConfig,
63
+ type IncomingMessage,
64
+ type ConversationMeta,
65
+ type DeviceInfo,
66
+ type LinkingTicket,
67
+ } from "./MessagingClient";
68
+
69
+ /**
70
+ * Wire-format envelope. Hosts that subscribe to the transport's live stream feed
71
+ * received envelopes to `client.processEnvelope`; the SDK then decrypts and emits
72
+ * `IncomingMessage` via `client.onMessage`.
73
+ *
74
+ * Shape mirrors `ping-openmls-sdk`'s `MessageEnvelope` exactly. Byte fields are
75
+ * `Uint8Array` on the public surface; the bridge marshals them as `number[]` JSON
76
+ * arrays internally.
77
+ */
78
+ export interface MessageEnvelope {
79
+ v: number;
80
+ conversationId: Uint8Array;
81
+ epoch: number;
82
+ kind: "Application" | "Commit" | "Welcome" | "Proposal" | "KeyPackage";
83
+ senderDevice: Uint8Array;
84
+ seq: number;
85
+ hlc: { wallMs: number; logical: number };
86
+ payload: Uint8Array;
87
+ contentHash: Uint8Array;
88
+ }
89
+
90
+ /**
91
+ * Smoke test for the native bridge. Returns `1` if the JS↔Swift round-trip works.
92
+ *
93
+ * Exported in every stage so consumers can confirm their TurboModule is loaded
94
+ * correctly without needing to instantiate a full `MessagingClient`.
95
+ */
96
+ export async function getWireContractVersion(): Promise<number> {
97
+ return NativePing.getWireContractVersion();
98
+ }
99
+
100
+ /**
101
+ * Stage 3 acceptance test. Exercises the Storage callback bridge end-to-end
102
+ * (Swift → JS → Swift) by performing put → get → listKeys → delete → get on a
103
+ * Storage object that you've previously connected via `connectStorageBridge`.
104
+ *
105
+ * Returns `true` on success. Rejects with `RoundtripMismatch` if the round-tripped
106
+ * bytes differ from the originals, or `RoundtripError` for any other failure.
107
+ *
108
+ * Mostly useful as a health check after install — production code should rarely call this.
109
+ */
110
+ export async function testStorageRoundtrip(): Promise<boolean> {
111
+ return NativePing.testStorageRoundtrip();
112
+ }
@@ -0,0 +1,124 @@
1
+ // JS-side wiring for the Storage callback bridge. The user supplies a `Storage`
2
+ // implementation (matching `ping-openmls-sdk`'s Storage interface); we attach event
3
+ // listeners that route native bridge calls to it and pipe the results back via
4
+ // `PingNative.resolveStorageCall` / `rejectStorageCall`.
5
+ //
6
+ // Stage 4 will tie this lifecycle to `MessagingClient.init` automatically. For stage 3
7
+ // it's exposed as a standalone helper so the round-trip self-test can wire a Storage
8
+ // up before calling `testStorageRoundtrip()`.
9
+ //
10
+ // Spec: docs/RN_NATIVE_BINDINGS.md (stage 3).
11
+
12
+ import { NativeEventEmitter, NativeModules } from "react-native";
13
+
14
+ /**
15
+ * Storage interface — same shape as `ping-openmls-sdk`'s `Storage` so MessagingClient.init
16
+ * (stage 4) can accept either.
17
+ */
18
+ export interface Storage {
19
+ get(namespace: string, key: string): Promise<Uint8Array | null>;
20
+ put(namespace: string, key: string, value: Uint8Array): Promise<void>;
21
+ delete(namespace: string, key: string): Promise<void>;
22
+ listKeys(namespace: string, prefix: string): Promise<string[]>;
23
+ }
24
+
25
+ interface StorageCallEvent {
26
+ id: string;
27
+ method: "get" | "put" | "delete" | "listKeys";
28
+ args: Record<string, unknown>;
29
+ }
30
+
31
+ /**
32
+ * Connect a JS Storage implementation to the native bridge. Returns an unsubscribe
33
+ * function that detaches the event listener. Calling unsubscribe leaves any
34
+ * already-in-flight calls hanging — only do it during teardown.
35
+ */
36
+ export function connectStorageBridge(storage: Storage): () => void {
37
+ const emitter = new NativeEventEmitter(NativeModules.PingNative);
38
+
39
+ const subscription = emitter.addListener("PingStorageCall", (event: StorageCallEvent) => {
40
+ void handle(event, storage);
41
+ });
42
+
43
+ return () => subscription.remove();
44
+ }
45
+
46
+ async function handle(event: StorageCallEvent, storage: Storage): Promise<void> {
47
+ const { id, method, args } = event;
48
+ try {
49
+ let result: unknown;
50
+ switch (method) {
51
+ case "get": {
52
+ const v = await storage.get(args.namespace as string, args.key as string);
53
+ result = v === null ? null : Array.from(v);
54
+ break;
55
+ }
56
+ case "put": {
57
+ const value = bytesIn(args.value);
58
+ await storage.put(args.namespace as string, args.key as string, value);
59
+ result = null;
60
+ break;
61
+ }
62
+ case "delete": {
63
+ await storage.delete(args.namespace as string, args.key as string);
64
+ result = null;
65
+ break;
66
+ }
67
+ case "listKeys": {
68
+ result = await storage.listKeys(args.namespace as string, args.prefix as string);
69
+ break;
70
+ }
71
+ default:
72
+ throw new Error(`unknown storage method: ${String(method)}`);
73
+ }
74
+ await NativeModules.PingNative.resolveStorageCall(id, result);
75
+ } catch (e) {
76
+ const message = e instanceof Error ? e.message : String(e);
77
+ try {
78
+ await NativeModules.PingNative.rejectStorageCall(id, message);
79
+ } catch {
80
+ // If even the reject fails (native module gone?), drop the call — the Swift
81
+ // continuation will leak but the alternative is a crash on teardown.
82
+ }
83
+ }
84
+ }
85
+
86
+ function bytesIn(v: unknown): Uint8Array {
87
+ if (v instanceof Uint8Array) return v;
88
+ if (Array.isArray(v)) return Uint8Array.from(v as number[]);
89
+ return new Uint8Array(0);
90
+ }
91
+
92
+ /**
93
+ * Trivial in-memory Storage implementation, useful for tests + the stage 3 round-trip
94
+ * self-test. Production consumers will use a SQLite-backed implementation (per the
95
+ * decision recorded in docs/RN_NATIVE_BINDINGS.md — GRDB).
96
+ */
97
+ export class InMemoryStorage implements Storage {
98
+ private readonly map = new Map<string, Uint8Array>();
99
+
100
+ private k(ns: string, key: string): string { return `${ns}/${key}`; }
101
+
102
+ async get(namespace: string, key: string): Promise<Uint8Array | null> {
103
+ return this.map.get(this.k(namespace, key)) ?? null;
104
+ }
105
+
106
+ async put(namespace: string, key: string, value: Uint8Array): Promise<void> {
107
+ this.map.set(this.k(namespace, key), value);
108
+ }
109
+
110
+ async delete(namespace: string, key: string): Promise<void> {
111
+ this.map.delete(this.k(namespace, key));
112
+ }
113
+
114
+ async listKeys(namespace: string, prefix: string): Promise<string[]> {
115
+ const out: string[] = [];
116
+ const nsPrefix = `${namespace}/`;
117
+ for (const k of this.map.keys()) {
118
+ if (!k.startsWith(nsPrefix)) continue;
119
+ const rel = k.slice(nsPrefix.length);
120
+ if (rel.startsWith(prefix)) out.push(rel);
121
+ }
122
+ return out;
123
+ }
124
+ }
@@ -0,0 +1,89 @@
1
+ // JS-side wiring for the Transport callback bridge. Same pattern as
2
+ // `storage-bridge.ts` — the user supplies a Transport-like object, we attach a listener
3
+ // to "PingTransportCall" events, route calls to the user's object, pipe results back
4
+ // via `PingNative.resolveTransportCall` / `rejectTransportCall`.
5
+ //
6
+ // Spec: docs/RN_NATIVE_BINDINGS.md (stage 3).
7
+
8
+ import { NativeEventEmitter, NativeModules } from "react-native";
9
+
10
+ /**
11
+ * Transport interface — same shape as `ping-openmls-sdk`'s web Transport.
12
+ *
13
+ * The first three methods are invoked by the Rust core (via the bridge) when it
14
+ * needs to send/fetch/discover. The optional `subscribe` is invoked by
15
+ * `MessagingClient.init` to wire the transport's live-receive path into the SDK
16
+ * without an explicit user-side `processEnvelope` call.
17
+ */
18
+ export interface Transport {
19
+ send(envelopeJson: Record<string, unknown>): Promise<void>;
20
+ fetchSince(conversationIdHex: string, cursorBase64: string, limit: number): Promise<Record<string, unknown>[]>;
21
+ discoverDevices(userIdHex: string): Promise<Record<string, unknown>[]>;
22
+ /**
23
+ * Subscribe to live envelope frames (typically from a WebSocket). The SDK calls
24
+ * this once during `MessagingClient.init`; each frame is fed into
25
+ * `client.processEnvelope` automatically. Implementations return an unsubscribe
26
+ * handle.
27
+ */
28
+ subscribe?(onEvent: (envelopeJson: Record<string, unknown>) => void): { unsubscribe(): void };
29
+ }
30
+
31
+ interface TransportCallEvent {
32
+ id: string;
33
+ method: "send" | "fetchSince" | "discoverDevices";
34
+ args: Record<string, unknown>;
35
+ }
36
+
37
+ /** Connect a JS Transport implementation to the native bridge. */
38
+ export function connectTransportBridge(transport: Transport): () => void {
39
+ const emitter = new NativeEventEmitter(NativeModules.PingNative);
40
+
41
+ const subscription = emitter.addListener("PingTransportCall", (event: TransportCallEvent) => {
42
+ void handle(event, transport);
43
+ });
44
+
45
+ return () => subscription.remove();
46
+ }
47
+
48
+ async function handle(event: TransportCallEvent, transport: Transport): Promise<void> {
49
+ const { id, method, args } = event;
50
+ // eslint-disable-next-line no-console
51
+ console.log("[transport-bridge]", method, "args=", JSON.stringify(args).slice(0, 200));
52
+ try {
53
+ let result: unknown;
54
+ switch (method) {
55
+ case "send": {
56
+ await transport.send(args.envelope as Record<string, unknown>);
57
+ result = null;
58
+ break;
59
+ }
60
+ case "fetchSince": {
61
+ result = await transport.fetchSince(
62
+ args.conversationIdHex as string,
63
+ args.cursorBase64 as string,
64
+ args.limit as number,
65
+ );
66
+ break;
67
+ }
68
+ case "discoverDevices": {
69
+ result = await transport.discoverDevices(args.userIdHex as string);
70
+ break;
71
+ }
72
+ default:
73
+ throw new Error(`unknown transport method: ${String(method)}`);
74
+ }
75
+ // eslint-disable-next-line no-console
76
+ console.log("[transport-bridge]", method, "OK, result-len=",
77
+ Array.isArray(result) ? result.length : (result === null ? "null" : typeof result));
78
+ await NativeModules.PingNative.resolveTransportCall(id, result);
79
+ } catch (e) {
80
+ const message = e instanceof Error ? e.message : String(e);
81
+ // eslint-disable-next-line no-console
82
+ console.warn("[transport-bridge]", method, "FAILED:", message);
83
+ try {
84
+ await NativeModules.PingNative.rejectTransportCall(id, message);
85
+ } catch {
86
+ // see storage-bridge.ts comment
87
+ }
88
+ }
89
+ }