Op verzoek van de gebruiker is dit geen app meer over de labels van een bepaalde wallet, maar wat het technisch al was: een relay voor apps die op Evolu gebouwd zijn. De tagline, de beschrijving, de releaseNotes en de statuspagina noemen geen enkele client meer, en category gaat van bitcoin naar files, waar de officiele store ook synchronisatie tussen apparaten onderbrengt. De tekst leunt op wat Evolu zelf schrijft en niet op een verzinsel: local-first, end-to-end versleuteld voordat het je apparaat verlaat, en een relay die alleen een owner id, tijdstempels en gevulde blobs ziet. Daar staat expliciet bij wat zelf hosten wel en niet verandert, want geheimer wordt de data er niet van; wie de versleutelde kopie bewaart en wie kan zien dat je synchroniseert wel. De tegenwerping is niet weerlegd maar geaccepteerd, en dat staat als zodanig in open punt 7: "Evolu Relay" zegt een Umbrel-gebruiker niets, terwijl de labels van een bekende wallet een concrete reden zijn om te installeren. Vindbaarheid inleveren is hier een keuze. Blijkt het te knellen, dan is daar terug te lezen waarom. In het Nederlandse codecommentaar blijft de historische toelichting staan, want die legt uit waarom de relay van Trezor eruit is gegaan. Alleen de regels die de app beschrijven zijn generiek gemaakt. Tests: test_appstore_vorm 34 goed, 0 fout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
481 lines
16 KiB
Plaintext
481 lines
16 KiB
Plaintext
<!DOCTYPE html>
|
|
<!--
|
|
═══════════════════════════════════════════════════════════════════════════════
|
|
De statuspagina van Evolu Relay.
|
|
|
|
Zichtbare tekst is Engels: dit is een publieke app store en dit is wat een
|
|
bezoeker ziet. Commentaar is Nederlands.
|
|
|
|
LET OP, en dit is de valstrik van dit bestand: umbreld haalt elke *.template
|
|
bij het starten door envsubst. Er mag dus GEEN dollarteken in staan. Dat sluit
|
|
ook JavaScript-template-literals uit, want die gebruiken accolades achter een
|
|
dollarteken en zouden stilzwijgend leeg worden. Alles hieronder plakt strings
|
|
dus met een plus aan elkaar. tests/test_appstore_vorm.py houdt dit dicht.
|
|
|
|
Het ontwerp volgt HomeGit/Docs/website-design-system.html, net als Electrum
|
|
Gate, zodat de twee apps naast elkaar op hetzelfde dashboard herkenbaar zijn.
|
|
Wat er bewust NIET uit overgenomen is: de verwijzing naar Google Fonts. Een app
|
|
op een Umbrel hoort niet te wachten op een lettertype van buiten.
|
|
═══════════════════════════════════════════════════════════════════════════════
|
|
-->
|
|
<html lang="en" data-theme="dark" data-accent="orange">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Evolu Relay</title>
|
|
<link rel="icon" href="icon.png">
|
|
<style>
|
|
:root {
|
|
--radius-sm: 8px;
|
|
--radius-md: 14px;
|
|
--radius-lg: 20px;
|
|
--radius-full: 999px;
|
|
--font-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
--font-mono: 'SF Mono', 'Fira Code', ui-monospace, Menlo, Consolas, monospace;
|
|
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
|
}
|
|
|
|
[data-theme="dark"][data-accent="orange"] {
|
|
--bg: #080808;
|
|
--bg-elevated: #181818;
|
|
--bg-raised: #242424;
|
|
--border: rgba(255,255,255,0.18);
|
|
--text-primary: #f2f2f2;
|
|
--text-sec: #a3a3a3;
|
|
--text-ter: #6a6a6a;
|
|
--accent: #FF6B00;
|
|
--accent-soft: rgba(255,107,0,0.16);
|
|
--pos: #FF8124;
|
|
--pos-bg: rgba(255,107,0,0.18);
|
|
--neg: #ff5a5a;
|
|
--neg-bg: rgba(255,69,69,0.18);
|
|
--neutral-bg: rgba(255,255,255,0.08);
|
|
}
|
|
|
|
[data-theme="light"][data-accent="orange"] {
|
|
--bg: #ffffff;
|
|
--bg-elevated: #f7f7f7;
|
|
--bg-raised: #f0f0f0;
|
|
--border: rgba(0,0,0,0.12);
|
|
--text-primary: #0a0a0a;
|
|
--text-sec: #888888;
|
|
--text-ter: #bbbbbb;
|
|
--accent: #E05500;
|
|
--accent-soft: rgba(224,85,0,0.08);
|
|
--pos: #D04800;
|
|
--pos-bg: rgba(224,85,0,0.08);
|
|
--neg: #cc2222;
|
|
--neg-bg: rgba(204,34,34,0.08);
|
|
--neutral-bg: rgba(0,0,0,0.04);
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
body {
|
|
margin: 0;
|
|
padding: 24px 16px 64px;
|
|
background: var(--bg);
|
|
color: var(--text-primary);
|
|
font-family: var(--font-base);
|
|
font-size: 15px;
|
|
line-height: 1.5;
|
|
-webkit-font-smoothing: antialiased;
|
|
}
|
|
|
|
.wrap { max-width: 780px; margin: 0 auto; }
|
|
|
|
header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
header img { width: 40px; height: 40px; border-radius: var(--radius-sm); }
|
|
header h1 { font-size: 1.35rem; margin: 0; font-weight: 700; letter-spacing: -0.01em; }
|
|
header .spacer { flex: 1; }
|
|
|
|
.sub { color: var(--text-sec); margin: 0 0 24px; font-size: 0.92rem; }
|
|
|
|
.theme-toggle {
|
|
background: var(--bg-raised);
|
|
border: 1px solid var(--border);
|
|
color: var(--text-sec);
|
|
border-radius: var(--radius-full);
|
|
padding: 6px 14px;
|
|
font: inherit;
|
|
font-size: 0.8rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.card {
|
|
background: var(--bg-elevated);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-lg);
|
|
padding: 20px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.card h2 {
|
|
font-size: 0.78rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
color: var(--text-ter);
|
|
margin: 0 0 14px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.stat-row { display: flex; flex-wrap: wrap; gap: 24px; }
|
|
.stat { min-width: 130px; }
|
|
.stat-label { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-ter); }
|
|
.stat-val { font-size: 1.15rem; font-weight: 600; margin-top: 2px; }
|
|
.stat-val.mono { font-family: var(--font-mono); font-size: 0.95rem; }
|
|
|
|
.pill {
|
|
display: inline-block;
|
|
padding: 3px 10px;
|
|
border-radius: var(--radius-full);
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
}
|
|
.pill.ok { background: var(--pos-bg); color: var(--pos); }
|
|
.pill.bad { background: var(--neg-bg); color: var(--neg); }
|
|
.pill.unknown { background: var(--neutral-bg); color: var(--text-sec); }
|
|
|
|
.url-box {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.85rem;
|
|
background: var(--bg-raised);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
padding: 10px 12px;
|
|
overflow-wrap: anywhere;
|
|
color: var(--accent);
|
|
}
|
|
|
|
.hint { color: var(--text-sec); font-size: 0.85rem; margin-top: 10px; }
|
|
|
|
.switch-row { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
|
|
.switch-row .grow { flex: 1; min-width: 200px; }
|
|
|
|
button.action {
|
|
background: var(--accent-soft);
|
|
border: 1px solid var(--accent);
|
|
color: var(--accent);
|
|
border-radius: var(--radius-full);
|
|
padding: 8px 18px;
|
|
font: inherit;
|
|
font-size: 0.82rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: transform 0.15s var(--ease-out);
|
|
}
|
|
button.action:hover:not(:disabled) { transform: translateY(-1px); }
|
|
button.action:disabled { opacity: 0.45; cursor: default; }
|
|
button.action.quiet { background: transparent; border-color: var(--border); color: var(--text-sec); }
|
|
button.action.danger { background: var(--neg-bg); border-color: var(--neg); color: var(--neg); }
|
|
|
|
.owner {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 12px 0;
|
|
border-top: 1px solid var(--border);
|
|
flex-wrap: wrap;
|
|
}
|
|
.owner:first-of-type { border-top: none; }
|
|
.owner .id {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.8rem;
|
|
overflow-wrap: anywhere;
|
|
flex: 1;
|
|
min-width: 220px;
|
|
}
|
|
.owner .meta { color: var(--text-ter); font-size: 0.75rem; }
|
|
.owner .buttons { display: flex; gap: 8px; }
|
|
|
|
.empty { color: var(--text-ter); font-size: 0.88rem; font-style: italic; }
|
|
|
|
.notice {
|
|
border-radius: var(--radius-md);
|
|
padding: 12px 14px;
|
|
margin-bottom: 16px;
|
|
font-size: 0.88rem;
|
|
display: none;
|
|
}
|
|
.notice.show { display: block; }
|
|
.notice.warn { background: var(--neg-bg); color: var(--neg); }
|
|
.notice.info { background: var(--accent-soft); color: var(--accent); }
|
|
|
|
footer { color: var(--text-ter); font-size: 0.78rem; text-align: center; margin-top: 32px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
|
|
<header>
|
|
<img src="icon.png" alt="">
|
|
<h1>Evolu Relay</h1>
|
|
<span class="spacer"></span>
|
|
<button class="theme-toggle" id="theme">Light</button>
|
|
</header>
|
|
<p class="sub">Encrypted sync and backup for your local-first apps, on hardware you own.</p>
|
|
|
|
<div class="notice" id="notice"></div>
|
|
|
|
<div class="card">
|
|
<h2>Relay</h2>
|
|
<div class="stat-row">
|
|
<div class="stat">
|
|
<div class="stat-label">Status</div>
|
|
<div class="stat-val"><span class="pill unknown" id="relay-state">checking</span></div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-label">Stored data</div>
|
|
<div class="stat-val mono" id="db-size">-</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-label">Last write</div>
|
|
<div class="stat-val mono" id="db-modified">-</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Point your app here</h2>
|
|
<div class="url-box" id="relay-url">-</div>
|
|
<p class="hint">
|
|
Set this as the sync address in your Evolu app. Some apps only offer the field in their developer
|
|
or advanced settings. The address has to start with http or https, not ws, even though the
|
|
connection itself is a websocket: the client works that out on its own.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>New owners</h2>
|
|
<div class="switch-row">
|
|
<div class="grow">
|
|
<div class="stat-val"><span class="pill unknown" id="learning-state">unknown</span></div>
|
|
<p class="hint" id="learning-hint">
|
|
While this is open, the next device that connects is accepted and remembered. Close it once your
|
|
own devices are paired.
|
|
</p>
|
|
</div>
|
|
<button class="action" id="learning-toggle" disabled>Loading</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Accepted owners</h2>
|
|
<div id="allowed"><p class="empty">None yet.</p></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Blocked owners</h2>
|
|
<div id="blocked"><p class="empty">None.</p></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Refused attempts</h2>
|
|
<div id="rejected"><p class="empty">None.</p></div>
|
|
<p class="hint">
|
|
Devices that tried to connect while new owners were closed. Only the twenty most recent are kept.
|
|
</p>
|
|
</div>
|
|
|
|
<footer>Evolu Relay on umbrelOS · WhatsNext?</footer>
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
'use strict';
|
|
|
|
var root = document.documentElement;
|
|
var noticeBox = document.getElementById('notice');
|
|
var busy = false;
|
|
|
|
// ── Thema ──────────────────────────────────────────────────────────────────
|
|
var themeButton = document.getElementById('theme');
|
|
var applyTheme = function (theme) {
|
|
root.setAttribute('data-theme', theme);
|
|
themeButton.textContent = theme === 'dark' ? 'Light' : 'Dark';
|
|
try { localStorage.setItem('evolu-relay-theme', theme); } catch (error) { /* privémodus */ }
|
|
};
|
|
try {
|
|
var saved = localStorage.getItem('evolu-relay-theme');
|
|
if (saved === 'light' || saved === 'dark') applyTheme(saved);
|
|
} catch (error) { /* privémodus */ }
|
|
themeButton.addEventListener('click', function () {
|
|
applyTheme(root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark');
|
|
});
|
|
|
|
// ── Hulpjes ────────────────────────────────────────────────────────────────
|
|
var notice = function (text, kind) {
|
|
noticeBox.textContent = text;
|
|
noticeBox.className = 'notice show ' + (kind || 'info');
|
|
};
|
|
var clearNotice = function () { noticeBox.className = 'notice'; };
|
|
|
|
var formatBytes = function (bytes) {
|
|
if (typeof bytes !== 'number') return '-';
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' kB';
|
|
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
|
|
};
|
|
|
|
var formatTime = function (value) {
|
|
if (typeof value !== 'string') return 'never';
|
|
var when = new Date(value);
|
|
if (isNaN(when.getTime())) return 'never';
|
|
return when.toLocaleString();
|
|
};
|
|
|
|
var setPill = function (element, text, kind) {
|
|
element.textContent = text;
|
|
element.className = 'pill ' + kind;
|
|
};
|
|
|
|
var shorten = function (ownerId) {
|
|
if (ownerId.length <= 24) return ownerId;
|
|
return ownerId.slice(0, 12) + '…' + ownerId.slice(-8);
|
|
};
|
|
|
|
// ── Opdrachten ─────────────────────────────────────────────────────────────
|
|
var send = function (command) {
|
|
if (busy) return;
|
|
busy = true;
|
|
clearNotice();
|
|
fetch('api/command', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(command)
|
|
}).then(function (response) {
|
|
return response.json().then(function (body) { return { ok: response.ok, body: body }; });
|
|
}).then(function (result) {
|
|
if (!result.ok) {
|
|
notice('The relay refused that: ' + (result.body.error || 'unknown reason'), 'warn');
|
|
return;
|
|
}
|
|
notice('Sent. The relay applies changes within a few seconds.', 'info');
|
|
}).catch(function () {
|
|
notice('Could not reach the agent.', 'warn');
|
|
}).then(function () {
|
|
busy = false;
|
|
window.setTimeout(refresh, 2500);
|
|
});
|
|
};
|
|
|
|
var ownerRow = function (entry, buttons) {
|
|
var row = document.createElement('div');
|
|
row.className = 'owner';
|
|
|
|
var id = document.createElement('div');
|
|
id.className = 'id';
|
|
id.textContent = shorten(entry.id);
|
|
id.title = entry.id;
|
|
row.appendChild(id);
|
|
|
|
var meta = document.createElement('div');
|
|
meta.className = 'meta';
|
|
var parts = [];
|
|
if (entry.firstSeen) parts.push('first seen ' + formatTime(entry.firstSeen));
|
|
if (entry.lastSeen) parts.push('last seen ' + formatTime(entry.lastSeen));
|
|
if (entry.attempts) parts.push(entry.attempts + ' attempts');
|
|
meta.textContent = parts.join(' · ');
|
|
row.appendChild(meta);
|
|
|
|
var holder = document.createElement('div');
|
|
holder.className = 'buttons';
|
|
buttons.forEach(function (spec) {
|
|
var button = document.createElement('button');
|
|
button.className = 'action ' + (spec.style || 'quiet');
|
|
button.textContent = spec.label;
|
|
button.addEventListener('click', function () { send(spec.command(entry.id)); });
|
|
holder.appendChild(button);
|
|
});
|
|
row.appendChild(holder);
|
|
|
|
return row;
|
|
};
|
|
|
|
var fillList = function (element, entries, emptyText, buttons) {
|
|
element.textContent = '';
|
|
if (!entries.length) {
|
|
var empty = document.createElement('p');
|
|
empty.className = 'empty';
|
|
empty.textContent = emptyText;
|
|
element.appendChild(empty);
|
|
return;
|
|
}
|
|
entries.forEach(function (entry) { element.appendChild(ownerRow(entry, buttons)); });
|
|
};
|
|
|
|
// ── De ronde ───────────────────────────────────────────────────────────────
|
|
var refresh = function () {
|
|
fetch('api/status', { cache: 'no-store' }).then(function (response) {
|
|
return response.json();
|
|
}).then(function (status) {
|
|
var relayPill = document.getElementById('relay-state');
|
|
if (status.relay.reachable === true) setPill(relayPill, 'running', 'ok');
|
|
else if (status.relay.reachable === false) setPill(relayPill, 'not responding', 'bad');
|
|
else setPill(relayPill, 'unknown', 'unknown');
|
|
|
|
document.getElementById('db-size').textContent = formatBytes(status.database.bytes);
|
|
document.getElementById('db-modified').textContent = formatTime(status.database.modified);
|
|
|
|
document.getElementById('relay-url').textContent =
|
|
'http://' + window.location.hostname + ':' + status.relay.publicPort;
|
|
|
|
var learningPill = document.getElementById('learning-state');
|
|
var toggle = document.getElementById('learning-toggle');
|
|
if (status.owners.learning === true) {
|
|
setPill(learningPill, 'open', 'ok');
|
|
toggle.textContent = 'Close';
|
|
toggle.disabled = false;
|
|
} else if (status.owners.learning === false) {
|
|
setPill(learningPill, 'closed', 'unknown');
|
|
toggle.textContent = 'Open';
|
|
toggle.disabled = false;
|
|
} else {
|
|
setPill(learningPill, 'unknown', 'unknown');
|
|
toggle.textContent = 'Loading';
|
|
toggle.disabled = true;
|
|
}
|
|
toggle.onclick = function () {
|
|
send({ action: 'set-learning', value: status.owners.learning !== true });
|
|
};
|
|
|
|
fillList(document.getElementById('allowed'), status.owners.allowed, 'None yet.', [
|
|
{ label: 'Block', style: 'danger', command: function (id) { return { action: 'block', ownerId: id }; } },
|
|
{ label: 'Forget', command: function (id) { return { action: 'forget', ownerId: id }; } }
|
|
]);
|
|
|
|
fillList(document.getElementById('blocked'), status.owners.blocked, 'None.', [
|
|
{ label: 'Allow', command: function (id) { return { action: 'allow', ownerId: id }; } },
|
|
{ label: 'Forget', command: function (id) { return { action: 'forget', ownerId: id }; } }
|
|
]);
|
|
|
|
fillList(document.getElementById('rejected'), status.owners.rejected, 'None.', [
|
|
{ label: 'Allow', command: function (id) { return { action: 'allow', ownerId: id }; } }
|
|
]);
|
|
|
|
if (status.owners.problem === 'onleesbaar') {
|
|
notice('The owner list could not be read and was moved aside. New owners are refused until you '
|
|
+ 'open them again above.', 'warn');
|
|
} else if (status.pendingCommand) {
|
|
notice('A command is waiting for the relay to pick it up.', 'info');
|
|
} else if (noticeBox.className.indexOf('warn') === -1) {
|
|
clearNotice();
|
|
}
|
|
}).catch(function () {
|
|
setPill(document.getElementById('relay-state'), 'agent unreachable', 'bad');
|
|
});
|
|
};
|
|
|
|
refresh();
|
|
window.setInterval(refresh, 5000);
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|