@meshwhisper/sdk 0.1.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/README.md +138 -0
- package/dist/browser/index.d.ts +4 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +19 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/chaff/index.d.ts +91 -0
- package/dist/chaff/index.d.ts.map +1 -0
- package/dist/chaff/index.js +268 -0
- package/dist/chaff/index.js.map +1 -0
- package/dist/cluster/index.d.ts +159 -0
- package/dist/cluster/index.d.ts.map +1 -0
- package/dist/cluster/index.js +393 -0
- package/dist/cluster/index.js.map +1 -0
- package/dist/compliance/index.d.ts +129 -0
- package/dist/compliance/index.d.ts.map +1 -0
- package/dist/compliance/index.js +315 -0
- package/dist/compliance/index.js.map +1 -0
- package/dist/crypto/index.d.ts +65 -0
- package/dist/crypto/index.d.ts.map +1 -0
- package/dist/crypto/index.js +146 -0
- package/dist/crypto/index.js.map +1 -0
- package/dist/group/index.d.ts +155 -0
- package/dist/group/index.d.ts.map +1 -0
- package/dist/group/index.js +560 -0
- package/dist/group/index.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/namespace/index.d.ts +155 -0
- package/dist/namespace/index.d.ts.map +1 -0
- package/dist/namespace/index.js +278 -0
- package/dist/namespace/index.js.map +1 -0
- package/dist/node/index.d.ts +4 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +19 -0
- package/dist/node/index.js.map +1 -0
- package/dist/packet/index.d.ts +63 -0
- package/dist/packet/index.d.ts.map +1 -0
- package/dist/packet/index.js +244 -0
- package/dist/packet/index.js.map +1 -0
- package/dist/permissions/index.d.ts +107 -0
- package/dist/permissions/index.d.ts.map +1 -0
- package/dist/permissions/index.js +282 -0
- package/dist/permissions/index.js.map +1 -0
- package/dist/persistence/idb-storage.d.ts +27 -0
- package/dist/persistence/idb-storage.d.ts.map +1 -0
- package/dist/persistence/idb-storage.js +75 -0
- package/dist/persistence/idb-storage.js.map +1 -0
- package/dist/persistence/index.d.ts +4 -0
- package/dist/persistence/index.d.ts.map +1 -0
- package/dist/persistence/index.js +3 -0
- package/dist/persistence/index.js.map +1 -0
- package/dist/persistence/node-storage.d.ts +33 -0
- package/dist/persistence/node-storage.d.ts.map +1 -0
- package/dist/persistence/node-storage.js +90 -0
- package/dist/persistence/node-storage.js.map +1 -0
- package/dist/persistence/serialization.d.ts +4 -0
- package/dist/persistence/serialization.d.ts.map +1 -0
- package/dist/persistence/serialization.js +49 -0
- package/dist/persistence/serialization.js.map +1 -0
- package/dist/persistence/types.d.ts +29 -0
- package/dist/persistence/types.d.ts.map +1 -0
- package/dist/persistence/types.js +5 -0
- package/dist/persistence/types.js.map +1 -0
- package/dist/ratchet/index.d.ts +80 -0
- package/dist/ratchet/index.d.ts.map +1 -0
- package/dist/ratchet/index.js +259 -0
- package/dist/ratchet/index.js.map +1 -0
- package/dist/reciprocity/index.d.ts +109 -0
- package/dist/reciprocity/index.d.ts.map +1 -0
- package/dist/reciprocity/index.js +311 -0
- package/dist/reciprocity/index.js.map +1 -0
- package/dist/relay/index.d.ts +87 -0
- package/dist/relay/index.d.ts.map +1 -0
- package/dist/relay/index.js +286 -0
- package/dist/relay/index.js.map +1 -0
- package/dist/routing/index.d.ts +136 -0
- package/dist/routing/index.d.ts.map +1 -0
- package/dist/routing/index.js +478 -0
- package/dist/routing/index.js.map +1 -0
- package/dist/sdk/index.d.ts +322 -0
- package/dist/sdk/index.d.ts.map +1 -0
- package/dist/sdk/index.js +1530 -0
- package/dist/sdk/index.js.map +1 -0
- package/dist/sybil/index.d.ts +123 -0
- package/dist/sybil/index.d.ts.map +1 -0
- package/dist/sybil/index.js +491 -0
- package/dist/sybil/index.js.map +1 -0
- package/dist/transport/browser/index.d.ts +34 -0
- package/dist/transport/browser/index.d.ts.map +1 -0
- package/dist/transport/browser/index.js +176 -0
- package/dist/transport/browser/index.js.map +1 -0
- package/dist/transport/local/index.d.ts +57 -0
- package/dist/transport/local/index.d.ts.map +1 -0
- package/dist/transport/local/index.js +442 -0
- package/dist/transport/local/index.js.map +1 -0
- package/dist/transport/negotiator/index.d.ts +79 -0
- package/dist/transport/negotiator/index.d.ts.map +1 -0
- package/dist/transport/negotiator/index.js +289 -0
- package/dist/transport/negotiator/index.js.map +1 -0
- package/dist/transport/node/index.d.ts +56 -0
- package/dist/transport/node/index.d.ts.map +1 -0
- package/dist/transport/node/index.js +209 -0
- package/dist/transport/node/index.js.map +1 -0
- package/dist/transport/noop/index.d.ts +11 -0
- package/dist/transport/noop/index.d.ts.map +1 -0
- package/dist/transport/noop/index.js +20 -0
- package/dist/transport/noop/index.js.map +1 -0
- package/dist/transport/p2p/index.d.ts +109 -0
- package/dist/transport/p2p/index.d.ts.map +1 -0
- package/dist/transport/p2p/index.js +237 -0
- package/dist/transport/p2p/index.js.map +1 -0
- package/dist/transport/websocket/index.d.ts +89 -0
- package/dist/transport/websocket/index.d.ts.map +1 -0
- package/dist/transport/websocket/index.js +498 -0
- package/dist/transport/websocket/index.js.map +1 -0
- package/dist/transport/websocket/serialize.d.ts +5 -0
- package/dist/transport/websocket/serialize.d.ts.map +1 -0
- package/dist/transport/websocket/serialize.js +55 -0
- package/dist/transport/websocket/serialize.js.map +1 -0
- package/dist/types.d.ts +215 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +15 -0
- package/dist/types.js.map +1 -0
- package/dist/x3dh/index.d.ts +120 -0
- package/dist/x3dh/index.d.ts.map +1 -0
- package/dist/x3dh/index.js +290 -0
- package/dist/x3dh/index.js.map +1 -0
- package/package.json +59 -0
- package/src/browser/index.ts +19 -0
- package/src/chaff/index.ts +340 -0
- package/src/cluster/index.ts +482 -0
- package/src/compliance/index.ts +407 -0
- package/src/crypto/index.ts +193 -0
- package/src/group/index.ts +719 -0
- package/src/index.ts +87 -0
- package/src/lz4js.d.ts +58 -0
- package/src/namespace/index.ts +336 -0
- package/src/node/index.ts +19 -0
- package/src/packet/index.ts +326 -0
- package/src/permissions/index.ts +405 -0
- package/src/persistence/idb-storage.ts +83 -0
- package/src/persistence/index.ts +3 -0
- package/src/persistence/node-storage.ts +96 -0
- package/src/persistence/serialization.ts +75 -0
- package/src/persistence/types.ts +33 -0
- package/src/ratchet/index.ts +363 -0
- package/src/reciprocity/index.ts +371 -0
- package/src/relay/index.ts +382 -0
- package/src/routing/index.ts +577 -0
- package/src/sdk/index.ts +1994 -0
- package/src/sybil/index.ts +661 -0
- package/src/transport/browser/index.ts +201 -0
- package/src/transport/local/index.ts +540 -0
- package/src/transport/negotiator/index.ts +397 -0
- package/src/transport/node/index.ts +234 -0
- package/src/transport/noop/index.ts +22 -0
- package/src/transport/p2p/index.ts +345 -0
- package/src/transport/websocket/index.ts +660 -0
- package/src/transport/websocket/serialize.ts +68 -0
- package/src/types.ts +275 -0
- package/src/x3dh/index.ts +388 -0
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// MeshWhisper SDK — Device Clustering Module
|
|
3
|
+
// Manages device self-clustering so a user's devices form a
|
|
4
|
+
// personal availability cluster (PRD §5.6). The most capable
|
|
5
|
+
// device acts as primary receiver; messages sync across the
|
|
6
|
+
// cluster over local connections when connectivity allows.
|
|
7
|
+
// ============================================================
|
|
8
|
+
|
|
9
|
+
import { blake3 } from '@noble/hashes/blake3';
|
|
10
|
+
import type { ClusterDevice, DeviceCapability, BatteryState, RelayWillingness } from '../types.js';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Constants
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
/** Domain separator for cluster key derivation. */
|
|
17
|
+
const CLUSTER_KEY_DOMAIN = 'meshwhisper-cluster-v1';
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Scoring tables for primary election
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
const BATTERY_SCORE: Record<BatteryState, number> = {
|
|
24
|
+
charging: 5,
|
|
25
|
+
high: 4,
|
|
26
|
+
medium: 3,
|
|
27
|
+
low: 2,
|
|
28
|
+
critical: 1,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const CONNECTIVITY_SCORE = {
|
|
32
|
+
internet: 3,
|
|
33
|
+
local_net: 2,
|
|
34
|
+
platform_p2p: 1,
|
|
35
|
+
none: 0,
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
const RELAY_SCORE: Record<RelayWillingness, number> = {
|
|
39
|
+
eager: 4,
|
|
40
|
+
willing: 3,
|
|
41
|
+
reluctant: 2,
|
|
42
|
+
unavailable: 1,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// SyncMessage
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A message queued for synchronization across the device cluster.
|
|
51
|
+
*/
|
|
52
|
+
export interface SyncMessage {
|
|
53
|
+
messageId: string;
|
|
54
|
+
encryptedPayload: Uint8Array;
|
|
55
|
+
receivedAt: number;
|
|
56
|
+
receivedBy: string;
|
|
57
|
+
syncedTo: Set<string>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// ClusterStatus
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
export interface ClusterStatus {
|
|
65
|
+
deviceCount: number;
|
|
66
|
+
primaryId: string;
|
|
67
|
+
syncPending: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// MessageSyncManager
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Manages the queue of messages waiting to be synchronized to other
|
|
76
|
+
* devices in the cluster.
|
|
77
|
+
*/
|
|
78
|
+
export class MessageSyncManager {
|
|
79
|
+
private readonly messages: Map<string, SyncMessage> = new Map();
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Enqueue a received message for sync to other cluster devices.
|
|
83
|
+
*/
|
|
84
|
+
queueForSync(message: SyncMessage): void {
|
|
85
|
+
if (!message.messageId) {
|
|
86
|
+
throw new TypeError('SyncMessage must have a non-empty messageId');
|
|
87
|
+
}
|
|
88
|
+
// Clone syncedTo so the caller cannot mutate our internal state.
|
|
89
|
+
this.messages.set(message.messageId, {
|
|
90
|
+
...message,
|
|
91
|
+
syncedTo: new Set(message.syncedTo),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Retrieve all messages that have **not** yet been synced to `deviceId`.
|
|
97
|
+
*/
|
|
98
|
+
getUnsyncedMessages(deviceId: string): SyncMessage[] {
|
|
99
|
+
const result: SyncMessage[] = [];
|
|
100
|
+
for (const msg of this.messages.values()) {
|
|
101
|
+
if (!msg.syncedTo.has(deviceId)) {
|
|
102
|
+
result.push(this.cloneMessage(msg));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Mark a single message as synced to a specific device.
|
|
110
|
+
*/
|
|
111
|
+
markSynced(messageId: string, deviceId: string): void {
|
|
112
|
+
const msg = this.messages.get(messageId);
|
|
113
|
+
if (msg) {
|
|
114
|
+
msg.syncedTo.add(deviceId);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Convenience method: returns all unsynced messages for `targetDeviceId`
|
|
120
|
+
* and marks each one as synced to that device in a single pass.
|
|
121
|
+
*/
|
|
122
|
+
sync(targetDeviceId: string): SyncMessage[] {
|
|
123
|
+
const unsynced = this.getUnsyncedMessages(targetDeviceId);
|
|
124
|
+
for (const msg of unsynced) {
|
|
125
|
+
this.markSynced(msg.messageId, targetDeviceId);
|
|
126
|
+
}
|
|
127
|
+
return unsynced;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Total count of messages that still need to be synced to at least one
|
|
132
|
+
* device. Used by `DeviceCluster.getClusterStatus()`.
|
|
133
|
+
*/
|
|
134
|
+
getPendingCount(allDeviceIds: string[]): number {
|
|
135
|
+
let count = 0;
|
|
136
|
+
for (const msg of this.messages.values()) {
|
|
137
|
+
for (const id of allDeviceIds) {
|
|
138
|
+
if (!msg.syncedTo.has(id)) {
|
|
139
|
+
count++;
|
|
140
|
+
break; // only count each message once
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return count;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Remove all tracked messages.
|
|
149
|
+
*/
|
|
150
|
+
clear(): void {
|
|
151
|
+
this.messages.clear();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// -----------------------------------------------------------------------
|
|
155
|
+
// Private helpers
|
|
156
|
+
// -----------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
private cloneMessage(msg: SyncMessage): SyncMessage {
|
|
159
|
+
return {
|
|
160
|
+
...msg,
|
|
161
|
+
syncedTo: new Set(msg.syncedTo),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// DeviceCluster
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Manages a personal device cluster.
|
|
172
|
+
*
|
|
173
|
+
* A cluster groups all of a single user's devices so they can:
|
|
174
|
+
* - elect a primary receiver (best battery/connectivity/willingness)
|
|
175
|
+
* - accept messages on behalf of the user from any member
|
|
176
|
+
* - synchronize messages to all other members when connectivity allows
|
|
177
|
+
*/
|
|
178
|
+
export class DeviceCluster {
|
|
179
|
+
private readonly localDeviceId: string;
|
|
180
|
+
private readonly clusterKey: Uint8Array;
|
|
181
|
+
private readonly devices: Map<string, ClusterDevice> = new Map();
|
|
182
|
+
private primaryDeviceId: string | null = null;
|
|
183
|
+
private running = false;
|
|
184
|
+
|
|
185
|
+
/** Public message sync manager. */
|
|
186
|
+
readonly syncManager: MessageSyncManager = new MessageSyncManager();
|
|
187
|
+
|
|
188
|
+
constructor(identityKey: Uint8Array, localDeviceId: string) {
|
|
189
|
+
if (!localDeviceId) {
|
|
190
|
+
throw new TypeError('localDeviceId must be a non-empty string');
|
|
191
|
+
}
|
|
192
|
+
if (identityKey.length === 0) {
|
|
193
|
+
throw new TypeError('identityKey must not be empty');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
this.localDeviceId = localDeviceId;
|
|
197
|
+
this.clusterKey = DeviceCluster.deriveClusterKey(identityKey);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// -----------------------------------------------------------------------
|
|
201
|
+
// Cluster key derivation
|
|
202
|
+
// -----------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Derive a cluster key from the user's identity private key.
|
|
206
|
+
*
|
|
207
|
+
* clusterKey = BLAKE3(identity_key || "meshwhisper-cluster-v1")
|
|
208
|
+
*/
|
|
209
|
+
static deriveClusterKey(identityPrivateKey: Uint8Array): Uint8Array {
|
|
210
|
+
const domain = new TextEncoder().encode(CLUSTER_KEY_DOMAIN);
|
|
211
|
+
const input = new Uint8Array(identityPrivateKey.length + domain.length);
|
|
212
|
+
input.set(identityPrivateKey, 0);
|
|
213
|
+
input.set(domain, identityPrivateKey.length);
|
|
214
|
+
return blake3(input);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// -----------------------------------------------------------------------
|
|
218
|
+
// Device registration
|
|
219
|
+
// -----------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Register a device in the cluster.
|
|
223
|
+
*/
|
|
224
|
+
addDevice(device: ClusterDevice): void {
|
|
225
|
+
if (!device.deviceId) {
|
|
226
|
+
throw new TypeError('ClusterDevice must have a non-empty deviceId');
|
|
227
|
+
}
|
|
228
|
+
this.devices.set(device.deviceId, { ...device });
|
|
229
|
+
|
|
230
|
+
if (this.running) {
|
|
231
|
+
this.reelectPrimary();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Remove a device from the cluster.
|
|
237
|
+
*/
|
|
238
|
+
removeDevice(deviceId: string): void {
|
|
239
|
+
this.devices.delete(deviceId);
|
|
240
|
+
|
|
241
|
+
if (this.primaryDeviceId === deviceId) {
|
|
242
|
+
this.primaryDeviceId = null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (this.running && this.devices.size > 0) {
|
|
246
|
+
this.reelectPrimary();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Return a snapshot of all devices currently in the cluster.
|
|
252
|
+
*/
|
|
253
|
+
getDevices(): ClusterDevice[] {
|
|
254
|
+
return Array.from(this.devices.values()).map((d) => ({ ...d }));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Return the local device entry.
|
|
259
|
+
*
|
|
260
|
+
* @throws if the local device has not been added to the cluster.
|
|
261
|
+
*/
|
|
262
|
+
getLocalDevice(): ClusterDevice {
|
|
263
|
+
const local = this.devices.get(this.localDeviceId);
|
|
264
|
+
if (!local) {
|
|
265
|
+
throw new Error(
|
|
266
|
+
`Local device "${this.localDeviceId}" has not been added to the cluster`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
return { ...local };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Check whether a device ID belongs to this cluster.
|
|
274
|
+
*/
|
|
275
|
+
isClusterMember(deviceId: string): boolean {
|
|
276
|
+
return this.devices.has(deviceId);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// -----------------------------------------------------------------------
|
|
280
|
+
// Primary receiver election
|
|
281
|
+
// -----------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Elect the most available device as primary receiver.
|
|
285
|
+
*
|
|
286
|
+
* Scoring criteria (in priority order):
|
|
287
|
+
* 1. Battery state (charging > high > medium > low > critical)
|
|
288
|
+
* 2. Connectivity (internet > local_net > platform_p2p)
|
|
289
|
+
* 3. Relay willingness (eager > willing > reluctant > unavailable)
|
|
290
|
+
* 4. Inbound connectable (true preferred)
|
|
291
|
+
*
|
|
292
|
+
* Returns the elected primary device, or throws if the cluster is empty.
|
|
293
|
+
*/
|
|
294
|
+
electPrimary(): ClusterDevice {
|
|
295
|
+
if (this.devices.size === 0) {
|
|
296
|
+
throw new Error('Cannot elect primary: cluster has no devices');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
let best: ClusterDevice | null = null;
|
|
300
|
+
let bestScore = -1;
|
|
301
|
+
|
|
302
|
+
for (const device of this.devices.values()) {
|
|
303
|
+
const score = DeviceCluster.scoreDevice(device);
|
|
304
|
+
if (score > bestScore) {
|
|
305
|
+
bestScore = score;
|
|
306
|
+
best = device;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// best is guaranteed non-null because devices.size > 0
|
|
311
|
+
const elected = best!;
|
|
312
|
+
|
|
313
|
+
// Update isPrimary flags
|
|
314
|
+
for (const device of this.devices.values()) {
|
|
315
|
+
device.isPrimary = device.deviceId === elected.deviceId;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
this.primaryDeviceId = elected.deviceId;
|
|
319
|
+
return { ...elected, isPrimary: true };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Return the current primary device, or `null` if no election has run.
|
|
324
|
+
*/
|
|
325
|
+
getPrimary(): ClusterDevice | null {
|
|
326
|
+
if (this.primaryDeviceId === null) {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
const device = this.devices.get(this.primaryDeviceId);
|
|
330
|
+
return device ? { ...device } : null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Whether the local device is currently the primary receiver.
|
|
335
|
+
*/
|
|
336
|
+
isPrimary(): boolean {
|
|
337
|
+
return this.primaryDeviceId === this.localDeviceId;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// -----------------------------------------------------------------------
|
|
341
|
+
// Capability updates
|
|
342
|
+
// -----------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Update the local device's capabilities and re-elect primary if the
|
|
346
|
+
* cluster is running.
|
|
347
|
+
*/
|
|
348
|
+
updateLocalCapabilities(capabilities: DeviceCapability): void {
|
|
349
|
+
const local = this.devices.get(this.localDeviceId);
|
|
350
|
+
if (!local) {
|
|
351
|
+
throw new Error(
|
|
352
|
+
`Local device "${this.localDeviceId}" has not been added to the cluster`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
local.capabilities = { ...capabilities };
|
|
356
|
+
|
|
357
|
+
if (this.running) {
|
|
358
|
+
this.reelectPrimary();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Handle a capability update broadcast from a remote device.
|
|
364
|
+
*/
|
|
365
|
+
handleCapabilityUpdate(deviceId: string, capabilities: DeviceCapability): void {
|
|
366
|
+
const device = this.devices.get(deviceId);
|
|
367
|
+
if (!device) {
|
|
368
|
+
throw new Error(`Device "${deviceId}" is not a member of this cluster`);
|
|
369
|
+
}
|
|
370
|
+
device.capabilities = { ...capabilities };
|
|
371
|
+
|
|
372
|
+
if (this.running) {
|
|
373
|
+
this.reelectPrimary();
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// -----------------------------------------------------------------------
|
|
378
|
+
// Cluster lifecycle
|
|
379
|
+
// -----------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Start cluster operations. Triggers an initial primary election.
|
|
383
|
+
*/
|
|
384
|
+
start(): void {
|
|
385
|
+
if (this.running) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
this.running = true;
|
|
389
|
+
|
|
390
|
+
if (this.devices.size > 0) {
|
|
391
|
+
this.reelectPrimary();
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Stop cluster operations gracefully.
|
|
397
|
+
*/
|
|
398
|
+
stop(): void {
|
|
399
|
+
if (!this.running) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
this.running = false;
|
|
403
|
+
this.primaryDeviceId = null;
|
|
404
|
+
|
|
405
|
+
// Reset isPrimary on all devices
|
|
406
|
+
for (const device of this.devices.values()) {
|
|
407
|
+
device.isPrimary = false;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Return a summary of the current cluster state.
|
|
413
|
+
*/
|
|
414
|
+
getClusterStatus(): ClusterStatus {
|
|
415
|
+
const deviceIds = Array.from(this.devices.keys());
|
|
416
|
+
return {
|
|
417
|
+
deviceCount: this.devices.size,
|
|
418
|
+
primaryId: this.primaryDeviceId ?? '',
|
|
419
|
+
syncPending: this.syncManager.getPendingCount(deviceIds),
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// -----------------------------------------------------------------------
|
|
424
|
+
// Accessors
|
|
425
|
+
// -----------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
/** The derived cluster key shared by all devices. */
|
|
428
|
+
getClusterKey(): Uint8Array {
|
|
429
|
+
return new Uint8Array(this.clusterKey);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Whether the cluster has been started. */
|
|
433
|
+
isRunning(): boolean {
|
|
434
|
+
return this.running;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// -----------------------------------------------------------------------
|
|
438
|
+
// Static scoring helpers
|
|
439
|
+
// -----------------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Compute an availability score for a device.
|
|
443
|
+
*
|
|
444
|
+
* Higher score = more suitable as primary receiver.
|
|
445
|
+
* The score is a composite of battery, connectivity, relay willingness,
|
|
446
|
+
* and inbound-connectable flag, weighted so battery dominates.
|
|
447
|
+
*/
|
|
448
|
+
static scoreDevice(device: ClusterDevice): number {
|
|
449
|
+
const cap = device.capabilities;
|
|
450
|
+
|
|
451
|
+
const battery = BATTERY_SCORE[cap.batteryState];
|
|
452
|
+
const connectivity = DeviceCluster.connectivityScore(cap);
|
|
453
|
+
const relay = RELAY_SCORE[cap.relayWillingness];
|
|
454
|
+
const inbound = cap.inboundConnectable ? 1 : 0;
|
|
455
|
+
|
|
456
|
+
// Weight battery most heavily so charging/plugged-in devices are
|
|
457
|
+
// strongly preferred, then connectivity, relay, inbound.
|
|
458
|
+
return battery * 1000 + connectivity * 100 + relay * 10 + inbound;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// -----------------------------------------------------------------------
|
|
462
|
+
// Private helpers
|
|
463
|
+
// -----------------------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Compute a connectivity score from capability flags.
|
|
467
|
+
* A device can have multiple bearers; we use the best available.
|
|
468
|
+
*/
|
|
469
|
+
private static connectivityScore(cap: DeviceCapability): number {
|
|
470
|
+
if (cap.bearerInternet) return CONNECTIVITY_SCORE.internet;
|
|
471
|
+
if (cap.bearerLocalNet) return CONNECTIVITY_SCORE.local_net;
|
|
472
|
+
if (cap.bearerPlatformP2P) return CONNECTIVITY_SCORE.platform_p2p;
|
|
473
|
+
return CONNECTIVITY_SCORE.none;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Run a primary election and update internal state.
|
|
478
|
+
*/
|
|
479
|
+
private reelectPrimary(): void {
|
|
480
|
+
this.electPrimary();
|
|
481
|
+
}
|
|
482
|
+
}
|