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,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
|
+
}
|
package/src/clipboard.ts
ADDED
|
@@ -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
|
+
}
|