react-native-ble-mesh 1.1.1 → 2.0.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 +288 -172
- package/docs/IOS-BACKGROUND-BLE.md +231 -0
- package/docs/OPTIMIZATION.md +70 -0
- package/docs/SPEC-v2.1.md +308 -0
- package/package.json +1 -1
- package/src/MeshNetwork.js +659 -465
- package/src/constants/index.js +1 -0
- package/src/crypto/AutoCrypto.js +79 -0
- package/src/crypto/CryptoProvider.js +99 -0
- package/src/crypto/index.js +15 -63
- package/src/crypto/providers/ExpoCryptoProvider.js +125 -0
- package/src/crypto/providers/QuickCryptoProvider.js +134 -0
- package/src/crypto/providers/TweetNaClProvider.js +124 -0
- package/src/crypto/providers/index.js +11 -0
- package/src/errors/MeshError.js +2 -1
- package/src/expo/withBLEMesh.js +102 -0
- package/src/hooks/useMesh.js +30 -9
- package/src/hooks/useMessages.js +2 -0
- package/src/index.js +23 -8
- package/src/mesh/dedup/DedupManager.js +36 -10
- package/src/mesh/fragment/Assembler.js +5 -0
- package/src/mesh/index.js +1 -1
- package/src/mesh/monitor/ConnectionQuality.js +408 -0
- package/src/mesh/monitor/NetworkMonitor.js +327 -316
- package/src/mesh/monitor/index.js +7 -3
- package/src/mesh/peer/PeerManager.js +6 -1
- package/src/mesh/router/MessageRouter.js +26 -15
- package/src/mesh/router/RouteTable.js +7 -1
- package/src/mesh/store/StoreAndForwardManager.js +295 -297
- package/src/mesh/store/index.js +1 -1
- package/src/service/BatteryOptimizer.js +282 -278
- package/src/service/EmergencyManager.js +224 -214
- package/src/service/HandshakeManager.js +167 -13
- package/src/service/MeshService.js +72 -6
- package/src/service/SessionManager.js +77 -2
- package/src/service/audio/AudioManager.js +8 -2
- package/src/service/file/FileAssembler.js +106 -0
- package/src/service/file/FileChunker.js +79 -0
- package/src/service/file/FileManager.js +307 -0
- package/src/service/file/FileMessage.js +122 -0
- package/src/service/file/index.js +15 -0
- package/src/service/text/broadcast/BroadcastManager.js +16 -0
- package/src/transport/BLETransport.js +131 -9
- package/src/transport/MockTransport.js +1 -1
- package/src/transport/MultiTransport.js +305 -0
- package/src/transport/WiFiDirectTransport.js +295 -0
- package/src/transport/adapters/NodeBLEAdapter.js +34 -0
- package/src/transport/adapters/RNBLEAdapter.js +56 -1
- package/src/transport/index.js +6 -0
- package/src/utils/compression.js +291 -291
- package/src/crypto/aead.js +0 -189
- package/src/crypto/chacha20.js +0 -181
- package/src/crypto/hkdf.js +0 -187
- package/src/crypto/hmac.js +0 -143
- package/src/crypto/keys/KeyManager.js +0 -271
- package/src/crypto/keys/KeyPair.js +0 -216
- package/src/crypto/keys/SecureStorage.js +0 -219
- package/src/crypto/keys/index.js +0 -32
- package/src/crypto/noise/handshake.js +0 -410
- package/src/crypto/noise/index.js +0 -27
- package/src/crypto/noise/session.js +0 -253
- package/src/crypto/noise/state.js +0 -268
- package/src/crypto/poly1305.js +0 -113
- package/src/crypto/sha256.js +0 -240
- package/src/crypto/x25519.js +0 -154
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview File transfer manager for mesh network
|
|
5
|
+
* @module service/file/FileManager
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const EventEmitter = require('../../utils/EventEmitter');
|
|
9
|
+
const FileChunker = require('./FileChunker');
|
|
10
|
+
const FileAssembler = require('./FileAssembler');
|
|
11
|
+
const { FileMessage, FILE_TRANSFER_STATE } = require('./FileMessage');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Default file transfer configuration
|
|
15
|
+
* @constant {Object}
|
|
16
|
+
*/
|
|
17
|
+
const DEFAULT_CONFIG = Object.freeze({
|
|
18
|
+
chunkSize: 4096,
|
|
19
|
+
maxFileSize: 10 * 1024 * 1024, // 10MB
|
|
20
|
+
transferTimeoutMs: 5 * 60 * 1000, // 5 minutes
|
|
21
|
+
maxConcurrentTransfers: 5
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Manages file transfers over the mesh network.
|
|
26
|
+
* Handles chunking, reassembly, progress tracking, and timeouts.
|
|
27
|
+
*
|
|
28
|
+
* @class FileManager
|
|
29
|
+
* @extends EventEmitter
|
|
30
|
+
*
|
|
31
|
+
* @fires FileManager#sendProgress - When send progress updates
|
|
32
|
+
* @fires FileManager#receiveProgress - When receive progress updates
|
|
33
|
+
* @fires FileManager#fileReceived - When a complete file is received
|
|
34
|
+
* @fires FileManager#transferFailed - When a transfer fails
|
|
35
|
+
* @fires FileManager#transferCancelled - When a transfer is cancelled
|
|
36
|
+
*/
|
|
37
|
+
class FileManager extends EventEmitter {
|
|
38
|
+
/**
|
|
39
|
+
* @param {Object} [config={}]
|
|
40
|
+
*/
|
|
41
|
+
constructor(config = {}) {
|
|
42
|
+
super();
|
|
43
|
+
this._config = { ...DEFAULT_CONFIG, ...config };
|
|
44
|
+
this._chunker = new FileChunker({
|
|
45
|
+
chunkSize: this._config.chunkSize,
|
|
46
|
+
maxFileSize: this._config.maxFileSize
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/** @type {Map<string, Object>} Active outgoing transfers */
|
|
50
|
+
this._outgoing = new Map();
|
|
51
|
+
/** @type {Map<string, Object>} Active incoming transfers */
|
|
52
|
+
this._incoming = new Map();
|
|
53
|
+
/** @type {Map<string, NodeJS.Timeout>} Transfer timeouts */
|
|
54
|
+
this._timeouts = new Map();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Prepares a file for sending. Returns the transfer object with chunks.
|
|
59
|
+
* The caller (MeshNetwork) is responsible for actually sending chunks via transport.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} peerId - Target peer ID
|
|
62
|
+
* @param {Object} fileInfo - File information
|
|
63
|
+
* @param {Uint8Array} fileInfo.data - File data
|
|
64
|
+
* @param {string} fileInfo.name - File name
|
|
65
|
+
* @param {string} [fileInfo.mimeType='application/octet-stream'] - MIME type
|
|
66
|
+
* @returns {Object} Transfer object with id, offer, and chunks
|
|
67
|
+
*/
|
|
68
|
+
prepareSend(peerId, fileInfo) {
|
|
69
|
+
if (this._outgoing.size >= this._config.maxConcurrentTransfers) {
|
|
70
|
+
throw new Error('Max concurrent transfers reached');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const transferId = `ft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
74
|
+
const chunks = this._chunker.chunk(fileInfo.data, transferId);
|
|
75
|
+
|
|
76
|
+
const fileMeta = new FileMessage({
|
|
77
|
+
id: transferId,
|
|
78
|
+
name: fileInfo.name,
|
|
79
|
+
mimeType: fileInfo.mimeType || 'application/octet-stream',
|
|
80
|
+
size: fileInfo.data.length,
|
|
81
|
+
totalChunks: chunks.length,
|
|
82
|
+
chunkSize: this._chunker.chunkSize
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const transfer = {
|
|
86
|
+
id: transferId,
|
|
87
|
+
peerId,
|
|
88
|
+
meta: fileMeta,
|
|
89
|
+
chunks,
|
|
90
|
+
sentChunks: 0,
|
|
91
|
+
state: FILE_TRANSFER_STATE.PENDING
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
this._outgoing.set(transferId, transfer);
|
|
95
|
+
this._setTransferTimeout(transferId);
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
id: transferId,
|
|
99
|
+
offer: fileMeta.toOffer(),
|
|
100
|
+
chunks,
|
|
101
|
+
totalChunks: chunks.length
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Marks a chunk as sent and emits progress
|
|
107
|
+
* @param {string} transferId - Transfer ID
|
|
108
|
+
* @param {number} chunkIndex - Chunk index that was sent
|
|
109
|
+
*/
|
|
110
|
+
markChunkSent(transferId, _chunkIndex) {
|
|
111
|
+
const transfer = this._outgoing.get(transferId);
|
|
112
|
+
if (!transfer) { return; }
|
|
113
|
+
|
|
114
|
+
transfer.sentChunks++;
|
|
115
|
+
if (!transfer.meta.startedAt) {
|
|
116
|
+
transfer.meta.startedAt = Date.now();
|
|
117
|
+
}
|
|
118
|
+
transfer.state = FILE_TRANSFER_STATE.TRANSFERRING;
|
|
119
|
+
|
|
120
|
+
const progress = Math.round((transfer.sentChunks / transfer.chunks.length) * 100);
|
|
121
|
+
this.emit('sendProgress', {
|
|
122
|
+
transferId,
|
|
123
|
+
peerId: transfer.peerId,
|
|
124
|
+
name: transfer.meta.name,
|
|
125
|
+
percent: progress
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (transfer.sentChunks >= transfer.chunks.length) {
|
|
129
|
+
transfer.state = FILE_TRANSFER_STATE.COMPLETE;
|
|
130
|
+
transfer.meta.completedAt = Date.now();
|
|
131
|
+
this._clearTransferTimeout(transferId);
|
|
132
|
+
this._outgoing.delete(transferId);
|
|
133
|
+
this.emit('sendComplete', {
|
|
134
|
+
transferId,
|
|
135
|
+
peerId: transfer.peerId,
|
|
136
|
+
name: transfer.meta.name,
|
|
137
|
+
elapsedMs: transfer.meta.elapsedMs
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Handles an incoming file offer
|
|
144
|
+
* @param {Object} offer - File offer metadata
|
|
145
|
+
* @param {string} senderId - Sender peer ID
|
|
146
|
+
* @returns {string} Transfer ID
|
|
147
|
+
*/
|
|
148
|
+
handleOffer(offer, senderId) {
|
|
149
|
+
if (this._incoming.size >= this._config.maxConcurrentTransfers) {
|
|
150
|
+
throw new Error('Max concurrent incoming transfers reached');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Validate offer fields
|
|
154
|
+
if (!offer || !offer.id || !offer.name) {
|
|
155
|
+
throw new Error('Invalid file offer: missing id or name');
|
|
156
|
+
}
|
|
157
|
+
if (typeof offer.totalChunks !== 'number' || offer.totalChunks <= 0) {
|
|
158
|
+
throw new Error('Invalid file offer: invalid totalChunks');
|
|
159
|
+
}
|
|
160
|
+
if (typeof offer.size !== 'number' || offer.size <= 0) {
|
|
161
|
+
throw new Error('Invalid file offer: invalid size');
|
|
162
|
+
}
|
|
163
|
+
if (offer.size > this._config.maxFileSize) {
|
|
164
|
+
throw new Error(`File too large: ${offer.size} bytes exceeds ${this._config.maxFileSize} byte limit`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const fileMeta = FileMessage.fromOffer(offer, senderId);
|
|
168
|
+
const assembler = new FileAssembler(offer.id, offer.totalChunks, offer.size);
|
|
169
|
+
|
|
170
|
+
this._incoming.set(offer.id, {
|
|
171
|
+
meta: fileMeta,
|
|
172
|
+
assembler,
|
|
173
|
+
senderId
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
this._setTransferTimeout(offer.id);
|
|
177
|
+
return offer.id;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Handles an incoming file chunk
|
|
182
|
+
* @param {string} transferId - Transfer ID
|
|
183
|
+
* @param {number} index - Chunk index
|
|
184
|
+
* @param {Uint8Array} data - Chunk data
|
|
185
|
+
*/
|
|
186
|
+
handleChunk(transferId, index, data) {
|
|
187
|
+
const transfer = this._incoming.get(transferId);
|
|
188
|
+
if (!transfer) { return; }
|
|
189
|
+
|
|
190
|
+
if (!transfer.meta.startedAt) {
|
|
191
|
+
transfer.meta.startedAt = Date.now();
|
|
192
|
+
}
|
|
193
|
+
transfer.meta.state = FILE_TRANSFER_STATE.TRANSFERRING;
|
|
194
|
+
|
|
195
|
+
const isNew = transfer.assembler.addChunk(index, data);
|
|
196
|
+
if (!isNew) { return; }
|
|
197
|
+
|
|
198
|
+
transfer.meta.receivedChunks = transfer.assembler.receivedChunks;
|
|
199
|
+
|
|
200
|
+
this.emit('receiveProgress', {
|
|
201
|
+
transferId,
|
|
202
|
+
from: transfer.senderId,
|
|
203
|
+
name: transfer.meta.name,
|
|
204
|
+
percent: transfer.assembler.progress
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
if (transfer.assembler.isComplete()) {
|
|
208
|
+
const fileData = transfer.assembler.assemble();
|
|
209
|
+
transfer.meta.state = FILE_TRANSFER_STATE.COMPLETE;
|
|
210
|
+
transfer.meta.completedAt = Date.now();
|
|
211
|
+
this._clearTransferTimeout(transferId);
|
|
212
|
+
this._incoming.delete(transferId);
|
|
213
|
+
|
|
214
|
+
this.emit('fileReceived', {
|
|
215
|
+
transferId,
|
|
216
|
+
from: transfer.senderId,
|
|
217
|
+
file: {
|
|
218
|
+
name: transfer.meta.name,
|
|
219
|
+
mimeType: transfer.meta.mimeType,
|
|
220
|
+
size: transfer.meta.size,
|
|
221
|
+
data: fileData
|
|
222
|
+
},
|
|
223
|
+
elapsedMs: transfer.meta.elapsedMs
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Cancels a transfer (incoming or outgoing)
|
|
230
|
+
* @param {string} transferId - Transfer ID
|
|
231
|
+
*/
|
|
232
|
+
cancelTransfer(transferId) {
|
|
233
|
+
this._clearTransferTimeout(transferId);
|
|
234
|
+
|
|
235
|
+
if (this._outgoing.has(transferId)) {
|
|
236
|
+
const transfer = this._outgoing.get(transferId);
|
|
237
|
+
transfer.state = FILE_TRANSFER_STATE.CANCELLED;
|
|
238
|
+
this._outgoing.delete(transferId);
|
|
239
|
+
this.emit('transferCancelled', { transferId, direction: 'outgoing' });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (this._incoming.has(transferId)) {
|
|
243
|
+
const transfer = this._incoming.get(transferId);
|
|
244
|
+
transfer.meta.state = FILE_TRANSFER_STATE.CANCELLED;
|
|
245
|
+
transfer.assembler.clear();
|
|
246
|
+
this._incoming.delete(transferId);
|
|
247
|
+
this.emit('transferCancelled', { transferId, direction: 'incoming' });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Gets active transfers
|
|
253
|
+
* @returns {Object} { outgoing: [], incoming: [] }
|
|
254
|
+
*/
|
|
255
|
+
getActiveTransfers() {
|
|
256
|
+
return {
|
|
257
|
+
outgoing: Array.from(this._outgoing.values()).map(t => ({
|
|
258
|
+
id: t.id, peerId: t.peerId, name: t.meta.name,
|
|
259
|
+
progress: Math.round((t.sentChunks / t.chunks.length) * 100),
|
|
260
|
+
state: t.state
|
|
261
|
+
})),
|
|
262
|
+
incoming: Array.from(this._incoming.values()).map(t => ({
|
|
263
|
+
id: t.meta.id, from: t.senderId, name: t.meta.name,
|
|
264
|
+
progress: t.assembler.progress,
|
|
265
|
+
state: t.meta.state
|
|
266
|
+
}))
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Destroys and cleans up
|
|
272
|
+
*/
|
|
273
|
+
destroy() {
|
|
274
|
+
for (const id of this._timeouts.keys()) {
|
|
275
|
+
this._clearTransferTimeout(id);
|
|
276
|
+
}
|
|
277
|
+
this._outgoing.clear();
|
|
278
|
+
for (const t of this._incoming.values()) {
|
|
279
|
+
t.assembler.clear();
|
|
280
|
+
}
|
|
281
|
+
this._incoming.clear();
|
|
282
|
+
this.removeAllListeners();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** @private */
|
|
286
|
+
_setTransferTimeout(transferId) {
|
|
287
|
+
const timer = setTimeout(() => {
|
|
288
|
+
this.cancelTransfer(transferId);
|
|
289
|
+
this.emit('transferFailed', {
|
|
290
|
+
transferId,
|
|
291
|
+
reason: 'timeout'
|
|
292
|
+
});
|
|
293
|
+
}, this._config.transferTimeoutMs);
|
|
294
|
+
this._timeouts.set(transferId, timer);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** @private */
|
|
298
|
+
_clearTransferTimeout(transferId) {
|
|
299
|
+
const timer = this._timeouts.get(transferId);
|
|
300
|
+
if (timer) {
|
|
301
|
+
clearTimeout(timer);
|
|
302
|
+
this._timeouts.delete(transferId);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
module.exports = FileManager;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview File message metadata
|
|
5
|
+
* @module service/file/FileMessage
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* File transfer message types
|
|
10
|
+
* @constant {Object}
|
|
11
|
+
*/
|
|
12
|
+
const FILE_MESSAGE_TYPE = Object.freeze({
|
|
13
|
+
/** Initial file offer with metadata */
|
|
14
|
+
OFFER: 'file:offer',
|
|
15
|
+
/** File chunk data */
|
|
16
|
+
CHUNK: 'file:chunk',
|
|
17
|
+
/** Transfer complete acknowledgment */
|
|
18
|
+
COMPLETE: 'file:complete',
|
|
19
|
+
/** Transfer cancelled */
|
|
20
|
+
CANCEL: 'file:cancel'
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* File transfer states
|
|
25
|
+
* @constant {Object}
|
|
26
|
+
*/
|
|
27
|
+
const FILE_TRANSFER_STATE = Object.freeze({
|
|
28
|
+
PENDING: 'pending',
|
|
29
|
+
TRANSFERRING: 'transferring',
|
|
30
|
+
COMPLETE: 'complete',
|
|
31
|
+
FAILED: 'failed',
|
|
32
|
+
CANCELLED: 'cancelled'
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Represents a file being transferred over the mesh.
|
|
37
|
+
* @class FileMessage
|
|
38
|
+
*/
|
|
39
|
+
class FileMessage {
|
|
40
|
+
/**
|
|
41
|
+
* @param {Object} options
|
|
42
|
+
* @param {string} options.id - Transfer ID
|
|
43
|
+
* @param {string} options.name - File name
|
|
44
|
+
* @param {string} options.mimeType - MIME type
|
|
45
|
+
* @param {number} options.size - Total size in bytes
|
|
46
|
+
* @param {number} options.totalChunks - Total number of chunks
|
|
47
|
+
* @param {number} [options.chunkSize=4096] - Chunk size in bytes
|
|
48
|
+
* @param {string} [options.senderId] - Sender peer ID
|
|
49
|
+
*/
|
|
50
|
+
constructor(options) {
|
|
51
|
+
this.id = options.id;
|
|
52
|
+
this.name = options.name;
|
|
53
|
+
this.mimeType = options.mimeType || 'application/octet-stream';
|
|
54
|
+
this.size = options.size;
|
|
55
|
+
this.totalChunks = options.totalChunks;
|
|
56
|
+
this.chunkSize = options.chunkSize || 4096;
|
|
57
|
+
this.senderId = options.senderId || null;
|
|
58
|
+
this.receivedChunks = 0;
|
|
59
|
+
this.state = FILE_TRANSFER_STATE.PENDING;
|
|
60
|
+
this.startedAt = null;
|
|
61
|
+
this.completedAt = null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Gets transfer progress as percentage
|
|
66
|
+
* @returns {number} 0-100
|
|
67
|
+
*/
|
|
68
|
+
get progress() {
|
|
69
|
+
if (this.totalChunks === 0) { return 100; }
|
|
70
|
+
return Math.round((this.receivedChunks / this.totalChunks) * 100);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Gets elapsed transfer time in ms
|
|
75
|
+
* @returns {number}
|
|
76
|
+
*/
|
|
77
|
+
get elapsedMs() {
|
|
78
|
+
if (!this.startedAt) { return 0; }
|
|
79
|
+
const end = this.completedAt || Date.now();
|
|
80
|
+
return end - this.startedAt;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Serializes the file offer metadata
|
|
85
|
+
* @returns {Object}
|
|
86
|
+
*/
|
|
87
|
+
toOffer() {
|
|
88
|
+
return {
|
|
89
|
+
type: FILE_MESSAGE_TYPE.OFFER,
|
|
90
|
+
id: this.id,
|
|
91
|
+
name: this.name,
|
|
92
|
+
mimeType: this.mimeType,
|
|
93
|
+
size: this.size,
|
|
94
|
+
totalChunks: this.totalChunks,
|
|
95
|
+
chunkSize: this.chunkSize
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Creates a FileMessage from an offer
|
|
101
|
+
* @param {Object} offer
|
|
102
|
+
* @param {string} senderId
|
|
103
|
+
* @returns {FileMessage}
|
|
104
|
+
*/
|
|
105
|
+
static fromOffer(offer, senderId) {
|
|
106
|
+
return new FileMessage({
|
|
107
|
+
id: offer.id,
|
|
108
|
+
name: offer.name,
|
|
109
|
+
mimeType: offer.mimeType,
|
|
110
|
+
size: offer.size,
|
|
111
|
+
totalChunks: offer.totalChunks,
|
|
112
|
+
chunkSize: offer.chunkSize,
|
|
113
|
+
senderId
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = {
|
|
119
|
+
FileMessage,
|
|
120
|
+
FILE_MESSAGE_TYPE,
|
|
121
|
+
FILE_TRANSFER_STATE
|
|
122
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const FileManager = require('./FileManager');
|
|
4
|
+
const FileChunker = require('./FileChunker');
|
|
5
|
+
const FileAssembler = require('./FileAssembler');
|
|
6
|
+
const { FileMessage, FILE_MESSAGE_TYPE, FILE_TRANSFER_STATE } = require('./FileMessage');
|
|
7
|
+
|
|
8
|
+
module.exports = {
|
|
9
|
+
FileManager,
|
|
10
|
+
FileChunker,
|
|
11
|
+
FileAssembler,
|
|
12
|
+
FileMessage,
|
|
13
|
+
FILE_MESSAGE_TYPE,
|
|
14
|
+
FILE_TRANSFER_STATE
|
|
15
|
+
};
|
|
@@ -45,6 +45,17 @@ class BroadcastManager extends EventEmitter {
|
|
|
45
45
|
this._senderId = null;
|
|
46
46
|
/** @private */
|
|
47
47
|
this._sendCallback = null;
|
|
48
|
+
/** @private */
|
|
49
|
+
this._cleanupTimer = null;
|
|
50
|
+
|
|
51
|
+
// Auto-cleanup every 5 minutes
|
|
52
|
+
this._cleanupTimer = setInterval(() => {
|
|
53
|
+
this.cleanup();
|
|
54
|
+
}, 5 * 60 * 1000);
|
|
55
|
+
|
|
56
|
+
if (this._cleanupTimer && typeof this._cleanupTimer.unref === 'function') {
|
|
57
|
+
this._cleanupTimer.unref();
|
|
58
|
+
}
|
|
48
59
|
}
|
|
49
60
|
|
|
50
61
|
/**
|
|
@@ -160,6 +171,11 @@ class BroadcastManager extends EventEmitter {
|
|
|
160
171
|
clear() {
|
|
161
172
|
this._recentBroadcasts = [];
|
|
162
173
|
this._seenMessageIds.clear();
|
|
174
|
+
|
|
175
|
+
if (this._cleanupTimer) {
|
|
176
|
+
clearInterval(this._cleanupTimer);
|
|
177
|
+
this._cleanupTimer = null;
|
|
178
|
+
}
|
|
163
179
|
}
|
|
164
180
|
|
|
165
181
|
/**
|
|
@@ -30,6 +30,7 @@ class BLETransport extends Transport {
|
|
|
30
30
|
* @param {string} [options.powerMode='BALANCED'] - Power mode
|
|
31
31
|
* @param {number} [options.maxPeers=8] - Maximum peers
|
|
32
32
|
* @param {number} [options.connectTimeoutMs=10000] - Connection timeout
|
|
33
|
+
* @param {number} [options.mtu=23] - Default BLE MTU
|
|
33
34
|
*/
|
|
34
35
|
constructor(adapter, options = {}) {
|
|
35
36
|
super(options);
|
|
@@ -59,6 +60,13 @@ class BLETransport extends Transport {
|
|
|
59
60
|
*/
|
|
60
61
|
this._connectTimeoutMs = options.connectTimeoutMs || 10000;
|
|
61
62
|
|
|
63
|
+
/**
|
|
64
|
+
* BLE MTU (Maximum Transmission Unit)
|
|
65
|
+
* @type {number}
|
|
66
|
+
* @private
|
|
67
|
+
*/
|
|
68
|
+
this._mtu = options.mtu || 23; // Default BLE MTU
|
|
69
|
+
|
|
62
70
|
/**
|
|
63
71
|
* Whether scanning is active
|
|
64
72
|
* @type {boolean}
|
|
@@ -66,6 +74,20 @@ class BLETransport extends Transport {
|
|
|
66
74
|
*/
|
|
67
75
|
this._isScanning = false;
|
|
68
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Per-peer write queues for serializing BLE writes
|
|
79
|
+
* @type {Map<string, Array>}
|
|
80
|
+
* @private
|
|
81
|
+
*/
|
|
82
|
+
this._writeQueue = new Map();
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Per-peer write locks
|
|
86
|
+
* @type {Map<string, boolean>}
|
|
87
|
+
* @private
|
|
88
|
+
*/
|
|
89
|
+
this._writing = new Map();
|
|
90
|
+
|
|
69
91
|
/**
|
|
70
92
|
* Bound event handlers for cleanup
|
|
71
93
|
* @type {Object}
|
|
@@ -86,6 +108,14 @@ class BLETransport extends Transport {
|
|
|
86
108
|
return this._isScanning;
|
|
87
109
|
}
|
|
88
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Gets the current BLE MTU
|
|
113
|
+
* @returns {number} Current MTU in bytes
|
|
114
|
+
*/
|
|
115
|
+
get mtu() {
|
|
116
|
+
return this._mtu;
|
|
117
|
+
}
|
|
118
|
+
|
|
89
119
|
/**
|
|
90
120
|
* Starts the BLE transport
|
|
91
121
|
* @returns {Promise<void>}
|
|
@@ -107,7 +137,18 @@ class BLETransport extends Transport {
|
|
|
107
137
|
}
|
|
108
138
|
|
|
109
139
|
this._adapter.onStateChange(this._handlers.onStateChange);
|
|
140
|
+
|
|
141
|
+
// Register disconnect callback if adapter supports it
|
|
142
|
+
if (typeof this._adapter.onDeviceDisconnected === 'function') {
|
|
143
|
+
this._adapter.onDeviceDisconnected((peerId) => {
|
|
144
|
+
this._handleDeviceDisconnected(peerId);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
110
148
|
this._setState(Transport.STATE.RUNNING);
|
|
149
|
+
|
|
150
|
+
// Auto-start scanning for peers
|
|
151
|
+
await this.startScanning();
|
|
111
152
|
} catch (error) {
|
|
112
153
|
this._setState(Transport.STATE.ERROR);
|
|
113
154
|
throw error;
|
|
@@ -191,11 +232,28 @@ class BLETransport extends Transport {
|
|
|
191
232
|
}
|
|
192
233
|
|
|
193
234
|
try {
|
|
235
|
+
let timeoutId;
|
|
236
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
237
|
+
timeoutId = setTimeout(() => reject(new Error('Connection timeout')), this._connectTimeoutMs);
|
|
238
|
+
});
|
|
194
239
|
const device = await Promise.race([
|
|
195
|
-
this._adapter.connect(peerId),
|
|
196
|
-
|
|
240
|
+
this._adapter.connect(peerId).then(d => { clearTimeout(timeoutId); return d; }),
|
|
241
|
+
timeoutPromise
|
|
197
242
|
]);
|
|
198
243
|
|
|
244
|
+
// Negotiate MTU for larger payloads
|
|
245
|
+
let negotiatedMtu = this._mtu;
|
|
246
|
+
try {
|
|
247
|
+
if (typeof this._adapter.requestMTU === 'function') {
|
|
248
|
+
const mtu = await this._adapter.requestMTU(peerId, 512);
|
|
249
|
+
if (mtu) {
|
|
250
|
+
negotiatedMtu = mtu;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
} catch (mtuError) {
|
|
254
|
+
// MTU negotiation failure is non-fatal, continue with default MTU
|
|
255
|
+
}
|
|
256
|
+
|
|
199
257
|
// Subscribe to notifications
|
|
200
258
|
await this._adapter.subscribe(
|
|
201
259
|
peerId,
|
|
@@ -207,7 +265,8 @@ class BLETransport extends Transport {
|
|
|
207
265
|
const connectionInfo = {
|
|
208
266
|
peerId,
|
|
209
267
|
device,
|
|
210
|
-
connectedAt: Date.now()
|
|
268
|
+
connectedAt: Date.now(),
|
|
269
|
+
mtu: negotiatedMtu
|
|
211
270
|
};
|
|
212
271
|
|
|
213
272
|
this._peers.set(peerId, connectionInfo);
|
|
@@ -254,12 +313,21 @@ class BLETransport extends Transport {
|
|
|
254
313
|
throw ConnectionError.fromCode('E207', peerId);
|
|
255
314
|
}
|
|
256
315
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
316
|
+
const peerInfo = this._peers.get(peerId);
|
|
317
|
+
const mtu = peerInfo.mtu || this._mtu || 23;
|
|
318
|
+
const chunkSize = Math.max(mtu - 3, 20); // ATT header overhead, minimum 20
|
|
319
|
+
|
|
320
|
+
if (data.length <= chunkSize) {
|
|
321
|
+
// Single write
|
|
322
|
+
await this._queuedWrite(peerId, data);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Chunk data for BLE MTU compliance
|
|
327
|
+
for (let offset = 0; offset < data.length; offset += chunkSize) {
|
|
328
|
+
const chunk = data.slice(offset, Math.min(offset + chunkSize, data.length));
|
|
329
|
+
await this._queuedWrite(peerId, chunk);
|
|
330
|
+
}
|
|
263
331
|
}
|
|
264
332
|
|
|
265
333
|
/**
|
|
@@ -326,6 +394,15 @@ class BLETransport extends Transport {
|
|
|
326
394
|
_handleDeviceDisconnected(peerId) {
|
|
327
395
|
if (this._peers.has(peerId)) {
|
|
328
396
|
this._peers.delete(peerId);
|
|
397
|
+
|
|
398
|
+
// Clean up write queue
|
|
399
|
+
const queue = this._writeQueue.get(peerId);
|
|
400
|
+
if (queue) {
|
|
401
|
+
queue.forEach(({ reject }) => reject(new Error('Peer disconnected')));
|
|
402
|
+
this._writeQueue.delete(peerId);
|
|
403
|
+
}
|
|
404
|
+
this._writing.delete(peerId);
|
|
405
|
+
|
|
329
406
|
this.emit('peerDisconnected', { peerId, reason: 'connection_lost' });
|
|
330
407
|
}
|
|
331
408
|
}
|
|
@@ -352,6 +429,51 @@ class BLETransport extends Transport {
|
|
|
352
429
|
setTimeout(() => reject(new Error(message)), ms);
|
|
353
430
|
});
|
|
354
431
|
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Queues a write operation to serialize BLE writes
|
|
435
|
+
* @param {string} peerId - Target peer ID
|
|
436
|
+
* @param {Uint8Array} data - Data to write
|
|
437
|
+
* @returns {Promise<void>}
|
|
438
|
+
* @private
|
|
439
|
+
*/
|
|
440
|
+
async _queuedWrite(peerId, data) {
|
|
441
|
+
if (!this._writeQueue.has(peerId)) {
|
|
442
|
+
this._writeQueue.set(peerId, []);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return new Promise((resolve, reject) => {
|
|
446
|
+
this._writeQueue.get(peerId).push({ data, resolve, reject });
|
|
447
|
+
this._processWriteQueue(peerId);
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Processes the write queue for a peer
|
|
453
|
+
* @param {string} peerId - Target peer ID
|
|
454
|
+
* @returns {Promise<void>}
|
|
455
|
+
* @private
|
|
456
|
+
*/
|
|
457
|
+
async _processWriteQueue(peerId) {
|
|
458
|
+
if (this._writing.get(peerId)) { return; } // Already processing
|
|
459
|
+
|
|
460
|
+
const queue = this._writeQueue.get(peerId);
|
|
461
|
+
if (!queue || queue.length === 0) { return; }
|
|
462
|
+
|
|
463
|
+
this._writing.set(peerId, true);
|
|
464
|
+
|
|
465
|
+
while (queue.length > 0) {
|
|
466
|
+
const { data, resolve, reject } = queue.shift();
|
|
467
|
+
try {
|
|
468
|
+
await this._adapter.write(peerId, BLE_SERVICE_UUID, BLE_CHARACTERISTIC_TX, data);
|
|
469
|
+
resolve();
|
|
470
|
+
} catch (err) {
|
|
471
|
+
reject(err);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
this._writing.set(peerId, false);
|
|
476
|
+
}
|
|
355
477
|
}
|
|
356
478
|
|
|
357
479
|
module.exports = BLETransport;
|
|
@@ -50,7 +50,7 @@ class MockTransport extends Transport {
|
|
|
50
50
|
* @type {string|null}
|
|
51
51
|
* @private
|
|
52
52
|
*/
|
|
53
|
-
this._localPeerId = options.localPeerId ||
|
|
53
|
+
this._localPeerId = options.localPeerId || `mock-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/**
|