Repository navigation
Expand file tree
/
Copy pathsettings.html
More file actions
450 lines (431 loc) · 23.5 KB
/
Copy pathsettings.html
File metadata and controls
450 lines (431 loc) · 23.5 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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>settings — fork·watch</title>
<meta name="description" content="fork·watch settings: the miner league (non-standard datacarrier by pool, per difficulty epoch) and witness configuration.">
<link rel="canonical" href="https://bitcoin-kernel.com/forks/settings.html">
<meta name="theme-color" content="#1a1214">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='5' fill='%231a1214'/%3E%3Cpath d='M16 26V16M16 16L9 8M16 16l7-8' stroke='%23f7931a' stroke-width='3' stroke-linecap='round'/%3E%3Ccircle cx='9' cy='7' r='2.6' fill='%23dc3545'/%3E%3Ccircle cx='23' cy='7' r='2.6' fill='%232fbf6b'/%3E%3C/svg%3E">
<style>
:root {
--bg: #141117; --nav-bg: #201b25; --panel-bg: #262030; --stat-box-bg: #100d13;
--border: #363043; --fg: #ffffff; --muted: #988fa8; --link: #00ddff; --accent: #f7931a;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { background: var(--bg); color: var(--fg); min-height: 100vh;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased; }
a { color: var(--link); text-decoration: none; } a:hover { text-decoration: underline; }
header.nav { display: flex; align-items: center; gap: .4rem; padding: .85rem 1.4rem;
background: var(--nav-bg); border-bottom: 1px solid var(--border); }
header.nav .brand { font-size: 1.15rem; font-weight: 600; color: var(--fg); }
header.nav .brand:hover { text-decoration: none; }
header.nav .brand .dot { color: var(--accent); }
header.nav .menu { display: flex; gap: .3rem; margin-left: .4rem; }
header.nav .menu a { color: var(--muted); font-size: .84rem; padding: .25rem .6rem; border-radius: 6px; }
header.nav .menu a:hover { color: var(--fg); background: #ffffff0d; text-decoration: none; }
header.nav .menu a.active { color: var(--fg); background: #ffffff14; }
.wrap { max-width: 820px; margin: 0 auto; padding: 2.2rem 1.4rem 4rem; }
h1 { font-size: 1.8rem; margin: 0 0 .3rem; }
.tagline { color: var(--muted); font-size: .98rem; margin: 0 0 1.8rem; line-height: 1.5; }
h2 { font-size: 1.05rem; margin: 2.2rem 0 .3rem; }
.h2note { color: var(--muted); font-size: .8rem; margin: 0 0 1rem; line-height: 1.5; }
code { background: var(--stat-box-bg); padding: .12rem .4rem; border-radius: 4px; color: #cfe; font-size: .85em;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
table.league { width: 100%; border-collapse: collapse; font-size: .86rem; }
table.league th { text-align: left; color: var(--muted); font-weight: 500; font-size: .68rem; text-transform: uppercase;
letter-spacing: .05em; padding: 0 .6rem .4rem; }
table.league th.num, table.league td.num { text-align: right; font-variant-numeric: tabular-nums; }
table.league td { padding: .45rem .6rem; border-top: 1px solid var(--border); vertical-align: middle; }
table.league tr:first-child td { border-top: none; }
table.league .rank { color: var(--muted); font-variant-numeric: tabular-nums; width: 1.6rem; }
table.league .share { width: 36%; }
table.league .bar { height: 8px; border-radius: 4px; background: #100d1399; overflow: hidden; box-shadow: inset 0 0 0 1px #ffffff10; }
table.league .bar > i { display: block; height: 100%; border-radius: 4px; background: var(--accent); }
.panel { background: var(--panel-bg); border: 1px solid var(--border); border-radius: 12px; padding: 1.2rem 1.3rem; }
label { display: block; font-size: .82rem; color: var(--muted); margin: .9rem 0 .3rem; }
label:first-child { margin-top: 0; }
input[type=text], textarea { width: 100%; background: var(--stat-box-bg); border: 1px solid var(--border);
color: var(--fg); border-radius: 8px; padding: .6rem .7rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: .82rem; resize: vertical; }
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--link); }
select { background: var(--stat-box-bg); border: 1px solid var(--border); color: var(--fg); border-radius: 8px;
padding: .45rem .6rem; font: inherit; font-size: .82rem; }
.hint { font-size: .74rem; color: var(--muted); margin-top: .3rem; }
.row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; align-items: center; }
button { font: inherit; font-size: .85rem; font-weight: 600; padding: .55rem 1.1rem; border-radius: 8px;
border: 1px solid var(--border); background: var(--stat-box-bg); color: var(--fg); cursor: pointer; }
button.primary { background: var(--accent); border-color: var(--accent); color: #201400; }
button:hover { filter: brightness(1.08); }
button:disabled { opacity: .45; cursor: default; }
button:disabled:hover { filter: none; }
#storage { font-size: .78rem; color: var(--muted); }
#storage b { color: var(--fg); }
#storage .sbar { height: 8px; border-radius: 4px; background: #100d1399; overflow: hidden;
box-shadow: inset 0 0 0 1px #ffffff10; margin: .3rem 0 .55rem; }
#storage .sbar > i { display: block; height: 100%; background: var(--accent); border-radius: 4px; transition: width .3s; }
.saved { color: #6fe6a0; font-size: .82rem; opacity: 0; transition: opacity .2s; }
.saved.show { opacity: 1; }
.empty { color: var(--muted); font-size: .9rem; padding: .5rem 0; }
footer { color: var(--muted); font-size: .8rem; margin-top: 2.6rem; border-top: 1px solid var(--border); padding-top: 1.2rem; line-height: 1.6; }
</style>
</head>
<body>
<header class="nav">
<a class="brand" href="./">fork<span class="dot">·</span>watch</a>
<nav class="menu">
<a href="./">Watch</a>
<a href="./settings.html" class="active">Settings</a>
</nav>
</header>
<div class="wrap">
<h1>Settings</h1>
<p class="tagline">The miner league from your scanned blocks, and which witnesses the Watch page follows.
Everything stays in your browser — nothing is sent anywhere.</p>
<h2>Miner league <span style="font-size:.7rem;color:var(--muted);font-weight:400">— non-standard datacarrier by pool, per 2016-block epoch</span></h2>
<p class="h2note">Which pools include the most OP_RETURNs over the 80/83-byte standard — an inclusion-policy
scoreboard, ranked per block mined so hashrate share doesn't decide the table. Attribution is the pool's own
coinbase tag, read from blocks this browser fetched and verified itself. When a chain split appears on the
Watch page, this is each arm's form guide.</p>
<div class="row" style="margin:0 0 .8rem"><select id="pool-epoch" style="display:none"></select></div>
<div id="poolleague"><span class="empty">No blocks scanned yet — new blocks are fetched, verified and classified in the background while this page (or the Watch page) is open.</span></div>
<div class="row" id="fillrow" style="display:none; margin-top:.8rem">
<button id="fill">Scan 100 more</button>
<span class="hint" id="fill-status" style="margin-top:0"></span>
</div>
<div id="storage" style="display:none; margin-top:1.1rem"></div>
<h2>Witnesses</h2>
<p class="h2note">Which NIP-333 publishers and relays the Watch page polls (one per line). Explorer witnesses
(mempool.space, blockstream.info) are always on. Mesh witnesses join via a
<code>?peer=wss://…&room=…</code> link, or set them here. Changes apply when the Watch page reloads.</p>
<div class="panel">
<label for="pubs">Publisher public keys <span style="opacity:.7">(64-char hex, one per line)</span></label>
<textarea id="pubs" rows="2" spellcheck="false" placeholder="cccccc0a…"></textarea>
<div class="hint" id="pubs-hint"></div>
<label for="relays">Relays <span style="opacity:.7">(one wss:// URL per line)</span></label>
<textarea id="relays" rows="3" spellcheck="false" placeholder="wss://relay.damus.io"></textarea>
<div class="hint" id="relays-hint"></div>
<label for="sig">Mesh signaling server <span style="opacity:.7">(wss:// …/.webrtc — optional)</span></label>
<input type="text" id="sig" spellcheck="false" autocomplete="off" placeholder="wss://your.pod/.webrtc">
<label for="room">Mesh room</label>
<input type="text" id="room" spellcheck="false" autocomplete="off" placeholder="b17c0100b10c48ea1710 (hex, 8–128 chars)">
<div class="hint" id="peer-status" style="margin-top:.7rem"></div>
<div class="row">
<button class="primary" id="save">Save</button>
<button id="reset">Reset to defaults</button>
<span class="saved" id="saved">saved ✓</span>
</div>
</div>
<footer>
Config and the scan ledger live only in this browser's <code>localStorage</code>. Pool attribution is the
miner's own coinbase stamp; blocks are verified by hash before they're counted — a lying explorer convicts itself.
</footer>
</div>
<script type="module">
import { PeerSource, iceServersFrom } from './peer-source.js';
const DEFAULT_PUBLISHERS = ['cccccc0a8338a4aba51691f309f2f892c6c58f655a1ec3fc30522c894c166d9c'];
const DEFAULT_RELAYS = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net'];
const EPOCH = 2016;
const $ = (id) => document.getElementById(id);
const fmt = (n) => Number(n).toLocaleString('en-US');
function readCfg() { try { return JSON.parse(localStorage.getItem('forkwatch.config') || '{}'); } catch { return {}; } }
function writeCfg(cfg) { try { localStorage.setItem('forkwatch.config', JSON.stringify(cfg)); } catch {} }
// ---- miner league (ledger written by the Watch page's scanner) ----
// entry: hash -> [height, pool|null, nonStd]
let epochChoice = null;
function renderLeague() {
let led = {};
try { led = JSON.parse(localStorage.getItem('forkwatch.classified') || '{}'); } catch {}
const entries = Object.values(led);
const el = $('poolleague'), sel = $('pool-epoch');
if (!entries.length) { sel.style.display = 'none'; $('fillrow').style.display = 'none'; return; }
sel.style.display = '';
$('fillrow').style.display = '';
const byEpoch = new Map();
for (const e of entries) {
const ep = Math.floor(e[0] / EPOCH);
if (!byEpoch.has(ep)) byEpoch.set(ep, []);
byEpoch.get(ep).push(e);
}
const epochs = [...byEpoch.keys()].sort((a, b) => b - a);
const sig = epochs.join(',');
if (sel.dataset.epochs !== sig) {
sel.dataset.epochs = sig;
sel.innerHTML = epochs.map((ep) => `<option value="${ep}">epoch ${fmt(ep)} · blocks #${fmt(ep * EPOCH)}–#${fmt(ep * EPOCH + EPOCH - 1)}</option>`).join('');
}
if (epochChoice == null || !byEpoch.has(epochChoice)) epochChoice = epochs[0];
sel.value = String(epochChoice);
const pools = new Map();
for (const e of byEpoch.get(epochChoice)) {
const name = e[1] || 'unrecognised';
const p = pools.get(name) || { blocks: 0, ns: 0 };
p.blocks++; p.ns += e[2] || 0;
pools.set(name, p);
}
const rows = [...pools.entries()]
.map(([name, p]) => ({ name, ...p, rate: p.ns / p.blocks }))
.sort((a, b) => b.rate - a.rate || b.ns - a.ns || b.blocks - a.blocks);
const maxNs = Math.max(1, ...rows.map((r) => r.ns));
const covered = byEpoch.get(epochChoice).length;
// the newest epoch is still being mined: its ceiling is the tip, not 2,016.
// Best local tip estimate = highest scanned height (the tip sweep keeps it fresh).
const tipH = Math.max(...byEpoch.get(epochChoice).map((e) => e[0]));
const inProgress = epochChoice === epochs[0] && tipH < epochChoice * EPOCH + EPOCH - 1;
const denom = inProgress ? tipH - epochChoice * EPOCH + 1 : EPOCH;
const denomNote = inProgress ? `blocks mined so far in this epoch (of ${fmt(EPOCH)} when it completes)` : 'blocks in this epoch';
const remaining = Math.max(0, denom - covered);
$('fill').disabled = !remaining;
$('fill').textContent = remaining ? `Scan ${fmt(Math.min(FILL_BATCH, remaining))} more` : 'Fully scanned ✓';
el.innerHTML = `<table class="league"><thead><tr><th class="rank">#</th><th>pool</th><th class="num">blocks</th>
<th class="num">non-std</th><th class="num">per block</th><th class="share">share</th></tr></thead><tbody>
${rows.map((r, i) => `<tr>
<td class="rank">${i + 1}</td>
<td><b>⛏ ${r.name}</b></td>
<td class="num">${fmt(r.blocks)}</td>
<td class="num"><b>${fmt(r.ns)}</b></td>
<td class="num">${r.rate.toFixed(1)}</td>
<td class="share"><div class="bar"><i style="width:${(100 * r.ns / maxNs).toFixed(1)}%"></i></div></td>
</tr>`).join('')}</tbody></table>
<p class="h2note" style="margin-top:.6rem">${fmt(covered)} of ${fmt(denom)} ${denomNote} scanned —
a sample from your watching, not a census. Higher per-block = the pool's policy admits more oversize datacarrier.</p>`;
}
$('pool-epoch').addEventListener('change', (e) => { epochChoice = Number(e.target.value); renderLeague(); });
// ---- self-sufficient scanning: Settings drives the same scan-worker the
// Watch page uses, so the league keeps filling in with just this tab open.
// Candidate hashes come from esplora tips; the worker re-verifies both the
// bytes (hash) and the header's proof-of-work, so nothing is taken on faith.
// Cross-tab dedupe: the ledger is re-read fresh before every enqueue, and
// entries are keyed by hash, so a race with the Watch tab is harmless.
const readLedger = () => { try { return JSON.parse(localStorage.getItem('forkwatch.classified') || '{}'); } catch { return {}; } };
const SCAN_CAP = 6000;
function recordScan(hash, entry) {
const led = readLedger();
led[hash] = entry;
const keys = Object.keys(led);
if (keys.length > SCAN_CAP) {
keys.map((k) => [k, led[k][0]]).sort((a, b) => a[1] - b[1])
.slice(0, keys.length - SCAN_CAP).forEach(([k]) => delete led[k]);
}
try { localStorage.setItem('forkwatch.classified', JSON.stringify(led)); } catch {}
}
const scanQueue = [];
const scanPending = new Set();
let scanReady = false, scanBusy = false;
const scanner = new Worker('./scan-worker.js', { type: 'module' });
scanner.postMessage({ type: 'init' });
scanner.onmessage = (ev) => {
const m = ev.data;
if (m.type === 'ready') { scanReady = true; pumpScan(); return; }
scanBusy = false;
scanPending.delete(m.hash);
if (m.type === 'scanned') { recordScan(m.hash, [m.height, m.pool || null, m.nonStd || 0]); renderLeague(); }
if (m.type === 'scanerr' && m.height != null) backfillSkip.add(m.height);
pumpScan();
maybeBackfill();
};
async function pumpScan() {
if (!scanReady || scanBusy || !scanQueue.length) return;
scanBusy = true;
const job = scanQueue.shift();
// peers first (verified bytes over WebRTC), esplora as the fallback
if (mesh && mesh.status().connected) {
try {
const bytes = await mesh.requestBlock(job.hash, job.height, 8000, true);
if (bytes) { scanner.postMessage({ type: 'scanBytes', ...job, bytes }, [bytes.buffer]); return; }
} catch { /* fall through to esplora */ }
}
scanner.postMessage({ type: 'scan', ...job });
}
// mesh: with a signaling server configured, this tab quietly joins the
// block·health room and pulls scanned blocks from peers' caches — a
// store-less PeerSource that advertises and serves nothing.
let mesh = null;
{
const w = readCfg().webrtc;
if (w?.signalUrl) {
mesh = new PeerSource({ signalUrl: w.signalUrl, room: w.room || 'b17c0100b10c48ea1710', iceServers: iceServersFrom(w), store: null,
onStatus: () => renderPeerStatus() });
mesh.start();
}
}
// health-style live mesh readout in the witness panel
function renderPeerStatus() {
const el = $('peer-status');
if (!el) return;
if (!readCfg().webrtc?.signalUrl) { el.textContent = 'Mesh disabled — set a signaling server above to pull blocks from peers.'; return; }
if (!mesh) { el.textContent = 'Mesh configured — reload this page to join.'; return; }
const s = mesh.status();
const path = s.peers?.find((p) => p.path)?.path;
el.innerHTML = `<b style="color:var(--fg)">${fmt(s.connected || 0)}</b> peer${s.connected === 1 ? '' : 's'} connected · `
+ `<b style="color:var(--fg)">${fmt(s.received || 0)}</b> block${s.received === 1 ? '' : 's'} pulled`
+ (path ? ` · via <span style="color:var(--link)">${path}</span>` : '')
+ ' · <span style="opacity:.65">live</span>';
}
async function sweepTip() {
for (const base of ['https://mempool.space/api', 'https://blockstream.info/api']) {
try {
const r = await fetch(`${base}/blocks`, { signal: AbortSignal.timeout(15000) });
if (!r.ok) continue;
const blocks = await r.json();
const led = readLedger(); // fresh read — dedupes against a scanning Watch tab
for (const b of blocks) {
if (!/^[0-9a-f]{64}$/.test(b.id) || led[b.id] || scanPending.has(b.id)) continue;
scanPending.add(b.id);
scanQueue.push({ hash: b.id, height: b.height });
}
pumpScan();
return;
} catch { /* next source */ }
}
}
sweepTip();
setInterval(sweepTip, 60000);
// ---- incremental backfill: grow the selected epoch 100 blocks at a time.
// Heights are resolved to hashes via esplora, then go through the same
// worker (bytes-hash + PoW verified), one block every ~1.5s so tip scans
// and the explorers are never crowded. The ledger is the resume state.
const FILL_BATCH = 100, FILL_GAP = 1500;
let fillLeft = 0, fillTimer = null;
const backfillSkip = new Set(); // heights that errored this session — don't retry in a loop
async function hashAtHeight(h) {
for (const base of ['https://mempool.space/api', 'https://blockstream.info/api']) {
try {
const r = await fetch(`${base}/block-height/${h}`, { signal: AbortSignal.timeout(15000) });
if (!r.ok) continue;
const t = (await r.text()).trim();
if (/^[0-9a-f]{64}$/.test(t)) return t;
} catch { /* next source */ }
}
return null;
}
function maybeBackfill() {
renderFillUI();
if (fillLeft <= 0 || fillTimer || !scanReady || scanBusy || scanQueue.length) return;
fillTimer = setTimeout(() => { fillTimer = null; backfillStep(); }, FILL_GAP);
}
async function backfillStep() {
if (fillLeft <= 0) { renderFillUI(); return; }
const led = readLedger(); // fresh — respects blocks another tab scanned meanwhile
const heights = new Set(Object.values(led).map((e) => e[0]));
const epochs = [...new Set([...heights].map((h) => Math.floor(h / EPOCH)))];
const ep = epochs.includes(epochChoice) ? epochChoice : Math.max(...epochs);
const lo = ep * EPOCH;
// newest epoch: walk down from its highest scanned block (heights above are
// the tip sweep's job); an older epoch: walk down from the epoch's top.
let h = ep === Math.max(...epochs)
? Math.max(...[...heights].filter((x) => x >= lo && x < lo + EPOCH))
: lo + EPOCH - 1;
while (h >= lo && (heights.has(h) || backfillSkip.has(h))) h--;
if (h < lo) { fillLeft = 0; renderFillUI(); return; } // epoch fully scanned
const hash = await hashAtHeight(h);
if (!hash) { backfillSkip.add(h); maybeBackfill(); return; }
fillLeft--;
if (!led[hash] && !scanPending.has(hash)) {
scanPending.add(hash);
scanQueue.push({ hash, height: h });
pumpScan();
}
maybeBackfill();
}
function renderFillUI() {
const s = $('fill-status');
if (!s) return;
const parts = [];
if (fillLeft > 0) parts.push(`backfilling — ${fillLeft} left in this batch…`);
if (mesh) {
const st = mesh.status();
parts.push(`mesh: ${st.connected} peer${st.connected === 1 ? '' : 's'}${st.received ? ` · ${st.received} block${st.received === 1 ? '' : 's'} via peers` : ''}`);
}
s.textContent = parts.join(' — ');
}
$('fill').addEventListener('click', () => {
fillLeft = Math.min(fillLeft + FILL_BATCH, 1000); // stackable, but bounded
maybeBackfill();
});
// ---- memory: what the scan ledger costs, and overall browser storage ----
function human(b) {
if (b == null) return '—';
if (b < 1024) return b + ' B';
if (b < 1e6) return (b / 1e3).toFixed(0) + ' KB';
if (b < 1e9) return (b / 1e6).toFixed(1) + ' MB';
return (b / 1e9).toFixed(2) + ' GB';
}
async function renderStorage() {
const el = $('storage');
const raw = localStorage.getItem('forkwatch.classified') || '';
const n = raw ? Object.keys(readLedger()).length : 0;
if (!n) { el.style.display = 'none'; return; }
el.style.display = '';
const ledPct = 100 * n / SCAN_CAP;
let html = `<div>Scan ledger: <b>${fmt(n)}</b> of <b>${fmt(SCAN_CAP)}</b> blocks · <b>${human(raw.length)}</b>
in localStorage — oldest evicted beyond the cap</div>
<div class="sbar"><i style="width:${Math.max(0.4, Math.min(100, ledPct)).toFixed(2)}%"></i></div>`;
try {
const { usage, quota } = await navigator.storage.estimate();
const pct = quota ? (100 * usage / quota) : 0;
const pctStr = pct === 0 ? '0' : pct < 0.1 ? '<0.1' : pct.toFixed(pct < 1 ? 1 : 0);
html += `<div>Browser storage: <b>${human(usage)}</b> of <b>${human(quota)}</b> (${pctStr}%)</div>
<div class="sbar"><i style="width:${Math.max(0.4, Math.min(100, pct)).toFixed(2)}%"></i></div>`;
} catch { /* estimate unsupported — ledger line still shows */ }
el.innerHTML = html;
}
// ---- witness config ----
function loadForm() {
const cfg = readCfg();
$('pubs').value = (Array.isArray(cfg.publishers) && cfg.publishers.length ? cfg.publishers : DEFAULT_PUBLISHERS).join('\n');
$('relays').value = (Array.isArray(cfg.relays) && cfg.relays.length ? cfg.relays : DEFAULT_RELAYS).join('\n');
$('sig').value = cfg.webrtc?.signalUrl || '';
$('room').value = cfg.webrtc?.room || '';
validate();
}
function parseForm() {
return {
pubs: $('pubs').value.split('\n').map((s) => s.trim().toLowerCase()).filter(Boolean),
relays: $('relays').value.split('\n').map((s) => s.trim()).filter(Boolean),
sig: $('sig').value.trim(),
room: $('room').value.trim(),
};
}
function validate() {
const { pubs, relays } = parseForm();
const badPub = pubs.find((p) => !/^[0-9a-f]{64}$/.test(p));
const badRelay = relays.find((r) => !/^wss?:\/\/.+/.test(r));
$('pubs-hint').textContent = badPub ? `Not 64-char hex: ${badPub.slice(0, 24)}…` : `${pubs.length} publisher${pubs.length === 1 ? '' : 's'}.`;
$('relays-hint').textContent = relays.length === 0 ? 'Add at least one relay.' : badRelay ? `Not a wss:// URL: ${badRelay}` : `${relays.length} relay${relays.length === 1 ? '' : 's'}.`;
return !badPub && !badRelay && pubs.length > 0 && relays.length > 0;
}
$('pubs').addEventListener('input', validate);
$('relays').addEventListener('input', validate);
$('save').addEventListener('click', () => {
if (!validate()) return;
const { pubs, relays, sig, room } = parseForm();
const cfg = readCfg();
if (JSON.stringify(pubs) !== JSON.stringify(DEFAULT_PUBLISHERS)) cfg.publishers = pubs; else delete cfg.publishers;
if (JSON.stringify(relays) !== JSON.stringify(DEFAULT_RELAYS)) cfg.relays = relays; else delete cfg.relays;
if (sig && /^wss?:\/\//.test(sig)) {
cfg.webrtc = { ...cfg.webrtc, signalUrl: sig }; // keep TURN fields a peering link may have set
if (room && /^[a-f0-9]{8,128}$/i.test(room)) cfg.webrtc.room = room.toLowerCase(); else delete cfg.webrtc.room;
} else delete cfg.webrtc;
writeCfg(cfg);
renderPeerStatus();
$('saved').classList.add('show'); setTimeout(() => $('saved').classList.remove('show'), 1600);
});
$('reset').addEventListener('click', () => {
localStorage.removeItem('forkwatch.config');
loadForm();
renderPeerStatus();
$('saved').classList.add('show'); setTimeout(() => $('saved').classList.remove('show'), 1600);
});
// live refresh while the Watch page scans in another tab
window.addEventListener('storage', (e) => { if (e.key === 'forkwatch.classified') { renderLeague(); renderStorage(); } });
setInterval(() => { renderLeague(); renderStorage(); renderFillUI(); renderPeerStatus(); }, 5000);
loadForm();
renderLeague();
renderStorage();
renderPeerStatus();
</script>
</body>
</html>