-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.html
More file actions
718 lines (678 loc) · 36.1 KB
/
Copy pathindex.html
File metadata and controls
718 lines (678 loc) · 36.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Reticulum Web Client</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' rx='20' fill='%231D9E75'/%3E%3Ctext y='75' x='50' font-size='70' text-anchor='middle' fill='%23E1F5EE'%3E%E2%8B%85%3C/text%3E%3C/svg%3E">
<script>
// Apply stored theme synchronously so the painted page matches from
// the first frame. No FOUC flash between light/dark at load.
try {
var t = localStorage.getItem('reticulum-theme') || 'system';
var eff = t === 'system'
? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: t;
document.documentElement.dataset.theme = eff;
} catch (e) {}
</script>
<link rel="stylesheet" href="css/style.css">
<!-- Leaflet CSS for the Nodes view map. Self-hosted alongside the
JS bundle in lib/ so no CDN request is made. -->
<link rel="stylesheet" href="lib/leaflet.css">
</head>
<body>
<div class="app-shell">
<!-- Destruct / panic button — fixed top-right, visible on every view.
Confirms intent, then wipes ALL locally stored data (identity,
contacts, messages, nodes, bookmarks, settings) and reloads. -->
<button id="btn-destruct" class="destruct-btn" title="Destruct — wipe all local data from this device" aria-label="Destruct: wipe all local data">
<svg viewBox="0 0 24 24" class="destruct-flame" aria-hidden="true">
<defs>
<linearGradient id="destructFlameGrad" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="#ff2d00"/>
<stop offset="55%" stop-color="#ff7a00"/>
<stop offset="100%" stop-color="#ffd000"/>
</linearGradient>
</defs>
<path class="flame-path" fill="url(#destructFlameGrad)" d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>
</svg>
</button>
<!-- Sidebar (desktop only) -->
<aside class="sidebar">
<div class="sidebar-header">
<div class="logo">
<span class="logo-mark"></span>
<span class="logo-name">Reticulum</span>
<span class="logo-version">v0.3.1<!-- BUILD_SHA --></span>
</div>
<span class="conn-pill">
<span class="status-dot" id="conn-dot"></span>
<span id="conn-text">Disconnected</span>
</span>
<div class="quick-connect">
<button class="quick-connect-btn js-connect-btn" data-transport="ble">
<svg viewBox="0 0 16 16"><path d="M5 3l6 5-3 2.5L5 13V3z M5 8l6 5" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linejoin="round"/></svg>
<span>Connect via Bluetooth</span>
</button>
<button class="quick-connect-btn js-connect-btn" data-transport="serial">
<svg viewBox="0 0 16 16"><rect x="5" y="1.5" width="6" height="6" rx="0.5" stroke="currentColor" stroke-width="1.3" fill="none"/><path d="M8 7.5v5M6 11h4v3H6z" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linejoin="round"/></svg>
<span>Connect via USB</span>
</button>
<button class="quick-connect-btn js-tcp-connect" data-transport="ws">
<svg viewBox="0 0 16 16"><rect x="2" y="4" width="12" height="8" rx="1" stroke="currentColor" stroke-width="1.3" fill="none"/><path d="M5 8h6M8 6v4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
<span>Connect via TCP</span>
</button>
</div>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<div class="nav-label">App</div>
<button class="nav-item active" data-view="messages">
<svg viewBox="0 0 16 16"><path d="M2 3h12v8H9l-3 3v-3H2V3z" stroke="currentColor" stroke-width="1.2" fill="none"/></svg>
Messages
<span class="nav-badge hidden" id="nav-badge-messages"></span>
</button>
<button class="nav-item" data-view="nodes">
<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.2" fill="none"/><path d="M2 8h12M8 2c-2 3-2 9 0 12M8 2c2 3 2 9 0 12" stroke="currentColor" stroke-width="1.2" fill="none"/></svg>
Nodes
<span class="nav-badge hidden" id="nav-badge-nodes"></span>
</button>
<button class="nav-item" data-view="nomadnet">
<svg viewBox="0 0 16 16"><rect x="2" y="3" width="12" height="10" rx="1" stroke="currentColor" stroke-width="1.2" fill="none"/><path d="M2 6h12" stroke="currentColor" stroke-width="1.2"/></svg>
Browser
</button>
<button class="nav-item" data-view="settings">
<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="2.5" stroke="currentColor" stroke-width="1.2" fill="none"/><path d="M8 1v2M8 13v2M1 8h2M13 8h2M3 3l1.5 1.5M11.5 11.5L13 13M3 13l1.5-1.5M11.5 4.5L13 3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
Settings
</button>
</div>
</nav>
<div class="sidebar-footer">
<div class="identity-row">
<div class="avatar" id="my-avatar">—</div>
<div class="identity-row-text">
<div class="identity-name" id="my-name-display">WebClient</div>
<div class="identity-hash-short js-address-short">—</div>
</div>
</div>
<button class="theme-toggle" id="theme-toggle" title="Toggle light / dark theme" aria-label="Toggle theme">
<svg viewBox="0 0 16 16" class="theme-icon-sun"><circle cx="8" cy="8" r="3" fill="currentColor"/><path d="M8 1v2M8 13v2M1 8h2M13 8h2M3 3l1.4 1.4M11.6 11.6L13 13M3 13l1.4-1.4M11.6 4.4L13 3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
<svg viewBox="0 0 16 16" class="theme-icon-moon"><path d="M13 9.5A5 5 0 017.5 3a5 5 0 000 10 5 5 0 005.5-3.5z" fill="currentColor"/></svg>
</button>
</div>
</aside>
<!-- Main content area -->
<main class="main">
<!-- =================== MESSAGES VIEW =================== -->
<section class="view view-messages active" id="messaging-panel">
<div class="list-panel">
<div class="panel-header">
<h2 class="panel-title">Messages</h2>
</div>
<div class="mobile-connect-hero js-mobile-connect">
<p class="mobile-connect-label">Not connected yet</p>
<button class="btn-primary btn-block js-connect-btn" data-transport="ble">Connect via Bluetooth</button>
<button class="btn-secondary btn-block js-connect-btn" data-transport="serial">Connect via USB (Serial)</button>
<button class="btn-secondary btn-block js-tcp-connect" data-transport="ws">Connect via TCP (rnsd)</button>
</div>
<div class="contact-filter">
<input id="contact-search" class="contact-search" type="search" placeholder="Search contacts…" autocomplete="off" />
<button id="contact-filter-pinned" class="filter-toggle" title="Show pinned only" aria-pressed="false">★</button>
</div>
<ul class="contact-list" id="contact-list">
<li class="contact-empty">Listening for announces…</li>
</ul>
</div>
<div class="chat-area">
<div class="chat-header">
<button class="back-btn" id="btn-back" title="Back to list" aria-label="Back">
<svg viewBox="0 0 16 16"><path d="M10 13L4 8l6-5" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<h2 class="chat-title" id="conv-title">Select a contact</h2>
</div>
<div class="message-list" id="message-list"></div>
<div id="compose-area" class="compose hidden">
<div id="staged-attachment" class="staged-attachment hidden"></div>
<button id="btn-attach" class="attach-btn" title="Attach a file or image" aria-label="Attach">📎</button>
<input id="attach-input" type="file" accept="image/*,*/*" hidden>
<textarea id="msg-content" placeholder="Type a message…" rows="1"></textarea>
<button id="btn-send" class="send-btn" title="Send" aria-label="Send">
<svg viewBox="0 0 16 16"><path d="M2 8l12-6-6 12V8H2z" fill="currentColor"/></svg>
</button>
</div>
</div>
<aside class="right-panel">
<div class="rp-section">
<div class="rp-title">Identity</div>
<div class="rp-identity">
<div class="avatar avatar-lg" id="my-avatar-rp">—</div>
<div>
<div class="rp-name" id="my-name-display-rp">WebClient</div>
<div class="rp-hash js-address-short">—</div>
</div>
</div>
<button id="btn-announce" class="btn-primary btn-block js-announce-btn">Announce now</button>
</div>
<div class="rp-section">
<div class="rp-title">Status</div>
<div class="rp-stat-row">
<span>Connection</span>
<span class="conn-pill conn-pill-rp">
<span class="status-dot js-conn-dot"></span>
<span class="js-conn-text">Disconnected</span>
</span>
</div>
<div class="rp-stat-row">
<span>Radio</span>
<span class="js-radio-status rp-val-muted">—</span>
</div>
</div>
</aside>
</section>
<!-- =================== NODES VIEW =================== -->
<section class="view view-nodes">
<div class="view-header">
<div>
<h2 class="view-title">Nodes</h2>
<p class="view-subtitle">Non-LXMF announces on the mesh — telemetry beacons, heartbeats, repeaters. Nodes whose announce carries <code>lat=</code>/<code>lon=</code> appear on the map.</p>
</div>
<div class="view-header-actions">
<button id="btn-add-dest" class="btn-primary">+ Add / Share</button>
<button id="btn-clear-nodes" class="btn-secondary">Clear all</button>
</div>
</div>
<div class="nodes-layout">
<aside class="nodes-sidebar">
<div class="nodes-sidebar-header">
<span class="rp-title">Discovered</span>
<input id="nodes-search" class="list-search" type="text" placeholder="Search the mesh…"
autocomplete="off" autocapitalize="off" spellcheck="false">
<div class="node-filter-chips">
<button class="chip active" data-nodefilter="all">All</button>
<button class="chip" data-nodefilter="messagable">Peers</button>
<button class="chip" data-nodefilter="contacts">Contacts</button>
<button class="chip" data-nodefilter="nodes">Nodes</button>
</div>
</div>
<div id="nodes-list" class="nodes-list"></div>
</aside>
<div class="nodes-map-wrap">
<div id="nodes-map" class="nodes-map">
<div class="nodes-map-placeholder">Open the Nodes view to initialise the map. No nodes with coordinates yet.</div>
</div>
</div>
</div>
</section>
<!-- =================== SETTINGS VIEW =================== -->
<section class="view view-settings">
<div class="view-header">
<h2 class="view-title">Settings</h2>
</div>
<div id="unsupported" class="notice err hidden">
<strong>Web Bluetooth not supported.</strong>
Use Chrome, Edge, or Brave on Android or desktop for BLE, or connect over WebSocket to an <code>rnsd</code> daemon.
</div>
<!-- Connect -->
<div class="settings-card">
<h3 class="settings-card-title">Connect</h3>
<p class="settings-card-hint">
Reach a Reticulum network via an RNode modem (Bluetooth or USB) or by
tunnelling WebSocket → TCP into a running <code>rnsd</code> daemon. See the README for the
Python bridge used by the WebSocket option.
</p>
<div class="status-line">
<span class="status-dot js-conn-dot"></span>
<span class="js-conn-text">Disconnected</span>
<span class="status-line-sep"></span>
<span class="js-radio-status status-muted" id="radio-status"></span>
</div>
<div class="row">
<button id="btn-connect-ble" class="btn-primary js-connect-btn" data-transport="ble">Connect (BLE)</button>
<button id="btn-connect-serial" class="btn-secondary js-connect-btn" data-transport="serial">Connect (Serial)</button>
<button id="btn-connect-ws" class="btn-secondary js-connect-btn" data-transport="ws">Connect (WebSocket)</button>
<button id="btn-disconnect" class="btn-secondary hidden">Disconnect</button>
</div>
<div class="row" id="ws-url-row">
<div class="field wide">
<label>WebSocket bridge URL</label>
<input id="ws-url" type="text" value="ws://localhost:7878" placeholder="ws://localhost:7878">
</div>
<div class="field wide">
<label>Reticulum daemon (host:port)</label>
<div class="hub-row">
<input id="ws-rnsd" type="text" list="rns-hubs" placeholder="pick a public hub or enter host:port"
autocomplete="off" autocapitalize="off" spellcheck="false">
<button type="button" class="hub-shuffle js-hub-shuffle" data-target="ws-rnsd" title="Pick another public hub" aria-label="Pick another public hub">↻</button>
</div>
</div>
</div>
</div>
<!-- Identity -->
<div class="settings-card" id="identity-panel">
<h3 class="settings-card-title">Identity</h3>
<p class="settings-card-hint">
Your cryptographic identity lives in IndexedDB and stays the same between sessions.
Export it as JSON to move it to another browser, or start over with a fresh keypair.
</p>
<div class="row">
<div class="field wide">
<label>LXMF Address</label>
<div class="identity-hash-box" id="my-address">—</div>
</div>
<div class="field">
<label>Display Name</label>
<input id="my-name" type="text" value="WebClient" maxlength="31">
</div>
</div>
<div class="row">
<button class="btn-primary js-announce-btn">Send Announce</button>
<button id="btn-export-id" class="btn-secondary">Export Identity</button>
<button id="btn-import-id" class="btn-secondary">Import Identity</button>
<button id="btn-new-id" class="btn-secondary btn-danger">New Identity</button>
<input id="import-id-input" type="file" accept="application/json,.json" class="hidden">
</div>
</div>
<!-- Radio -->
<div class="settings-card" id="config-panel">
<h3 class="settings-card-title">Radio Configuration</h3>
<p class="settings-card-hint">
Only meaningful when connected via BLE or Serial to an RNode. Change the parameters
to match the LoRa network you want to join, then press Start.
</p>
<div class="row">
<div class="field">
<label>Frequency (Hz)</label>
<input id="cfg-freq" type="number" value="904375000">
</div>
<div class="field narrow">
<label>Bandwidth</label>
<select id="cfg-bw">
<option value="7800">7.8 kHz</option>
<option value="10400">10.4 kHz</option>
<option value="15600">15.6 kHz</option>
<option value="20800">20.8 kHz</option>
<option value="31250">31.25 kHz</option>
<option value="41700">41.7 kHz</option>
<option value="62500">62.5 kHz</option>
<option value="125000">125 kHz</option>
<option value="250000" selected>250 kHz</option>
<option value="500000">500 kHz</option>
</select>
</div>
<div class="field narrow">
<label>SF</label>
<select id="cfg-sf">
<option>7</option><option>8</option><option>9</option>
<option selected>10</option><option>11</option><option>12</option>
</select>
</div>
<div class="field narrow">
<label>CR</label>
<select id="cfg-cr">
<option value="5" selected>4/5</option>
<option value="6">4/6</option>
<option value="7">4/7</option>
<option value="8">4/8</option>
</select>
</div>
<div class="field narrow">
<label>TX Power</label>
<input id="cfg-txp" type="number" min="-9" max="22" value="22">
</div>
</div>
<div class="row">
<button id="btn-start-radio" class="btn-primary">Start Radio</button>
<button id="btn-stop-radio" class="btn-secondary">Stop</button>
</div>
</div>
<!-- Appearance -->
<div class="settings-card">
<h3 class="settings-card-title">Appearance</h3>
<div class="row theme-row">
<span class="theme-row-label">Theme</span>
<div class="seg-control" id="theme-seg">
<button class="seg-btn" data-theme="light">Light</button>
<button class="seg-btn" data-theme="dark">Dark</button>
<button class="seg-btn" data-theme="system">System</button>
</div>
</div>
</div>
<!-- Help -->
<details class="settings-card help-card" open>
<summary class="settings-card-title">Help — how this works</summary>
<div class="help-content">
<h4>What this is</h4>
<p>A browser-based Reticulum messaging client. It generates your
cryptographic identity locally in your browser, talks to a radio or
daemon for transport, and exchanges end-to-end encrypted LXMF messages
with other Reticulum nodes (Sideband, MeshChat, NomadNet, other RNodes).
Nothing leaves your browser except over the transport you connect to.</p>
<h4>Three ways to connect</h4>
<ul>
<li><strong>BLE</strong> — Web Bluetooth to an RNode LoRa modem.
Works on Chrome, Edge, and Brave on Android or desktop.
Primary path for LoRa.</li>
<li><strong>Serial</strong> — Web Serial to a USB-connected RNode.
Desktop Chrome/Edge/Brave.</li>
<li><strong>WebSocket</strong> — connect to a local or remote
<code>rnsd</code> Reticulum daemon through the small Python bridge
in <code>tools/ws_bridge.py</code>. Works in every modern browser
including Safari, Firefox, and iOS. Use this when you do not have
an RNode plugged in or when the browser does not have Web Bluetooth.
See the <strong>TCP (WebSocket) connection</strong> section in the
README for setup.</li>
</ul>
<h4>Your identity</h4>
<p>The first time you load the page, the client generates a fresh
Ed25519 / X25519 identity and stores it in your browser's IndexedDB.
Your LXMF address (the 16-byte hash shown under <em>Identity</em>)
is derived from that identity and stays the same as long as you do not
click <em>New Identity</em>. The <em>Export Identity</em> button writes
the private keys to a JSON file so you can move the identity to a
different browser or back it up; <em>Import Identity</em> loads such a
file back in (replacing the current identity, so export it first if
you want to keep it). Never share that file with anyone you do not
trust with your messages.</p>
<h4>Announcing yourself</h4>
<p>Clicking <em>Announce now</em> broadcasts your identity and display
name to the Reticulum network. Other nodes learn how to reach you and
show you in their contact lists. The client also auto-announces once
at connect time and every five minutes after that so the network's
path tables stay warm. Without the periodic re-announce, some nodes
will silently drop link deliveries addressed to you.</p>
<h4>Contacts and Nodes</h4>
<p>When the client receives an announce from another LXMF delivery
destination, it appears in the <strong>Messages</strong> contact list. Click
a contact to open the conversation.</p>
<p>Announces from non-LXMF destinations — repeater telemetry
beacons, heartbeats, auxiliary destinations — never appear in
Messages because they are not people you can talk to. They show up
in the <strong>Nodes</strong> view instead, with last-seen time, RSSI,
and destination hash. That gives you visibility into the rest of the
mesh without cluttering your conversations.</p>
<h4>Sending messages and what the marks mean</h4>
<p>Type in the compose box and hit Enter (or click Send). A new row
appears in the conversation view with a small status glyph next to
the timestamp that updates as the send progresses:</p>
<ul class="state-legend">
<li><span class="message-state pending">⏳</span> <strong>pending</strong>
— the radio is off or the previous send attempt failed;
the retry tick will pick it up when the radio is up again.</li>
<li><span class="message-state sending">↑</span> <strong>sending</strong>
— the packet is in flight to the radio right now.</li>
<li><span class="message-state sent">✓</span> <strong>sent</strong>
— the radio has transmitted, but no delivery receipt has
come back from the recipient yet.</li>
<li><span class="message-state delivered">✓✓</span> <strong>delivered</strong>
— the recipient sent back a delivery proof. The message
was received and acknowledged.</li>
<li><span class="message-state failed">✗</span> <strong>failed</strong>
— all retries are exhausted. Hover for the last error.</li>
</ul>
<p>If a send times out waiting for a delivery proof, the retry tick
re-transmits it automatically with a 5s / 15s / 60s backoff (three
attempts total, then failed).</p>
<h4>Privacy</h4>
<p>All Reticulum protocol work — identity, ECDH, HKDF, AES-CBC,
HMAC, Ed25519 signing, LXMF packing — happens inside your browser.
The radio or daemon on the other end of the transport only sees
encrypted packets; it does not see your private keys and cannot decrypt
your messages. Message history is stored in IndexedDB, never uploaded
anywhere. Closing or clearing the site data wipes everything local.</p>
<h4>Security and trust model</h4>
<p>This section describes what the app protects and what it does not,
so you can make an informed decision about the risks you accept.</p>
<ul>
<li><strong>End-to-end encryption</strong> — every LXMF message is
encrypted with a fresh ephemeral X25519 key exchange and AES-256-CBC
+ HMAC before it leaves your browser. Neither the radio, the
transport daemon, nor any relay node can read message content.
Delivery receipts on link-delivered messages are signed with
Ed25519 and are unforgeable.</li>
<li><strong>Private keys at rest</strong> — your Ed25519 signing key,
X25519 encryption key, and ratchet key are stored
<em>unencrypted</em> in your browser's IndexedDB. Anyone with
access to your browser profile (browser extensions, device backup
tools, physical access to an unlocked device) can extract them.
The <em>Export Identity</em> file is likewise unencrypted JSON
containing the full private keys. Treat it like a password and
store it somewhere safe.</li>
<li><strong>Forward secrecy</strong> — the ratchet key is generated
once when your identity is created and is never rotated. It does
not provide forward secrecy in the current implementation. If an
attacker obtains your private key, they can decrypt past messages
they previously captured.</li>
<li><strong>BLE transport</strong> — the Bluetooth link between your
device and the RNode modem is <em>not</em> encrypted at the radio
layer (no BLE bonding). An observer within Bluetooth range (~10 m)
can see the encrypted Reticulum packets and their headers
(destination hashes, packet types, sizes, timing) but cannot
decrypt the message content inside them.</li>
<li><strong>WebSocket transport</strong> — when connecting to a
remote <code>rnsd</code> daemon over <code>ws://</code> (not
<code>wss://</code>), Reticulum packet headers are visible to
any network observer on the path. Message content remains
end-to-end encrypted. Use <code>wss://</code> for remote
connections if your bridge supports TLS. A warning banner appears
in the app when a non-localhost <code>ws://</code> connection is
active.</li>
<li><strong>Metadata</strong> — Reticulum packet headers contain
destination hashes in cleartext by design. Any observer on the
radio channel or transport path can see <em>who</em> is
communicating with <em>whom</em> (by destination hash) and the
timing and size of packets, even though they cannot read the
content. Periodic announces broadcast your public key, display
name, and destination hash to the mesh.</li>
<li><strong>Map tiles</strong> — the Nodes map loads tiles from
OpenStreetMap (<code>tile.openstreetmap.org</code>). The tile
server sees your IP address and the geographic area you are
viewing, though it does not see your identity or messages.</li>
</ul>
</div>
</details>
<!-- Log -->
<details class="settings-card log-card">
<summary class="settings-card-title">Diagnostics log</summary>
<div class="log-controls">
<button id="btn-clear-log" class="btn-secondary">Clear</button>
</div>
<div id="log"></div>
</details>
<!-- About -->
<div class="settings-card about-card">
<h3 class="settings-card-title">About</h3>
<div class="about-row">
<span class="about-label">Version</span>
<span class="version">v0.3.1<!-- BUILD_SHA --></span>
</div>
<div class="about-row">
<span class="about-label">Source</span>
<a href="https://github.com/thatSFguy/reticulum-lora-webclient" target="_blank" rel="noopener">github.com/thatSFguy/reticulum-lora-webclient</a>
</div>
<div class="about-row">
<span class="about-label">Privacy</span>
<a href="privacy.html" target="_blank" rel="noopener">Privacy statement</a>
</div>
</div>
</section>
<!-- NomadNet browser -->
<section class="view view-nomadnet">
<div class="nn-toolbar">
<button id="nn-back" class="btn-icon" title="Back" disabled>‹</button>
<button id="nn-forward" class="btn-icon" title="Forward" disabled>›</button>
<button id="nn-reload" class="btn-icon" title="Reload">⟳</button>
<input id="nn-address" class="nn-address" type="text" autocomplete="off" autocapitalize="off" spellcheck="false"
placeholder="node hash (32 hex) e.g. a1b2…:/page/index.mu" />
<button id="nn-go" class="btn-primary">Go</button>
<button id="nn-bookmark" class="btn-icon" title="Bookmark this page">☆</button>
</div>
<div class="nn-body">
<aside class="nn-sidebar">
<input id="nn-search" class="list-search" type="text" placeholder="Filter nodes & bookmarks…"
autocomplete="off" autocapitalize="off" spellcheck="false">
<div id="nn-nodes" class="nn-nodes-list"></div>
</aside>
<div class="nn-main">
<div id="nn-status" class="nn-status nn-status-info">Pick a NomadNet node, or enter a node hash above.</div>
<article id="nn-page" class="nn-page"></article>
</div>
</div>
</section>
</main>
<!-- Mobile scroll hint — shown on first paint on narrow viewports
to prompt the user to scroll so the browser's URL bar
collapses and the full layout becomes visible. Dismissed on
first scroll or after a few seconds. -->
<div id="scroll-hint" class="scroll-hint">
<svg viewBox="0 0 16 16"><path d="M8 2v10M4 8l4 4 4-4" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span>Scroll to hide browser bar</span>
</div>
<!-- Bottom nav (mobile only) -->
<nav class="bottomnav">
<button class="bn-item active" data-view="messages">
<svg viewBox="0 0 20 20"><path d="M3 4h14v10H11.5l-3 3V14H3V4z" stroke="currentColor" stroke-width="1.3" fill="none"/></svg>
<span>Messages</span>
</button>
<button class="bn-item" data-view="nodes">
<svg viewBox="0 0 20 20"><circle cx="10" cy="10" r="7" stroke="currentColor" stroke-width="1.3" fill="none"/><path d="M3 10h14M10 3c-2 3.5-2 10.5 0 14M10 3c2 3.5 2 10.5 0 14" stroke="currentColor" stroke-width="1.2" fill="none"/></svg>
<span>Nodes</span>
</button>
<button class="bn-item" data-view="nomadnet">
<svg viewBox="0 0 20 20"><rect x="3" y="4" width="14" height="12" rx="1" stroke="currentColor" stroke-width="1.3" fill="none"/><path d="M3 8h14" stroke="currentColor" stroke-width="1.3"/></svg>
<span>Browser</span>
</button>
<button class="bn-item" data-view="settings">
<svg viewBox="0 0 20 20"><circle cx="10" cy="10" r="3" stroke="currentColor" stroke-width="1.3" fill="none"/><path d="M10 2v2M10 16v2M2 10h2M16 10h2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
<span>Settings</span>
</button>
</nav>
</div>
<!-- Add / share contact: paste a contact card or hash, scan a QR, or copy
your own card for someone else to add. -->
<div id="add-modal" class="modal-overlay hidden">
<div class="modal">
<h2 class="modal-title">Add / share contact</h2>
<p class="modal-text">Paste a contact card or a destination hash from another Reticulum app, or scan its QR.</p>
<textarea id="add-input" class="add-input" rows="3" spellcheck="false" autocapitalize="off"
placeholder='{"destHash":"…","publicKey":"…"} — or a 32-character hex hash'></textarea>
<video id="add-video" class="add-video hidden" playsinline muted></video>
<div id="add-status" class="modal-hint"></div>
<div class="modal-actions">
<button id="add-scan" class="btn-secondary">Scan QR</button>
<div class="modal-buttons">
<button id="add-cancel" class="btn-secondary">Close</button>
<button id="add-submit" class="btn-primary">Add</button>
</div>
</div>
<hr class="mu-hr">
<div class="dest-fact-label">Your address card — share so others can add you</div>
<code id="my-card" class="add-mycard"></code>
<div class="modal-actions"><span class="modal-hint">QR image export is coming next; for now copy & share the text.</span>
<button id="my-card-copy" class="btn-secondary">Copy</button></div>
</div>
</div>
<!-- Destruct confirmation. Reuses the modal-overlay pattern. -->
<div id="destruct-modal" class="modal-overlay hidden">
<div class="modal destruct-modal">
<div class="destruct-modal-icon">🔥</div>
<h2 class="modal-title">Burn it all?</h2>
<p class="modal-text">
This permanently erases <strong>everything stored on this device</strong> —
your identity keys, all contacts, conversations, discovered nodes,
bookmarks, and settings. It cannot be undone, and anyone who messaged
this identity will no longer be able to reach you.
</p>
<div class="modal-actions">
<div class="modal-buttons">
<button id="destruct-cancel" class="btn-secondary">Cancel</button>
<button id="destruct-confirm" class="btn-danger">Burn everything</button>
</div>
</div>
</div>
</div>
<!-- Destination detail sheet (full hash, facts, and Message / Contact /
Delete actions). Populated dynamically by openDestDetail(). -->
<div id="dest-modal" class="modal-overlay hidden">
<div class="modal dest-sheet" id="dest-modal-card"></div>
</div>
<!-- TCP / rnsd connection dialog (reachable from the main screen so
Settings isn't required to connect over TCP). -->
<div id="tcp-modal" class="modal-overlay hidden">
<div class="modal">
<h2 class="modal-title">Connect via TCP</h2>
<p class="modal-text">
Reach a Reticulum daemon (<code>rnsd</code>) through a local WebSocket
bridge. Leave the bridge URL as-is for a bridge on this machine. The
daemon is prefilled with a public hub at random — tap ↻ for another,
or type your own.
</p>
<div class="field wide">
<label>WebSocket bridge URL</label>
<input id="tcp-url" type="text" value="ws://localhost:7878" placeholder="ws://localhost:7878"
autocomplete="off" autocapitalize="off" spellcheck="false">
</div>
<div class="field wide">
<label>Reticulum daemon (host:port)</label>
<div class="hub-row">
<input id="tcp-rnsd" type="text" list="rns-hubs" placeholder="pick a public hub or enter host:port"
autocomplete="off" autocapitalize="off" spellcheck="false">
<button type="button" class="hub-shuffle js-hub-shuffle" data-target="tcp-rnsd" title="Pick another public hub" aria-label="Pick another public hub">↻</button>
</div>
</div>
<div class="modal-actions">
<span class="modal-hint">No bridge running? You'll get a one-click download next.</span>
<div class="modal-buttons">
<button id="tcp-cancel" class="btn-secondary">Cancel</button>
<button id="tcp-connect" class="btn-primary">Connect</button>
</div>
</div>
</div>
</div>
<!-- Curated public Reticulum hubs, shared by the Settings + TCP-modal
rnsd-target inputs. Options are populated from RNS_HUBS in app.js so
the list lives in one place. -->
<datalist id="rns-hubs"></datalist>
<!-- Bridge-not-detected helper (shown when a local WebSocket/TCP
connection fails because the ws_bridge isn't running). -->
<div id="bridge-modal" class="modal-overlay hidden">
<div class="modal">
<h2 class="modal-title">Bridge not running</h2>
<p class="modal-text">
Browsers can't open raw TCP connections, so a small local
<strong>WebSocket bridge</strong> has to be running to reach a Reticulum
daemon over TCP. Nothing answered at <code id="bridge-target"></code>.
</p>
<ol class="modal-steps">
<li>
<a id="bridge-download" class="btn-primary" href="#" rel="noopener">Download the bridge</a>
<span id="bridge-os-note" class="modal-hint"></span>
</li>
<li>Run the downloaded file.
<span class="modal-hint">On Windows, if SmartScreen warns, click “More info → Run anyway” — the binary is unsigned.</span>
</li>
<li>This window connects automatically once it's running.
<span id="bridge-wait" class="modal-hint">Waiting for the bridge…</span>
</li>
</ol>
<div class="modal-actions">
<a id="bridge-all" class="modal-link" href="https://github.com/thatSFguy/reticulum-lora-webclient/releases?q=bridge-v" target="_blank" rel="noopener">All platforms & checksums</a>
<div class="modal-buttons">
<button id="bridge-retry" class="btn-secondary">Retry now</button>
<button id="bridge-close" class="btn-secondary">Cancel</button>
</div>
</div>
</div>
</div>
<!-- Import map: resolve bare specifiers to self-hosted bundles in lib/ -->
<script type="importmap">
{
"imports": {
"@noble/curves/ed25519": "./lib/noble-curves-ed25519.js",
"@msgpack/msgpack": "./lib/msgpack.js"
}
}
</script>
<script type="module" src="js/app.js"></script>
</body>
</html>