bitchat-node 0.1.1 → 0.1.2

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.
Files changed (45) hide show
  1. package/README.md +94 -23
  2. package/dist/client.d.ts +1 -0
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +143 -28
  5. package/dist/client.js.map +1 -1
  6. package/dist/debug.d.ts +39 -0
  7. package/dist/debug.d.ts.map +1 -0
  8. package/dist/debug.js +89 -0
  9. package/dist/debug.js.map +1 -0
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +4 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/mesh/router.d.ts.map +1 -1
  15. package/dist/mesh/router.js +9 -1
  16. package/dist/mesh/router.js.map +1 -1
  17. package/dist/protocol/binary.d.ts +1 -1
  18. package/dist/protocol/binary.d.ts.map +1 -1
  19. package/dist/protocol/binary.js +2 -2
  20. package/dist/protocol/binary.js.map +1 -1
  21. package/dist/session/manager.d.ts.map +1 -1
  22. package/dist/session/manager.js +18 -1
  23. package/dist/session/manager.js.map +1 -1
  24. package/dist/transport/ble.d.ts +15 -0
  25. package/dist/transport/ble.d.ts.map +1 -1
  26. package/dist/transport/ble.js +63 -16
  27. package/dist/transport/ble.js.map +1 -1
  28. package/dist/ui/html.d.ts +5 -0
  29. package/dist/ui/html.d.ts.map +1 -0
  30. package/dist/ui/html.js +506 -0
  31. package/dist/ui/html.js.map +1 -0
  32. package/dist/ui/server.d.ts +5 -1
  33. package/dist/ui/server.d.ts.map +1 -1
  34. package/dist/ui/server.js +61 -255
  35. package/dist/ui/server.js.map +1 -1
  36. package/package.json +2 -1
  37. package/src/client.ts +159 -34
  38. package/src/debug.ts +119 -0
  39. package/src/index.ts +11 -0
  40. package/src/mesh/router.ts +11 -1
  41. package/src/protocol/binary.ts +2 -2
  42. package/src/session/manager.ts +19 -1
  43. package/src/transport/ble.ts +70 -16
  44. package/src/ui/html.ts +506 -0
  45. package/src/ui/server.ts +78 -258
@@ -15,6 +15,7 @@ import {
15
15
  SERVICE_UUID_TESTNET,
16
16
  } from '../protocol/constants.js';
17
17
  import type { PeerID } from '../protocol/types.js';
18
+ import { debugLog } from '../debug.js';
18
19
 
19
20
  // Types for noble
20
21
  type NobleModule = typeof import('@abandonware/noble');
@@ -91,22 +92,17 @@ export class BLELink implements Link {
91
92
  throw new Error('Message too large for MTU, fragmentation not yet implemented');
92
93
  }
93
94
 
94
- console.log('[BLE] Sending', data.length, 'bytes to', this.id);
95
- console.log('[BLE] First 50 bytes:', Buffer.from(data.slice(0, 50)).toString('hex'));
96
- console.log('[BLE] Last 20 bytes:', Buffer.from(data.slice(-20)).toString('hex'));
95
+ debugLog.bleTx(data, `→ ${this.id.slice(0, 8)} (${data.length}b)`);
97
96
 
98
97
  const buf = Buffer.from(data);
99
- console.log('[BLE] Buffer check - is Buffer:', Buffer.isBuffer(buf), 'length:', buf.length);
100
98
 
101
- // Try write WITHOUT response (withoutResponse = true)
102
- // This uses a different BLE write type that might work better
99
+ // Use regular write WITH response (withoutResponse = false) to match iOS behavior
103
100
  return new Promise((resolve, reject) => {
104
- this.characteristic.write(buf, true, (error) => {
101
+ this.characteristic.write(buf, false, (error) => {
105
102
  if (error) {
106
- console.error('[BLE] Send error:', error);
103
+ debugLog.error('BLE TX', error);
107
104
  reject(error);
108
105
  } else {
109
- console.log('[BLE] Send success (without response)');
110
106
  resolve();
111
107
  }
112
108
  });
@@ -118,6 +114,39 @@ export class BLELink implements Link {
118
114
  }
119
115
  }
120
116
 
117
+ /**
118
+ * Peripheral Link - represents a central (e.g., iOS) connected to us
119
+ */
120
+ export class PeripheralLink implements Link {
121
+ readonly id: string;
122
+ private _peerID?: PeerID;
123
+ private notifyCallback: (data: Buffer) => void;
124
+
125
+ constructor(centralId: string, notifyCallback: (data: Buffer) => void) {
126
+ this.id = centralId;
127
+ this.notifyCallback = notifyCallback;
128
+ }
129
+
130
+ get peerID(): PeerID | undefined {
131
+ return this._peerID;
132
+ }
133
+
134
+ setPeerID(peerID: PeerID): void {
135
+ this._peerID = peerID;
136
+ console.log(`[BLE] PeripheralLink ${this.id.slice(0,8)} now associated with peer ${peerID.toHex().slice(0,8)}`);
137
+ }
138
+
139
+ async send(data: Uint8Array): Promise<void> {
140
+ debugLog.bleTx(data, `→ notify (${data.length}b)`);
141
+ const buf = Buffer.from(data);
142
+ this.notifyCallback(buf);
143
+ }
144
+
145
+ get isConnected(): boolean {
146
+ return true; // Peripheral link is always "connected" while active
147
+ }
148
+ }
149
+
121
150
  /**
122
151
  * BLE Transport - handles Bluetooth mesh networking
123
152
  */
@@ -134,6 +163,8 @@ export class BLETransport extends EventEmitter {
134
163
  // Subscribed centrals (peripheral mode)
135
164
  private subscribedCentrals = new Set<string>();
136
165
  private notifyCallback?: (data: Buffer) => void;
166
+ private peripheralLink?: PeripheralLink;
167
+ private lastCentralAddress = 'unknown-central';
137
168
 
138
169
  constructor(config: Partial<BLETransportConfig> = {}) {
139
170
  super();
@@ -354,9 +385,7 @@ export class BLETransport extends EventEmitter {
354
385
  await peripheral.connectAsync();
355
386
  console.log('[BLE] Connected to', peripheral.uuid);
356
387
 
357
- // Clear tracking state on success
358
- this.connectingPeripherals.delete(peripheral.uuid);
359
- this.failedConnections.delete(peripheral.uuid);
388
+ // NOTE: Keep in connectingPeripherals until fully set up to prevent race conditions
360
389
 
361
390
  // Discover services and characteristics
362
391
  console.log('[BLE] Discovering services for', peripheral.uuid);
@@ -393,6 +422,7 @@ export class BLETransport extends EventEmitter {
393
422
  // Subscribe to notifications
394
423
  await characteristic.subscribeAsync();
395
424
  characteristic.on('data', (data: Buffer) => {
425
+ debugLog.bleRx(new Uint8Array(data), `← ${peripheral.uuid.slice(0, 8)} (${data.length}b)`);
396
426
  this.emit('data', new Uint8Array(data), link);
397
427
  });
398
428
 
@@ -401,7 +431,12 @@ export class BLETransport extends EventEmitter {
401
431
  this.onDisconnect(peripheral.uuid);
402
432
  });
403
433
 
434
+ // Now safe to clear connecting state - link is fully established
435
+ this.connectingPeripherals.delete(peripheral.uuid);
436
+ this.failedConnections.delete(peripheral.uuid);
437
+
404
438
  this.emit('link:connected', link);
439
+ console.log('[BLE] Link established to', peripheral.uuid);
405
440
  } catch (error) {
406
441
  // Clear connecting state
407
442
  this.connectingPeripherals.delete(peripheral.uuid);
@@ -468,17 +503,28 @@ export class BLETransport extends EventEmitter {
468
503
  callback: (result: number) => void
469
504
  ) => {
470
505
  // Handle incoming data from central
471
- this.emit('data', new Uint8Array(data), null as any);
506
+ debugLog.bleRx(new Uint8Array(data), `← central (${data.length}b)`);
507
+ // Pass the peripheral link if available
508
+ this.emit('data', new Uint8Array(data), this.peripheralLink ?? (null as any));
472
509
  callback(CharacteristicClass.RESULT_SUCCESS);
473
510
  },
474
511
  onSubscribe: (_maxValueSize: number, updateValueCallback: (data: Buffer) => void) => {
475
- // Central subscribed to notifications - store callback
512
+ // Central subscribed to notifications - store callback and create link
476
513
  this.notifyCallback = updateValueCallback;
514
+ // Create a peripheral link for the connected central
515
+ this.peripheralLink = new PeripheralLink(this.lastCentralAddress, updateValueCallback);
477
516
  console.log('Central subscribed to notifications');
517
+ console.log('[BLE] Created peripheral link for central:', this.lastCentralAddress);
518
+ // Emit link:connected so router can use this link
519
+ this.emit('link:connected', this.peripheralLink);
478
520
  },
479
521
  onUnsubscribe: () => {
480
522
  // Central unsubscribed
523
+ if (this.peripheralLink) {
524
+ this.emit('link:disconnected', this.peripheralLink.id);
525
+ }
481
526
  this.notifyCallback = undefined;
527
+ this.peripheralLink = undefined;
482
528
  console.log('Central unsubscribed');
483
529
  },
484
530
  });
@@ -496,10 +542,18 @@ export class BLETransport extends EventEmitter {
496
542
  // Listen for central connection events
497
543
  const bleno = this.bleno as any;
498
544
  bleno.on('accept', (clientAddress: string) => {
499
- console.log('[BLE] Central accepted connection from:', clientAddress);
545
+ console.log('[BLE] Central connected:', clientAddress);
546
+ this.lastCentralAddress = clientAddress;
547
+ this.subscribedCentrals.add(clientAddress);
500
548
  });
501
549
  bleno.on('disconnect', (clientAddress: string) => {
502
- console.log('[BLE] Central disconnected:', clientAddress);
550
+ console.log('[BLE] Central disconnected:', clientAddress);
551
+ this.subscribedCentrals.delete(clientAddress);
552
+ // Clean up peripheral link if this central disconnected
553
+ if (this.peripheralLink && this.peripheralLink.id === clientAddress) {
554
+ this.emit('link:disconnected', this.peripheralLink.id);
555
+ this.peripheralLink = undefined;
556
+ }
503
557
  });
504
558
  bleno.on('servicesSet', (error?: Error) => {
505
559
  console.log('[BLE] Services set, error:', error);
package/src/ui/html.ts ADDED
@@ -0,0 +1,506 @@
1
+ /**
2
+ * Enhanced Bitchat Web UI with Debug Panel
3
+ */
4
+
5
+ export const HTML = `<!DOCTYPE html>
6
+ <html>
7
+ <head>
8
+ <meta charset="UTF-8">
9
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
10
+ <title>Bitchat Node</title>
11
+ <style>
12
+ * { box-sizing: border-box; margin: 0; padding: 0; }
13
+ body {
14
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
15
+ background: #0f0f1a; color: #eee; height: 100vh; display: flex; flex-direction: column;
16
+ }
17
+
18
+ /* Header */
19
+ header {
20
+ background: #1a1a2e; padding: 12px 20px; border-bottom: 1px solid #2a2a4a;
21
+ display: flex; justify-content: space-between; align-items: center;
22
+ }
23
+ header h1 { font-size: 16px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
24
+ header h1 .logo { font-size: 20px; }
25
+ .header-info { display: flex; align-items: center; gap: 16px; }
26
+ .status {
27
+ font-size: 11px; padding: 4px 10px; border-radius: 12px;
28
+ background: #2a2a4a; color: #888;
29
+ }
30
+ .status.connected { background: #064e3b; color: #34d399; }
31
+ .peer-count { font-size: 11px; color: #60a5fa; }
32
+ .my-info { font-size: 11px; color: #888; font-family: monospace; }
33
+
34
+ /* Tab Navigation */
35
+ .tabs {
36
+ background: #1a1a2e; display: flex; border-bottom: 1px solid #2a2a4a;
37
+ }
38
+ .tab {
39
+ padding: 10px 20px; cursor: pointer; font-size: 13px; color: #888;
40
+ border-bottom: 2px solid transparent; transition: all 0.2s;
41
+ }
42
+ .tab:hover { color: #ccc; }
43
+ .tab.active { color: #60a5fa; border-bottom-color: #60a5fa; }
44
+ .tab .badge {
45
+ background: #dc2626; color: white; font-size: 10px; padding: 2px 6px;
46
+ border-radius: 10px; margin-left: 6px;
47
+ }
48
+
49
+ /* Main Layout */
50
+ main { flex: 1; display: flex; overflow: hidden; }
51
+ .panel { display: none; flex: 1; flex-direction: column; overflow: hidden; }
52
+ .panel.active { display: flex; }
53
+
54
+ /* Chat Panel */
55
+ .chat-container { display: flex; flex: 1; overflow: hidden; }
56
+ .messages-area { flex: 1; display: flex; flex-direction: column; }
57
+ .messages {
58
+ flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 8px;
59
+ }
60
+ .message {
61
+ background: #1a1a2e; padding: 10px 14px; border-radius: 12px; max-width: 75%;
62
+ animation: fadeIn 0.15s ease;
63
+ }
64
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } }
65
+ .message.mine { background: #1e3a5f; align-self: flex-end; }
66
+ .message.private { border-left: 3px solid #ec4899; }
67
+ .message .meta { font-size: 10px; color: #666; margin-bottom: 4px; }
68
+ .message .meta .nickname { color: #60a5fa; font-weight: 500; }
69
+ .message .meta .private-tag { color: #ec4899; margin-left: 6px; }
70
+ .message .content { line-height: 1.4; word-wrap: break-word; font-size: 14px; }
71
+ .system { text-align: center; font-size: 11px; color: #555; padding: 6px; }
72
+
73
+ /* Input Area */
74
+ .input-area { background: #1a1a2e; padding: 12px 16px; border-top: 1px solid #2a2a4a; }
75
+ .dm-indicator {
76
+ font-size: 11px; color: #ec4899; margin-bottom: 8px;
77
+ display: flex; align-items: center; gap: 8px;
78
+ }
79
+ .dm-indicator .clear { cursor: pointer; color: #888; }
80
+ .dm-indicator .clear:hover { color: #fff; }
81
+ .input-row { display: flex; gap: 8px; }
82
+ input[type="text"] {
83
+ flex: 1; background: #0f0f1a; border: 1px solid #2a2a4a; border-radius: 8px;
84
+ padding: 10px 14px; color: #eee; font-size: 14px; outline: none;
85
+ }
86
+ input[type="text"]:focus { border-color: #3b82f6; }
87
+ input[type="text"]::placeholder { color: #444; }
88
+ button {
89
+ background: #3b82f6; color: white; border: none; border-radius: 8px;
90
+ padding: 10px 20px; font-size: 13px; font-weight: 500; cursor: pointer;
91
+ }
92
+ button:hover { background: #2563eb; }
93
+ button:disabled { background: #374151; cursor: not-allowed; }
94
+ button.secondary { background: #2a2a4a; }
95
+ button.secondary:hover { background: #3a3a5a; }
96
+
97
+ /* Peers Sidebar */
98
+ .peers-panel {
99
+ width: 180px; background: #1a1a2e; border-left: 1px solid #2a2a4a;
100
+ padding: 12px; overflow-y: auto;
101
+ }
102
+ .peers-panel h3 {
103
+ font-size: 10px; color: #666; margin-bottom: 10px;
104
+ text-transform: uppercase; letter-spacing: 1px;
105
+ }
106
+ .peer {
107
+ padding: 8px 10px; background: #0f0f1a; border-radius: 6px; margin-bottom: 6px;
108
+ font-size: 12px; cursor: pointer; transition: all 0.15s;
109
+ }
110
+ .peer:hover { background: #1e3a5f; }
111
+ .peer.selected { background: #3b82f6; }
112
+ .peer .peer-nick { font-weight: 500; }
113
+ .peer .peer-id { font-size: 9px; color: #555; margin-top: 2px; font-family: monospace; }
114
+
115
+ /* Debug Panel */
116
+ .debug-panel { padding: 0; display: flex; flex-direction: column; }
117
+ .debug-toolbar {
118
+ background: #1a1a2e; padding: 10px 16px; border-bottom: 1px solid #2a2a4a;
119
+ display: flex; gap: 8px; align-items: center;
120
+ }
121
+ .debug-toolbar label { font-size: 11px; color: #888; display: flex; align-items: center; gap: 4px; }
122
+ .debug-toolbar input[type="checkbox"] { accent-color: #3b82f6; }
123
+ .debug-toolbar select {
124
+ background: #0f0f1a; border: 1px solid #2a2a4a; border-radius: 4px;
125
+ padding: 4px 8px; color: #eee; font-size: 11px;
126
+ }
127
+ .debug-log {
128
+ flex: 1; overflow-y: auto; padding: 8px; font-family: 'SF Mono', Monaco, monospace;
129
+ font-size: 11px; line-height: 1.6; background: #0a0a12;
130
+ }
131
+ .debug-entry { padding: 4px 8px; border-radius: 4px; margin-bottom: 2px; }
132
+ .debug-entry.ble-rx { background: #1e293b; border-left: 3px solid #3b82f6; }
133
+ .debug-entry.ble-tx { background: #1e2d1e; border-left: 3px solid #22c55e; }
134
+ .debug-entry.session { background: #2d1e2d; border-left: 3px solid #a855f7; }
135
+ .debug-entry.error { background: #2d1e1e; border-left: 3px solid #ef4444; }
136
+ .debug-entry .time { color: #555; }
137
+ .debug-entry .tag {
138
+ font-weight: 600; padding: 1px 4px; border-radius: 3px; margin: 0 4px;
139
+ }
140
+ .debug-entry.ble-rx .tag { color: #60a5fa; }
141
+ .debug-entry.ble-tx .tag { color: #4ade80; }
142
+ .debug-entry.session .tag { color: #c084fc; }
143
+ .debug-entry.error .tag { color: #f87171; }
144
+ .debug-entry .hex { color: #fbbf24; word-break: break-all; }
145
+ .debug-entry .decoded { color: #94a3b8; margin-top: 2px; font-size: 10px; }
146
+
147
+ /* Sessions Panel */
148
+ .sessions-panel { padding: 16px; }
149
+ .session-card {
150
+ background: #1a1a2e; border-radius: 8px; padding: 12px 16px; margin-bottom: 12px;
151
+ }
152
+ .session-card h4 { font-size: 13px; color: #60a5fa; margin-bottom: 8px; font-family: monospace; }
153
+ .session-card .status-row {
154
+ display: flex; justify-content: space-between; font-size: 11px;
155
+ color: #888; margin-bottom: 4px;
156
+ }
157
+ .session-card .status-value { color: #eee; }
158
+ .session-card .status-value.success { color: #4ade80; }
159
+ .session-card .status-value.pending { color: #fbbf24; }
160
+ .session-card .status-value.error { color: #f87171; }
161
+ </style>
162
+ </head>
163
+ <body>
164
+ <header>
165
+ <div>
166
+ <h1><span class="logo">⚡</span> Bitchat Node</h1>
167
+ </div>
168
+ <div class="header-info">
169
+ <span class="peer-count" id="peer-count">0 peers</span>
170
+ <span class="status" id="status">Connecting...</span>
171
+ <span class="my-info" id="my-info"></span>
172
+ </div>
173
+ </header>
174
+
175
+ <div class="tabs">
176
+ <div class="tab active" data-panel="chat">💬 Chat</div>
177
+ <div class="tab" data-panel="debug">🔍 Debug <span class="badge" id="debug-count" style="display:none">0</span></div>
178
+ <div class="tab" data-panel="sessions">🔐 Sessions</div>
179
+ </div>
180
+
181
+ <main>
182
+ <!-- Chat Panel -->
183
+ <div class="panel active" id="panel-chat">
184
+ <div class="chat-container">
185
+ <div class="messages-area">
186
+ <div class="messages" id="messages"></div>
187
+ <div class="input-area">
188
+ <div class="dm-indicator" id="dm-indicator" style="display:none">
189
+ Sending to: <strong id="dm-target"></strong>
190
+ <span class="clear" onclick="clearDM()">✕ clear</span>
191
+ </div>
192
+ <div class="input-row">
193
+ <input type="text" id="input" placeholder="Type a message..." autocomplete="off">
194
+ <button id="send">Send</button>
195
+ </div>
196
+ </div>
197
+ </div>
198
+ <div class="peers-panel">
199
+ <h3>Peers</h3>
200
+ <div id="peers-list"></div>
201
+ </div>
202
+ </div>
203
+ </div>
204
+
205
+ <!-- Debug Panel -->
206
+ <div class="panel" id="panel-debug">
207
+ <div class="debug-panel">
208
+ <div class="debug-toolbar">
209
+ <label><input type="checkbox" id="filter-ble" checked> BLE</label>
210
+ <label><input type="checkbox" id="filter-session" checked> Session</label>
211
+ <label><input type="checkbox" id="filter-error" checked> Errors</label>
212
+ <select id="filter-direction">
213
+ <option value="all">All directions</option>
214
+ <option value="rx">RX only</option>
215
+ <option value="tx">TX only</option>
216
+ </select>
217
+ <button class="secondary" onclick="clearDebug()">Clear</button>
218
+ <label style="margin-left:auto"><input type="checkbox" id="auto-scroll" checked> Auto-scroll</label>
219
+ </div>
220
+ <div class="debug-log" id="debug-log"></div>
221
+ </div>
222
+ </div>
223
+
224
+ <!-- Sessions Panel -->
225
+ <div class="panel" id="panel-sessions">
226
+ <div class="sessions-panel" id="sessions-list">
227
+ <p style="color:#666;font-size:12px">No active sessions</p>
228
+ </div>
229
+ </div>
230
+ </main>
231
+
232
+ <script>
233
+ const messagesEl = document.getElementById('messages');
234
+ const inputEl = document.getElementById('input');
235
+ const sendBtn = document.getElementById('send');
236
+ const statusEl = document.getElementById('status');
237
+ const peerCountEl = document.getElementById('peer-count');
238
+ const peersListEl = document.getElementById('peers-list');
239
+ const myInfoEl = document.getElementById('my-info');
240
+ const debugLogEl = document.getElementById('debug-log');
241
+ const debugCountEl = document.getElementById('debug-count');
242
+ const dmIndicatorEl = document.getElementById('dm-indicator');
243
+ const dmTargetEl = document.getElementById('dm-target');
244
+ const sessionsListEl = document.getElementById('sessions-list');
245
+
246
+ let ws;
247
+ let myPeerID = '';
248
+ let selectedPeer = null;
249
+ let debugCount = 0;
250
+ const peers = new Map();
251
+ const sessions = new Map();
252
+
253
+ // Tab switching
254
+ document.querySelectorAll('.tab').forEach(tab => {
255
+ tab.addEventListener('click', () => {
256
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
257
+ document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
258
+ tab.classList.add('active');
259
+ document.getElementById('panel-' + tab.dataset.panel).classList.add('active');
260
+ if (tab.dataset.panel === 'debug') {
261
+ debugCount = 0;
262
+ debugCountEl.style.display = 'none';
263
+ }
264
+ });
265
+ });
266
+
267
+ function connect() {
268
+ ws = new WebSocket('ws://' + location.host + '/ws');
269
+
270
+ ws.onopen = () => {
271
+ statusEl.textContent = 'Connected';
272
+ statusEl.className = 'status connected';
273
+ };
274
+
275
+ ws.onclose = () => {
276
+ statusEl.textContent = 'Disconnected';
277
+ statusEl.className = 'status';
278
+ setTimeout(connect, 2000);
279
+ };
280
+
281
+ ws.onmessage = (e) => {
282
+ const msg = JSON.parse(e.data);
283
+ handleMessage(msg);
284
+ };
285
+ }
286
+
287
+ function handleMessage(msg) {
288
+ switch (msg.type) {
289
+ case 'init':
290
+ myPeerID = msg.peerID;
291
+ myInfoEl.textContent = msg.nickname + ' · ' + msg.peerID.slice(0, 8) + '...';
292
+ break;
293
+
294
+ case 'message':
295
+ addMessage(msg.message);
296
+ break;
297
+
298
+ case 'peer:connected':
299
+ peers.set(msg.peer.peerID, msg.peer);
300
+ updatePeersList();
301
+ addSystem(msg.peer.nickname + ' joined');
302
+ break;
303
+
304
+ case 'peer:disconnected':
305
+ const peer = peers.get(msg.peerID);
306
+ peers.delete(msg.peerID);
307
+ updatePeersList();
308
+ if (peer) addSystem(peer.nickname + ' left');
309
+ break;
310
+
311
+ case 'peers':
312
+ peers.clear();
313
+ msg.peers.forEach(p => peers.set(p.peerID, p));
314
+ updatePeersList();
315
+ break;
316
+
317
+ case 'debug':
318
+ addDebugEntry(msg);
319
+ break;
320
+
321
+ case 'session':
322
+ updateSession(msg);
323
+ break;
324
+
325
+ case 'error':
326
+ addSystem('Error: ' + msg.error);
327
+ addDebugEntry({ category: 'error', text: msg.error, time: Date.now() });
328
+ break;
329
+ }
330
+ }
331
+
332
+ function addMessage(msg) {
333
+ const div = document.createElement('div');
334
+ div.className = 'message' + (msg.sender === myPeerID ? ' mine' : '') + (msg.isPrivate ? ' private' : '');
335
+ div.innerHTML =
336
+ '<div class="meta">' +
337
+ '<span class="nickname">' + escapeHtml(msg.senderNickname || 'unknown') + '</span> · ' +
338
+ new Date(msg.timestamp).toLocaleTimeString() +
339
+ (msg.isPrivate ? '<span class="private-tag">🔒 private</span>' : '') +
340
+ '</div>' +
341
+ '<div class="content">' + escapeHtml(msg.content || msg.text) + '</div>';
342
+ messagesEl.appendChild(div);
343
+ messagesEl.scrollTop = messagesEl.scrollHeight;
344
+ }
345
+
346
+ function addSystem(text) {
347
+ const div = document.createElement('div');
348
+ div.className = 'system';
349
+ div.textContent = '— ' + text + ' —';
350
+ messagesEl.appendChild(div);
351
+ messagesEl.scrollTop = messagesEl.scrollHeight;
352
+ }
353
+
354
+ function addDebugEntry(entry) {
355
+ const categoryClass = entry.category || 'ble-rx';
356
+ const filterCheckbox = document.getElementById('filter-' + (categoryClass.split('-')[0] || 'ble'));
357
+ const directionFilter = document.getElementById('filter-direction').value;
358
+
359
+ // Apply filters
360
+ if (filterCheckbox && !filterCheckbox.checked) return;
361
+ if (directionFilter === 'rx' && categoryClass === 'ble-tx') return;
362
+ if (directionFilter === 'tx' && categoryClass === 'ble-rx') return;
363
+
364
+ const div = document.createElement('div');
365
+ div.className = 'debug-entry ' + categoryClass;
366
+
367
+ const time = new Date(entry.time || Date.now()).toLocaleTimeString();
368
+ const tag = entry.tag || categoryClass.toUpperCase();
369
+
370
+ let content = '<span class="time">' + time + '</span>' +
371
+ '<span class="tag">[' + tag + ']</span> ';
372
+
373
+ if (entry.hex) {
374
+ content += '<span class="hex">' + entry.hex + '</span>';
375
+ }
376
+ if (entry.text) {
377
+ content += escapeHtml(entry.text);
378
+ }
379
+ if (entry.decoded) {
380
+ content += '<div class="decoded">↳ ' + escapeHtml(entry.decoded) + '</div>';
381
+ }
382
+
383
+ div.innerHTML = content;
384
+ debugLogEl.appendChild(div);
385
+
386
+ // Update badge if not viewing debug
387
+ if (!document.getElementById('panel-debug').classList.contains('active')) {
388
+ debugCount++;
389
+ debugCountEl.textContent = debugCount > 99 ? '99+' : debugCount;
390
+ debugCountEl.style.display = 'inline';
391
+ }
392
+
393
+ if (document.getElementById('auto-scroll').checked) {
394
+ debugLogEl.scrollTop = debugLogEl.scrollHeight;
395
+ }
396
+ }
397
+
398
+ function clearDebug() {
399
+ debugLogEl.innerHTML = '';
400
+ }
401
+
402
+ function updateSession(msg) {
403
+ sessions.set(msg.peerID, msg);
404
+ renderSessions();
405
+ }
406
+
407
+ function renderSessions() {
408
+ if (sessions.size === 0) {
409
+ sessionsListEl.innerHTML = '<p style="color:#666;font-size:12px">No active sessions</p>';
410
+ return;
411
+ }
412
+
413
+ sessionsListEl.innerHTML = '';
414
+ sessions.forEach((session, peerID) => {
415
+ const card = document.createElement('div');
416
+ card.className = 'session-card';
417
+
418
+ const statusClass = session.established ? 'success' : (session.error ? 'error' : 'pending');
419
+ const statusText = session.established ? 'Established' : (session.error || 'Handshaking...');
420
+
421
+ card.innerHTML =
422
+ '<h4>' + peerID.slice(0, 16) + '...</h4>' +
423
+ '<div class="status-row"><span>Status:</span><span class="status-value ' + statusClass + '">' + statusText + '</span></div>' +
424
+ '<div class="status-row"><span>Direction:</span><span class="status-value">' + (session.initiator ? 'Initiator' : 'Responder') + '</span></div>' +
425
+ '<div class="status-row"><span>TX Nonce:</span><span class="status-value">' + (session.txNonce ?? '—') + '</span></div>' +
426
+ '<div class="status-row"><span>RX Nonce:</span><span class="status-value">' + (session.rxNonce ?? '—') + '</span></div>';
427
+
428
+ sessionsListEl.appendChild(card);
429
+ });
430
+ }
431
+
432
+ function updatePeersList() {
433
+ peerCountEl.textContent = peers.size + ' peer' + (peers.size !== 1 ? 's' : '');
434
+ peersListEl.innerHTML = '';
435
+
436
+ if (peers.size === 0) {
437
+ peersListEl.innerHTML = '<p style="color:#555;font-size:11px;padding:8px">No peers nearby</p>';
438
+ return;
439
+ }
440
+
441
+ peers.forEach((peer, id) => {
442
+ const div = document.createElement('div');
443
+ div.className = 'peer' + (selectedPeer === id ? ' selected' : '');
444
+ div.innerHTML =
445
+ '<div class="peer-nick">' + escapeHtml(peer.nickname) + '</div>' +
446
+ '<div class="peer-id">' + id.slice(0, 12) + '...</div>';
447
+ div.onclick = () => selectPeer(id, peer.nickname);
448
+ peersListEl.appendChild(div);
449
+ });
450
+ }
451
+
452
+ function selectPeer(id, nickname) {
453
+ if (selectedPeer === id) {
454
+ clearDM();
455
+ } else {
456
+ selectedPeer = id;
457
+ dmTargetEl.textContent = nickname;
458
+ dmIndicatorEl.style.display = 'flex';
459
+ inputEl.placeholder = 'Private message to ' + nickname + '...';
460
+ updatePeersList();
461
+ }
462
+ }
463
+
464
+ function clearDM() {
465
+ selectedPeer = null;
466
+ dmIndicatorEl.style.display = 'none';
467
+ inputEl.placeholder = 'Type a message...';
468
+ updatePeersList();
469
+ }
470
+ window.clearDM = clearDM;
471
+
472
+ function send() {
473
+ const text = inputEl.value.trim();
474
+ if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;
475
+
476
+ addMessage({
477
+ id: Date.now().toString(),
478
+ sender: myPeerID,
479
+ senderNickname: 'me',
480
+ content: text,
481
+ timestamp: new Date().toISOString(),
482
+ isPrivate: !!selectedPeer
483
+ });
484
+
485
+ ws.send(JSON.stringify({
486
+ type: 'send',
487
+ text: text,
488
+ to: selectedPeer || null
489
+ }));
490
+
491
+ inputEl.value = '';
492
+ }
493
+
494
+ function escapeHtml(text) {
495
+ const div = document.createElement('div');
496
+ div.textContent = text;
497
+ return div.innerHTML;
498
+ }
499
+
500
+ sendBtn.onclick = send;
501
+ inputEl.onkeydown = (e) => { if (e.key === 'Enter') send(); };
502
+
503
+ connect();
504
+ </script>
505
+ </body>
506
+ </html>`;