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
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ping-openmls-sdk-react-native-macos",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Real MLS for React Native macOS apps — wraps the ping-openmls-sdk Rust core via UniFFI.",
|
|
5
|
+
"homepage": "https://github.com/AMP-Media-Development/ping-openmls-sdk",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"author": "Ping",
|
|
8
|
+
"main": "./src/index.ts",
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"ios",
|
|
13
|
+
"Frameworks",
|
|
14
|
+
"ping-openmls-sdk-react-native-macos.podspec",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"keywords": [
|
|
18
|
+
"react-native",
|
|
19
|
+
"react-native-macos",
|
|
20
|
+
"mls",
|
|
21
|
+
"openmls",
|
|
22
|
+
"e2ee"
|
|
23
|
+
],
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"react": ">=18",
|
|
26
|
+
"react-native": ">=0.73",
|
|
27
|
+
"react-native-macos": ">=0.73"
|
|
28
|
+
},
|
|
29
|
+
"codegenConfig": {
|
|
30
|
+
"name": "PingNativeSpec",
|
|
31
|
+
"type": "modules",
|
|
32
|
+
"jsSrcsDir": "src"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# CocoaPods descriptor for `ping-openmls-sdk-react-native-macos`.
|
|
2
|
+
#
|
|
3
|
+
# RN's autolinking discovers this file via the `react-native.config.js` of consumer apps
|
|
4
|
+
# (or via Microsoft's `react-native-macos` autolinking script for macOS targets) and
|
|
5
|
+
# generates the `pod 'ping-openmls-sdk-react-native-macos'` line in the host's Podfile.
|
|
6
|
+
#
|
|
7
|
+
# Spec: docs/RN_NATIVE_BINDINGS.md (stage 2).
|
|
8
|
+
|
|
9
|
+
require 'json'
|
|
10
|
+
|
|
11
|
+
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
|
|
12
|
+
|
|
13
|
+
Pod::Spec.new do |s|
|
|
14
|
+
# CocoaPods Pod names cannot contain `@` or `/` (those are reserved — `/` means subspec).
|
|
15
|
+
# The npm package is `ping-openmls-sdk-react-native-macos` but the Pod name has to be
|
|
16
|
+
# the hyphenated, unscoped form. RN's autolinker discovers this podspec via
|
|
17
|
+
# `react-native.config.js` (sibling file) and translates between the two names.
|
|
18
|
+
s.name = 'ping-openmls-sdk-react-native-macos'
|
|
19
|
+
s.version = package['version']
|
|
20
|
+
s.summary = package['description']
|
|
21
|
+
s.homepage = package['homepage']
|
|
22
|
+
# SPDX identifier only — pointing at `:file => '../../LICENSE'` would resolve from the
|
|
23
|
+
# package's install location under `node_modules/`, where the relative path doesn't
|
|
24
|
+
# reach the repo root. CocoaPods accepts a bare SPDX string for the license type.
|
|
25
|
+
s.license = package['license']
|
|
26
|
+
s.authors = { 'Ping' => 'https://github.com/AMP-Media-Development' }
|
|
27
|
+
|
|
28
|
+
# macOS-only Pod. The directory is named `ios/` to match the conventional CocoaPods
|
|
29
|
+
# layout, but the platform target is macOS — react-native-macos uses the iOS Pod
|
|
30
|
+
# tooling because it shares the AppKit-on-Cocoa codepath, even though the deployment
|
|
31
|
+
# target is `:osx`.
|
|
32
|
+
s.platforms = { :osx => '10.15' }
|
|
33
|
+
|
|
34
|
+
s.source = { :git => 'https://github.com/AMP-Media-Development/ping-openmls-sdk.git',
|
|
35
|
+
:tag => "ping-openmls-sdk-rn-macos-v#{s.version}" }
|
|
36
|
+
|
|
37
|
+
s.source_files = 'ios/**/*.{h,m,mm,swift}'
|
|
38
|
+
s.public_header_files = 'ios/pingFFI.h'
|
|
39
|
+
s.preserve_paths = 'ios/module.modulemap', 'Frameworks/libping_ffi.a'
|
|
40
|
+
|
|
41
|
+
# Universal arm64+x86_64 static library produced by `scripts/build-macos.sh`. Static
|
|
42
|
+
# linking means the linker bakes the Rust code into the host app's binary at build
|
|
43
|
+
# time — no dylib to embed, no rpath setup, no runtime loading. This matches what the
|
|
44
|
+
# iOS pipeline does (see `scripts/build-ios.sh` → xcframework wraps a `.a`).
|
|
45
|
+
s.vendored_libraries = 'Frameworks/libping_ffi.a'
|
|
46
|
+
|
|
47
|
+
s.dependency 'React-Core'
|
|
48
|
+
|
|
49
|
+
s.pod_target_xcconfig = {
|
|
50
|
+
'SWIFT_INCLUDE_PATHS' => '$(PODS_TARGET_SRCROOT)/ios',
|
|
51
|
+
'CLANG_ENABLE_MODULES' => 'YES',
|
|
52
|
+
'DEFINES_MODULE' => 'YES'
|
|
53
|
+
}
|
|
54
|
+
end
|
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
// Public `MessagingClient` API for `ping-openmls-sdk-react-native-macos`. Mirrors the
|
|
2
|
+
// shape of `ping-openmls-sdk`'s web SDK exactly so a desktop app can swap between the
|
|
3
|
+
// two with a single import change.
|
|
4
|
+
//
|
|
5
|
+
// Stage 4b surface: `init`, `userId`, `deviceId`. Stages 4c–4f add the rest.
|
|
6
|
+
//
|
|
7
|
+
// Spec: docs/RN_NATIVE_BINDINGS.md (stage 4b).
|
|
8
|
+
|
|
9
|
+
import { NativeEventEmitter, NativeModules } from "react-native";
|
|
10
|
+
import NativePing from "./NativePing";
|
|
11
|
+
import { connectStorageBridge, type Storage } from "./storage-bridge";
|
|
12
|
+
import { connectTransportBridge, type Transport } from "./transport-bridge";
|
|
13
|
+
|
|
14
|
+
export interface ClientConfig {
|
|
15
|
+
/**
|
|
16
|
+
* Identity export bytes. For a fresh device, generate via
|
|
17
|
+
* `MessagingClient.generateIdentity()`. For a returning device, load from secure
|
|
18
|
+
* storage (the bytes carry the long-term identity material).
|
|
19
|
+
*/
|
|
20
|
+
identityExport: Uint8Array;
|
|
21
|
+
/** Human-readable label for this device (shown to users in device lists). */
|
|
22
|
+
deviceLabel: string;
|
|
23
|
+
/** Caller's storage backend. Wired into the native bridge during `init`. */
|
|
24
|
+
storage: Storage;
|
|
25
|
+
/** Caller's transport backend. Wired into the native bridge during `init`. */
|
|
26
|
+
transport: Transport;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* High-level facade over the native MessagingClient. Methods marshal arguments + return
|
|
31
|
+
* values across the JS↔Swift bridge; the actual MLS work happens in Rust.
|
|
32
|
+
*/
|
|
33
|
+
export class MessagingClient {
|
|
34
|
+
/** Subscribers for incoming application messages (stage 4e). */
|
|
35
|
+
private readonly messageListeners = new Set<(msg: IncomingMessage) => void>();
|
|
36
|
+
/** Subscribers for conversation epoch changes (stage 4e). */
|
|
37
|
+
private readonly convUpdateListeners = new Set<(id: Uint8Array, epoch: number) => void>();
|
|
38
|
+
/** Native event subscriptions held while the client is alive. */
|
|
39
|
+
private observerSubscriptions: Array<{ remove(): void }> = [];
|
|
40
|
+
/** Transport's live-receive subscription, populated by init() if transport.subscribe exists. */
|
|
41
|
+
private transportSubscription?: { unsubscribe(): void };
|
|
42
|
+
|
|
43
|
+
private constructor(
|
|
44
|
+
private readonly disconnectStorage: () => void,
|
|
45
|
+
private readonly disconnectTransport: () => void,
|
|
46
|
+
) {}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Construct a MessagingClient. Wires up the storage + transport bridges, then asks the
|
|
50
|
+
* native module to instantiate the underlying UniFFI handle.
|
|
51
|
+
*/
|
|
52
|
+
static async init(cfg: ClientConfig): Promise<MessagingClient> {
|
|
53
|
+
// Connect bridges BEFORE calling native init — Rust's `MessagingClient::init` reads
|
|
54
|
+
// from storage during construction, so the JS handlers must be subscribed first.
|
|
55
|
+
const disconnectStorage = connectStorageBridge(cfg.storage);
|
|
56
|
+
const disconnectTransport = connectTransportBridge(cfg.transport);
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const identityB64 = bytesToBase64(cfg.identityExport);
|
|
60
|
+
await NativePing.initClient(identityB64, cfg.deviceLabel, Date.now());
|
|
61
|
+
} catch (e) {
|
|
62
|
+
// Init failed — disconnect the bridges so we don't leak event listeners.
|
|
63
|
+
disconnectStorage();
|
|
64
|
+
disconnectTransport();
|
|
65
|
+
throw e;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const client = new MessagingClient(disconnectStorage, disconnectTransport);
|
|
69
|
+
|
|
70
|
+
// Install the observer bridge so native can fire application-message and
|
|
71
|
+
// conversation-updated events. Failure here is non-fatal — the client still works
|
|
72
|
+
// for explicit `processEnvelope` / `syncConversations` calls.
|
|
73
|
+
try {
|
|
74
|
+
await client.installObserver();
|
|
75
|
+
} catch (e) {
|
|
76
|
+
// eslint-disable-next-line no-console
|
|
77
|
+
console.warn("[ping-openmls-sdk-react-native-macos] observer install failed:", e);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Auto-subscribe transport's live receive path. Dispatch on envelope kind:
|
|
81
|
+
// - Welcome → joinConversation (the conv is, by definition, unknown until we join)
|
|
82
|
+
// - Everything else → processEnvelope
|
|
83
|
+
// Both can fail silently — Welcomes are broadcast to all WS clients (only the
|
|
84
|
+
// recipient device succeeds), and non-Welcomes for unknown convs are
|
|
85
|
+
// foreign traffic we drop.
|
|
86
|
+
//
|
|
87
|
+
// Rust's `join_conversation` does NOT fire `on_conversation_updated` for the new
|
|
88
|
+
// conv (the observer is only invoked from `process_envelope` on epoch advances).
|
|
89
|
+
// We emit it manually here so consumers using `client.onConversationUpdated`
|
|
90
|
+
// see the join — matching what the web SDK's worker does.
|
|
91
|
+
if (cfg.transport.subscribe) {
|
|
92
|
+
const sub = cfg.transport.subscribe((envelopeJson) => {
|
|
93
|
+
void (async () => {
|
|
94
|
+
const kind = envelopeJson.kind;
|
|
95
|
+
// eslint-disable-next-line no-console
|
|
96
|
+
console.log("[ws-subscribe] received kind=" + String(kind));
|
|
97
|
+
try {
|
|
98
|
+
if (kind === "Welcome") {
|
|
99
|
+
const c = await client.joinConversation(envelopeJson);
|
|
100
|
+
// eslint-disable-next-line no-console
|
|
101
|
+
console.log("[ws-subscribe] joined conv id=" +
|
|
102
|
+
Array.from(c.id).map((b) => b.toString(16).padStart(2, "0")).join(""));
|
|
103
|
+
client.fireConvUpdated(c.id, 0);
|
|
104
|
+
} else {
|
|
105
|
+
await client.processEnvelope(envelopeJson);
|
|
106
|
+
}
|
|
107
|
+
} catch (e) {
|
|
108
|
+
// Expected for foreign Welcomes (other device's KP) or unknown-conv traffic.
|
|
109
|
+
// Logged for diagnostic value during stage 5 — drop to silent in production.
|
|
110
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
111
|
+
// eslint-disable-next-line no-console
|
|
112
|
+
console.log("[ws-subscribe] " + String(kind) + " dropped: " + msg.slice(0, 80));
|
|
113
|
+
}
|
|
114
|
+
})();
|
|
115
|
+
});
|
|
116
|
+
client.transportSubscription = sub;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return client;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Manually fire the `onConversationUpdated` listeners. Used by the auto-subscribe
|
|
124
|
+
* path to surface joins (since Rust's `join_conversation` doesn't trigger the
|
|
125
|
+
* observer).
|
|
126
|
+
*/
|
|
127
|
+
private fireConvUpdated(id: Uint8Array, epoch: number): void {
|
|
128
|
+
for (const cb of this.convUpdateListeners) cb(id, epoch);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private async installObserver(): Promise<void> {
|
|
132
|
+
await NativePing.setObserver();
|
|
133
|
+
|
|
134
|
+
const emitter = new NativeEventEmitter(NativeModules.PingNative);
|
|
135
|
+
this.observerSubscriptions.push(
|
|
136
|
+
emitter.addListener("PingApplicationMessage", (raw: Record<string, unknown>) => {
|
|
137
|
+
const msg = decodeIncomingMessage(raw);
|
|
138
|
+
for (const cb of this.messageListeners) cb(msg);
|
|
139
|
+
}),
|
|
140
|
+
emitter.addListener(
|
|
141
|
+
"PingConversationUpdated",
|
|
142
|
+
(raw: { conversation_id?: number[]; new_epoch?: number }) => {
|
|
143
|
+
const id = bytesFrom(raw.conversation_id);
|
|
144
|
+
const epoch = Number(raw.new_epoch ?? 0);
|
|
145
|
+
for (const cb of this.convUpdateListeners) cb(id, epoch);
|
|
146
|
+
},
|
|
147
|
+
),
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Generate a fresh identity. Returns the bytes that should be persisted by the caller
|
|
153
|
+
* and passed back as `identityExport` on subsequent launches.
|
|
154
|
+
*
|
|
155
|
+
* Calls into Rust via UniFFI's `generateIdentityExport()` free function — the bytes
|
|
156
|
+
* are a properly-formatted Identity export (Ed25519 keypair + metadata, CBOR-encoded),
|
|
157
|
+
* not raw randomness. Persist them to secure storage; never log.
|
|
158
|
+
*/
|
|
159
|
+
static async generateIdentity(): Promise<Uint8Array> {
|
|
160
|
+
const b64 = await NativePing.generateIdentityExport();
|
|
161
|
+
return base64ToBytes(b64);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** This client's user id. Stable across launches for a given identity export. */
|
|
165
|
+
async userId(): Promise<Uint8Array> {
|
|
166
|
+
const hex = await NativePing.getUserId();
|
|
167
|
+
return hexToBytes(hex);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** This client's device id. Stable across launches for a given identity + device label. */
|
|
171
|
+
async deviceId(): Promise<Uint8Array> {
|
|
172
|
+
const hex = await NativePing.getDeviceId();
|
|
173
|
+
return hexToBytes(hex);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Generate a fresh KeyPackage. Peers retrieve this (via the relay's directory or a
|
|
178
|
+
* join-code endpoint) and use it to add this device to their conversations.
|
|
179
|
+
*/
|
|
180
|
+
async freshKeyPackage(): Promise<Uint8Array> {
|
|
181
|
+
const arr = await NativePing.freshKeyPackage();
|
|
182
|
+
return Uint8Array.from(arr);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Create a new conversation. Returns a `Conversation` handle. */
|
|
186
|
+
async createConversation(opts?: { name?: string | null }): Promise<Conversation> {
|
|
187
|
+
const idArr = await NativePing.createConversation(opts?.name ?? null, Date.now());
|
|
188
|
+
return new Conversation(this, Uint8Array.from(idArr));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Re-bind to an existing conversation by id (e.g. after a Welcome). */
|
|
192
|
+
getConversation(id: Uint8Array): Conversation {
|
|
193
|
+
return new Conversation(this, id);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Join a conversation from a Welcome envelope. Used by the auto-subscribe path in
|
|
198
|
+
* `init()` — when the transport's live stream delivers a Welcome and we don't yet
|
|
199
|
+
* track that conversation, we route to this method instead of `processEnvelope`.
|
|
200
|
+
*
|
|
201
|
+
* The relay broadcasts Welcomes to every connected device; only the device whose
|
|
202
|
+
* KeyPackage was used succeeds. Other devices throw an MLS-level error which the
|
|
203
|
+
* caller silently drops (they're "not for us").
|
|
204
|
+
*/
|
|
205
|
+
async joinConversation(welcomeJson: Record<string, unknown>): Promise<Conversation> {
|
|
206
|
+
const idArr = await NativePing.joinConversation(welcomeJson, Date.now());
|
|
207
|
+
return new Conversation(this, Uint8Array.from(idArr));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Process an incoming envelope from the transport. Hosts that subscribe to the
|
|
212
|
+
* transport's live stream forward each frame here; returns the decrypted
|
|
213
|
+
* `IncomingMessage` for application traffic, or `null` for handshake messages
|
|
214
|
+
* (Commit/Welcome/Proposal — those advance MLS state without a user-visible payload).
|
|
215
|
+
*/
|
|
216
|
+
async processEnvelope(envelope: Record<string, unknown>): Promise<IncomingMessage | null> {
|
|
217
|
+
const result = await NativePing.processEnvelope(envelope, Date.now());
|
|
218
|
+
return result ? decodeIncomingMessage(result) : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Catch-up sync — pull missed envelopes from the transport's `fetchSince` for every
|
|
223
|
+
* open conversation, apply them, and return the decrypted application messages in
|
|
224
|
+
* apply order.
|
|
225
|
+
*/
|
|
226
|
+
async syncConversations(): Promise<IncomingMessage[]> {
|
|
227
|
+
const arr = await NativePing.syncConversations(Date.now());
|
|
228
|
+
return arr.map(decodeIncomingMessage);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Conversation metadata for every open conversation. */
|
|
232
|
+
async listConversations(): Promise<ConversationMeta[]> {
|
|
233
|
+
const arr = await NativePing.listConversations();
|
|
234
|
+
return arr.map(decodeConversationMeta);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Device info for every known device. v0.1: just the local device. */
|
|
238
|
+
async listDevices(): Promise<DeviceInfo[]> {
|
|
239
|
+
const arr = await NativePing.listDevices();
|
|
240
|
+
return arr.map(decodeDeviceInfo);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Subscribe to incoming application messages. Returns an unsubscribe function.
|
|
245
|
+
* Fires for every decrypted application payload — handshake messages
|
|
246
|
+
* (Welcome/Commit/Proposal) instead fire `onConversationUpdated`.
|
|
247
|
+
*/
|
|
248
|
+
onMessage(callback: (msg: IncomingMessage) => void): () => void {
|
|
249
|
+
this.messageListeners.add(callback);
|
|
250
|
+
return () => this.messageListeners.delete(callback);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Subscribe to conversation epoch changes. Fires when a Welcome lands (new conv) or
|
|
255
|
+
* a Commit advances an existing conv to a new epoch.
|
|
256
|
+
*/
|
|
257
|
+
onConversationUpdated(callback: (id: Uint8Array, newEpoch: number) => void): () => void {
|
|
258
|
+
this.convUpdateListeners.add(callback);
|
|
259
|
+
return () => this.convUpdateListeners.delete(callback);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Build a linking ticket for a new device. The new device has just published its
|
|
264
|
+
* KeyPackage (`newDeviceKp`); this builds an HPKE-wrapped Welcome to the user's
|
|
265
|
+
* DeviceGroup plus a catch-up snapshot. The new device consumes via
|
|
266
|
+
* `consumeLinkingTicket`.
|
|
267
|
+
*/
|
|
268
|
+
async buildLinkingTicket(
|
|
269
|
+
newDeviceId: Uint8Array,
|
|
270
|
+
newDeviceKp: Uint8Array,
|
|
271
|
+
): Promise<LinkingTicket> {
|
|
272
|
+
const raw = await NativePing.buildLinkingTicket(
|
|
273
|
+
Array.from(newDeviceId),
|
|
274
|
+
Array.from(newDeviceKp),
|
|
275
|
+
Date.now(),
|
|
276
|
+
);
|
|
277
|
+
return decodeLinkingTicket(raw);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Consume a linking ticket on the new device. Joins the DeviceGroup. */
|
|
281
|
+
async consumeLinkingTicket(ticket: LinkingTicket): Promise<void> {
|
|
282
|
+
await NativePing.consumeLinkingTicket(encodeLinkingTicket(ticket), Date.now());
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Revoke a device — issues Remove proposals across the user's groups. */
|
|
286
|
+
async revokeDevice(deviceId: Uint8Array): Promise<void> {
|
|
287
|
+
await NativePing.revokeDevice(Array.from(deviceId), Date.now());
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Detach storage + transport + observer listeners. Call when you're done. */
|
|
291
|
+
close(): void {
|
|
292
|
+
this.transportSubscription?.unsubscribe();
|
|
293
|
+
this.transportSubscription = undefined;
|
|
294
|
+
for (const sub of this.observerSubscriptions) sub.remove();
|
|
295
|
+
this.observerSubscriptions = [];
|
|
296
|
+
this.messageListeners.clear();
|
|
297
|
+
this.convUpdateListeners.clear();
|
|
298
|
+
this.disconnectStorage();
|
|
299
|
+
this.disconnectTransport();
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Decrypted incoming message — application-layer payload + metadata. */
|
|
304
|
+
export interface IncomingMessage {
|
|
305
|
+
conversationId: Uint8Array;
|
|
306
|
+
senderDevice: Uint8Array;
|
|
307
|
+
epoch: number;
|
|
308
|
+
hlc: { wallMs: number; logical: number };
|
|
309
|
+
plaintext: Uint8Array;
|
|
310
|
+
contentHash: Uint8Array;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Conversation metadata returned by `listConversations`. */
|
|
314
|
+
export interface ConversationMeta {
|
|
315
|
+
id: Uint8Array;
|
|
316
|
+
name: string | null;
|
|
317
|
+
epoch: number;
|
|
318
|
+
memberCount: number;
|
|
319
|
+
isDeviceGroup: boolean;
|
|
320
|
+
createdAtMs: number;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Device metadata returned by `listDevices`. */
|
|
324
|
+
export interface DeviceInfo {
|
|
325
|
+
deviceId: Uint8Array;
|
|
326
|
+
userId: Uint8Array;
|
|
327
|
+
label: string;
|
|
328
|
+
createdAtMs: number;
|
|
329
|
+
lastSeenMs: number;
|
|
330
|
+
revoked: boolean;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Linking ticket — bundle a new device's join package for an existing device to sign. */
|
|
334
|
+
export interface LinkingTicket {
|
|
335
|
+
v: number;
|
|
336
|
+
userId: Uint8Array;
|
|
337
|
+
userPubkey: Uint8Array;
|
|
338
|
+
newDeviceId: Uint8Array;
|
|
339
|
+
deviceBindingSig: Uint8Array;
|
|
340
|
+
deviceGroupWelcome: Uint8Array;
|
|
341
|
+
catchupSnapshot: Uint8Array;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function decodeIncomingMessage(d: Record<string, unknown>): IncomingMessage {
|
|
345
|
+
const hlc = d.hlc as { wall_ms?: number; logical?: number } | undefined;
|
|
346
|
+
return {
|
|
347
|
+
conversationId: bytesFrom(d.conversation_id),
|
|
348
|
+
senderDevice: bytesFrom(d.sender_device),
|
|
349
|
+
epoch: Number(d.epoch ?? 0),
|
|
350
|
+
hlc: { wallMs: hlc?.wall_ms ?? 0, logical: hlc?.logical ?? 0 },
|
|
351
|
+
plaintext: bytesFrom(d.plaintext),
|
|
352
|
+
contentHash: bytesFrom(d.content_hash),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function decodeConversationMeta(d: Record<string, unknown>): ConversationMeta {
|
|
357
|
+
return {
|
|
358
|
+
id: bytesFrom(d.id),
|
|
359
|
+
name: (d.name as string | null) ?? null,
|
|
360
|
+
epoch: Number(d.epoch ?? 0),
|
|
361
|
+
memberCount: Number(d.member_count ?? 0),
|
|
362
|
+
isDeviceGroup: Boolean(d.is_device_group),
|
|
363
|
+
createdAtMs: Number(d.created_at_ms ?? 0),
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function decodeDeviceInfo(d: Record<string, unknown>): DeviceInfo {
|
|
368
|
+
return {
|
|
369
|
+
deviceId: bytesFrom(d.device_id),
|
|
370
|
+
userId: bytesFrom(d.user_id),
|
|
371
|
+
label: String(d.label ?? ""),
|
|
372
|
+
createdAtMs: Number(d.created_at_ms ?? 0),
|
|
373
|
+
lastSeenMs: Number(d.last_seen_ms ?? 0),
|
|
374
|
+
revoked: Boolean(d.revoked),
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function decodeLinkingTicket(d: Record<string, unknown>): LinkingTicket {
|
|
379
|
+
return {
|
|
380
|
+
v: Number(d.v ?? 1),
|
|
381
|
+
userId: bytesFrom(d.user_id),
|
|
382
|
+
userPubkey: bytesFrom(d.user_pubkey),
|
|
383
|
+
newDeviceId: bytesFrom(d.new_device_id),
|
|
384
|
+
deviceBindingSig: bytesFrom(d.device_binding_sig),
|
|
385
|
+
deviceGroupWelcome: bytesFrom(d.device_group_welcome),
|
|
386
|
+
catchupSnapshot: bytesFrom(d.catchup_snapshot),
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function encodeLinkingTicket(t: LinkingTicket): Record<string, unknown> {
|
|
391
|
+
return {
|
|
392
|
+
v: t.v,
|
|
393
|
+
user_id: Array.from(t.userId),
|
|
394
|
+
user_pubkey: Array.from(t.userPubkey),
|
|
395
|
+
new_device_id: Array.from(t.newDeviceId),
|
|
396
|
+
device_binding_sig: Array.from(t.deviceBindingSig),
|
|
397
|
+
device_group_welcome: Array.from(t.deviceGroupWelcome),
|
|
398
|
+
catchup_snapshot: Array.from(t.catchupSnapshot),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function bytesFrom(v: unknown): Uint8Array {
|
|
403
|
+
if (Array.isArray(v)) return Uint8Array.from(v as number[]);
|
|
404
|
+
return new Uint8Array(0);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Scoped handle for a single conversation. Mirrors `ping-openmls-sdk`'s `Conversation`
|
|
409
|
+
* class so the desktop demo can swap implementations with a single import change.
|
|
410
|
+
*/
|
|
411
|
+
export class Conversation {
|
|
412
|
+
constructor(
|
|
413
|
+
private readonly client: MessagingClient,
|
|
414
|
+
public readonly id: Uint8Array,
|
|
415
|
+
) {}
|
|
416
|
+
|
|
417
|
+
/** Send an application message. Returns the wire envelope; the SDK has already pushed it through the transport. */
|
|
418
|
+
async send(plaintext: Uint8Array): Promise<Record<string, unknown>> {
|
|
419
|
+
return await NativePing.sendMessage(
|
|
420
|
+
Array.from(this.id),
|
|
421
|
+
Array.from(plaintext),
|
|
422
|
+
Date.now(),
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Add members by their KeyPackage bytes (typically obtained via the relay's directory). */
|
|
427
|
+
async addMembers(keyPackages: Uint8Array[]): Promise<void> {
|
|
428
|
+
await NativePing.addMembers(
|
|
429
|
+
Array.from(this.id),
|
|
430
|
+
keyPackages.map((kp) => Array.from(kp)),
|
|
431
|
+
Date.now(),
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Remove members by leaf index in the conversation's MLS ratchet tree. */
|
|
436
|
+
async removeMembers(leafIndexes: number[]): Promise<void> {
|
|
437
|
+
await NativePing.removeMembers(Array.from(this.id), leafIndexes, Date.now());
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ---------- helpers ----------
|
|
442
|
+
|
|
443
|
+
// Base64 alphabet — standard, with `+/` and `=` padding.
|
|
444
|
+
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
445
|
+
|
|
446
|
+
function bytesToBase64(b: Uint8Array): string {
|
|
447
|
+
// Hermes doesn't ship `btoa`/`Buffer`; this is a small pure-JS encoder. Safe for any
|
|
448
|
+
// size; we only encode ~32B identity exports here so performance is not a concern.
|
|
449
|
+
let out = "";
|
|
450
|
+
let i = 0;
|
|
451
|
+
for (; i + 3 <= b.length; i += 3) {
|
|
452
|
+
const x = (b[i] << 16) | (b[i + 1] << 8) | b[i + 2];
|
|
453
|
+
out += B64[(x >> 18) & 63] + B64[(x >> 12) & 63] + B64[(x >> 6) & 63] + B64[x & 63];
|
|
454
|
+
}
|
|
455
|
+
const rem = b.length - i;
|
|
456
|
+
if (rem === 1) {
|
|
457
|
+
const x = b[i] << 16;
|
|
458
|
+
out += B64[(x >> 18) & 63] + B64[(x >> 12) & 63] + "==";
|
|
459
|
+
} else if (rem === 2) {
|
|
460
|
+
const x = (b[i] << 16) | (b[i + 1] << 8);
|
|
461
|
+
out += B64[(x >> 18) & 63] + B64[(x >> 12) & 63] + B64[(x >> 6) & 63] + "=";
|
|
462
|
+
}
|
|
463
|
+
return out;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function hexToBytes(s: string): Uint8Array {
|
|
467
|
+
if (s.length % 2 !== 0) throw new Error("odd-length hex");
|
|
468
|
+
const out = new Uint8Array(s.length / 2);
|
|
469
|
+
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);
|
|
470
|
+
return out;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Decode base64 to bytes. Mirror of bytesToBase64; same Hermes-compatible reasoning. */
|
|
474
|
+
function base64ToBytes(s: string): Uint8Array {
|
|
475
|
+
const clean = s.replace(/[^A-Za-z0-9+/]/g, "");
|
|
476
|
+
const padded = clean.length % 4 === 2 ? clean + "==" : clean.length % 4 === 3 ? clean + "=" : clean;
|
|
477
|
+
const out: number[] = [];
|
|
478
|
+
for (let i = 0; i < padded.length; i += 4) {
|
|
479
|
+
const a = B64.indexOf(padded[i]);
|
|
480
|
+
const b = B64.indexOf(padded[i + 1]);
|
|
481
|
+
const c = padded[i + 2] === "=" ? -1 : B64.indexOf(padded[i + 2]);
|
|
482
|
+
const d = padded[i + 3] === "=" ? -1 : B64.indexOf(padded[i + 3]);
|
|
483
|
+
out.push((a << 2) | (b >> 4));
|
|
484
|
+
if (c >= 0) out.push(((b & 15) << 4) | (c >> 2));
|
|
485
|
+
if (d >= 0) out.push(((c & 3) << 6) | d);
|
|
486
|
+
}
|
|
487
|
+
return Uint8Array.from(out);
|
|
488
|
+
}
|