engine: downstream MTU and downstream train in the measurement document

Three facts the client cannot produce alone, kept deliberately separate:
mtu.pmtud_down (largest datagram that arrives unfragmented — meaningful only
because the server sets DF), mtu.frag_delivery (whether larger ones arrive once
fragmentation is allowed), and train.udp_downstream (loss, reordering and
arrival spacing in the download direction, which a round trip cannot separate
from upstream loss).

ServerMeasurement now runs them on the same ProbeSession as the echo train. It
had to: a fresh session restarts client-side sequence numbers and the server's
anti-replay window discards the lot, so the re-primed source is never recorded
and every granted send goes to a socket that has already closed. That produced
four confidently-wrong FAILED tests and a RED verdict on a healthy network.

Live against fmr: path MTU 1500, fragments to 4000, 100/100 downstream, GREEN.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-01 10:54:38 +02:00
co-authored by Claude Fable 5
parent ce1aaa332a
commit 14e5fad1b2
18 changed files with 911 additions and 127 deletions
+74
View File
@@ -0,0 +1,74 @@
# Echolot website (`web/`)
Minimal single-page site for [echo-lot.app](https://echo-lot.app), served from Cloudflare
Workers. Static files in `public/` are served straight from the edge; the tiny Worker in
`src/index.js` only runs for paths that aren't files:
| Path | Behavior |
| ------------- | ------------------------------------------------------------------------ |
| `/apk` | 302 → newest `.apk` asset of the latest Gitea release (QR-code friendly) |
| `/apk.sha256` | 302 → the matching `.sha256` asset |
| `/api/latest` | JSON `{version, published_at, apk, sha256}` — the homepage's version readout |
| `/fdroid`, `/source` | 302 → the URLs configured in `wrangler.jsonc` vars |
The latest release is resolved from the Gitea API **at request time** (edge-cached 5 min), so
publishing a release — `git tag v0.2.0 && git push origin v0.2.0`, which triggers
`.gitea/workflows/release.yml` — is the only release step. The site never needs a redeploy for
a new version, and empty/unreachable values fall back to the homepage instead of 404ing.
Light/dark follows the OS (`prefers-color-scheme`), no toggle, no JS required for it. Colors
come from the branding palette (teal = instrument, single amber point = finding).
`public/assets/` (favicon, wordmark, social preview) are **copies** of `../assets/branding/`
that directory is the source of truth; re-copy after any branding change.
## Deploy
Everything is driven by [wrangler](https://developers.cloudflare.com/workers/wrangler/), config
in `wrangler.jsonc`. No build step, no node_modules to commit.
### One-time setup
1. In the Cloudflare dashboard, add **echo-lot.app** as a zone (and point the domain's
nameservers at Cloudflare). The `routes` in `wrangler.jsonc` use `custom_domain: true`, so
wrangler creates the DNS records for `echo-lot.app` and `www` automatically on first deploy —
the zone just has to exist in the same account.
2. Auth, either flavor:
- **Interactive:** `npx wrangler login` (opens the browser once, stores an OAuth token).
- **API token (also what CI uses):** dashboard → My Profile → API Tokens → create from the
**"Edit Cloudflare Workers"** template. Then:
```
$env:CLOUDFLARE_API_TOKEN = "..." # PowerShell; export ... on POSIX
$env:CLOUDFLARE_ACCOUNT_ID = "..." # dashboard → Workers & Pages, right sidebar
```
### Deploy
```
cd web
npx wrangler@4 deploy
```
That's it — uploads `src/index.js` + the `public/` assets, wires the custom domains. Useful
extras: `npx wrangler dev` (local preview at localhost:8787), `npx wrangler tail` (live logs),
`npx wrangler versions list`.
### CI deploy (Gitea Actions)
`.gitea/workflows/deploy-site.yml` runs `wrangler deploy` on every push to `main`/`master` that
touches `web/`. It stays inert until you add two repo secrets (Settings → Actions → Secrets):
`CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` (same values as above).
Cloudflare's raw REST API (`PUT /accounts/:id/workers/scripts/...`) exists, but the assets
upload needs a manifest/session dance that wrangler already implements — use wrangler even in
automation.
## Config knobs (`wrangler.jsonc` → `vars`)
- `GITEA_REPO_API` — Gitea repo API base; releases must be publicly readable.
- `DOWNLOAD_URL` — manual `/apk` fallback while Gitea is unreachable.
- `FDROID_URL` — set when the F-Droid listing exists; until then `/fdroid` loops home.
- `SOURCE_URL` — public source mirror for the footer + `/source`.
Vars are plain (non-secret) config; change + `wrangler deploy` to apply.
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96">
<defs>
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#0E2433"/>
<stop offset="1" stop-color="#071522"/>
</linearGradient>
</defs>
<rect width="96" height="96" rx="21" fill="url(#tile)"/>
<!-- the network, at rest -->
<g fill="#1E4A5C">
<circle cx="24" cy="24" r="2.6"/><circle cx="48" cy="24" r="2.6"/><circle cx="72" cy="24" r="2.6"/>
<circle cx="24" cy="48" r="2.6"/> <circle cx="72" cy="48" r="2.6"/>
<circle cx="24" cy="72" r="2.6"/><circle cx="48" cy="72" r="2.6"/><circle cx="72" cy="72" r="2.6"/>
</g>
<!-- one node, under examination -->
<circle cx="48" cy="48" r="8" fill="none" stroke="#FFB454" stroke-opacity="0.3" stroke-width="2"/>
<circle cx="48" cy="48" r="4.5" fill="#FFB454"/>
<g fill="none" stroke="#35E0C4" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M 33 41 V 33 H 41"/>
<path d="M 55 33 H 63 V 41"/>
<path d="M 63 55 V 63 H 55"/>
<path d="M 41 63 H 33 V 55"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -92 433 104">
<!-- "echolot" — hand-drawn monoline letterforms (no font dependency). For dark grounds. -->
<g fill="none" stroke="#E8F4F2" stroke-width="11" stroke-linecap="round">
<path d="M 0 -24 H 48"/>
<path d="M 48 -24 A 24 24 0 1 0 40.97 -7.03"/>
<path d="M 110.97 -40.97 A 24 24 0 1 0 110.97 -7.03"/>
<path d="M 140 -76 V 0"/>
<path d="M 140 -24 A 24 24 0 0 1 188 -24 L 188 0"/>
<circle cx="234" cy="-24" r="24"/>
<path d="M 280 -76 V 0"/>
<circle cx="326" cy="-24" r="24" stroke="#35E0C4"/>
<path d="M 372 -48 H 402"/>
<path d="M 387 -68 V 0"/>
</g>
<!-- the finding -->
<circle cx="326" cy="-24" r="6.5" fill="#FFB454"/>
</svg>

After

Width:  |  Height:  |  Size: 742 B

+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -92 433 104">
<!-- "echolot" — hand-drawn monoline letterforms (no font dependency). For light grounds. -->
<g fill="none" stroke="#1B3540" stroke-width="11" stroke-linecap="round">
<path d="M 0 -24 H 48"/>
<path d="M 48 -24 A 24 24 0 1 0 40.97 -7.03"/>
<path d="M 110.97 -40.97 A 24 24 0 1 0 110.97 -7.03"/>
<path d="M 140 -76 V 0"/>
<path d="M 140 -24 A 24 24 0 0 1 188 -24 L 188 0"/>
<circle cx="234" cy="-24" r="24"/>
<path d="M 280 -76 V 0"/>
<circle cx="326" cy="-24" r="24" stroke="#0E9384"/>
<path d="M 372 -48 H 402"/>
<path d="M 387 -68 V 0"/>
</g>
<!-- the finding -->
<circle cx="326" cy="-24" r="6.5" fill="#E08A1E"/>
</svg>

After

Width:  |  Height:  |  Size: 743 B

+156 -87
View File
@@ -5,103 +5,124 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Echolot — depth soundings for your local network</title>
<title>Echolot — measure, don't guess</title>
<meta name="description" content="Free Android app for detecting and debugging local network issues: rogue DHCP, broken IPv6 RAs, MTU black holes, multicast loss, lying DNS. No root required.">
<meta property="og:title" content="Echolot">
<meta property="og:description" content="Depth soundings for your local network. F/OSS Android network diagnostics — no root required.">
<meta property="og:description" content="Measure, don't guess. F/OSS Android network diagnostics — no root required.">
<meta property="og:url" content="https://echo-lot.app/">
<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' fill='%23071A29'/%3E%3Cg fill='none' stroke='%23FFB454' stroke-width='2'%3E%3Ccircle cx='16' cy='16' r='3' fill='%23FFB454' stroke='none'/%3E%3Cpath d='M16 6a10 10 0 0 1 10 10'/%3E%3Cpath d='M16 1a15 15 0 0 1 15 15' opacity='.5'/%3E%3C/g%3E%3C/svg%3E">
<meta property="og:image" content="https://echo-lot.app/assets/social-preview.png">
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#071522">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#F2F6F7">
<link rel="icon" href="/assets/icon.svg" type="image/svg+xml">
<link rel="icon" href="/assets/icon.png" type="image/png" sizes="512x512">
<link rel="apple-touch-icon" href="/assets/icon.png">
<style>
/* Branding: teal is always the instrument (links, brackets, controls);
the single amber point is the finding (the focused node, the version
readout). Dark = the instrument's own display; light = the same tokens
on paper. Palette from assets/branding/. */
:root {
--depth-0: #0B2437; /* surface */
--depth-1: #092031; /* photic */
--depth-2: #071A29; /* mid */
--depth-3: #051320; /* floor */
--foam: #DCE9F1; /* primary text */
--slate: #8AA5B8; /* secondary text */
--grid: #16374E; /* hairlines, chart grid */
--ping: #FFB454; /* the one accent: sonar amber */
--ok: #7BC98F; /* verdict green, chips only */
color-scheme: dark;
--bg-0: #0C2130; /* top of page */
--bg-1: #071522; /* abyss — page floor, panel ground */
--tile: #0E2433; /* raised surfaces */
--foam: #E8F4F2; /* primary text */
--slate: #7DA2AC; /* secondary text */
--caption:#5E8B96; /* mono captions, from the banner */
--grid: #10303F; /* hairlines */
--rest: #1E4A5C; /* the network, at rest */
--teal: #35E0C4; /* instrument */
--on-teal:#04212B; /* text on teal */
--amber: #FFB454; /* the finding */
--mono: "Cascadia Code", "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
--sans: "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
}
@media (prefers-color-scheme: light) {
:root {
color-scheme: light;
--bg-0: #F2F6F7;
--bg-1: #E4ECEF;
--tile: #EBF1F3;
--foam: #1B3540; /* ink, from wordmark-on-light */
--slate: #47656F;
--caption:#5E8B96;
--grid: #C4D3D8;
--rest: #9FB8C1;
--teal: #0E9384; /* instrument, printable contrast */
--on-teal:#F5FBFA;
--amber: #C77413; /* finding ink */
}
}
* { box-sizing: border-box; margin: 0; }
html { scroll-behavior: smooth; }
body {
font-family: var(--sans);
color: var(--foam);
background: linear-gradient(var(--depth-0), var(--depth-1) 30%, var(--depth-2) 65%, var(--depth-3));
background: linear-gradient(var(--bg-0), var(--bg-1) 70%);
min-height: 100vh;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
a { color: var(--ping); text-decoration-thickness: 1px; text-underline-offset: 3px; }
a { color: var(--teal); text-decoration-thickness: 1px; text-underline-offset: 3px; }
a:hover { text-decoration-thickness: 2px; }
:focus-visible { outline: 2px solid var(--ping); outline-offset: 3px; border-radius: 2px; }
:focus-visible { outline: 2px solid var(--teal); outline-offset: 3px; border-radius: 2px; }
.col { max-width: 46rem; margin: 0 auto; padding: 0 1.25rem; }
/* Depth ruler: fixed left margin scale, desktop only. Marks are set per-section
by scroll position purely decoratively — it is a ruler, not navigation. */
.ruler {
/* Graduated rule: the tick motif from the banner, vertical. Fixed left
margin, desktop only, purely decorative. */
.rule {
position: fixed; top: 0; bottom: 0; left: 0; width: 3.5rem;
border-right: 1px solid var(--grid);
font-family: var(--mono); font-size: .65rem; color: var(--slate);
display: none;
}
@media (min-width: 72rem) { .ruler { display: block; } }
.ruler span {
position: absolute; right: .5rem; transform: translateY(-50%);
}
.ruler span::after {
content: ""; position: absolute; right: -.55rem; top: 50%;
width: .35rem; height: 1px; background: var(--slate);
@media (min-width: 72rem) { .rule { display: block; } }
.rule::after {
content: ""; position: absolute; right: 0; top: 0; bottom: 0; width: .4rem;
background: repeating-linear-gradient(to bottom, var(--rest) 0 1.5px, transparent 1.5px 60px);
}
header.hero { padding: 4.5rem 0 3rem; }
.wordmark {
font-family: var(--mono); font-size: .8rem; letter-spacing: .35em;
text-transform: uppercase; color: var(--slate);
}
.wordmark b { color: var(--ping); font-weight: 600; }
.wordmark { display: block; height: 30px; width: auto; }
h1 {
font-size: clamp(1.9rem, 5vw, 3rem);
font-weight: 650; letter-spacing: -.02em; line-height: 1.15;
margin: 1rem 0 .75rem; max-width: 30ch;
margin: 1.75rem 0 .75rem; max-width: 30ch;
}
.hero p.lede { color: var(--slate); max-width: 52ch; font-size: 1.05rem; }
.hero p.lede strong { color: var(--foam); font-weight: 600; }
/* Echogram: the signature. A chart-recorder trace of ping RTTs; the sweep
line is the sounder, the profile is the "seabed" the echoes draw. */
figure.echogram {
/* Focus panel: the signature, straight from the mark. The network at rest,
one node under examination — teal brackets are the instrument, the amber
point is the finding. */
figure.focus {
margin: 2.5rem 0 0; border: 1px solid var(--grid); border-radius: 4px;
background:
repeating-linear-gradient(to right, transparent 0 39px, var(--grid) 39px 40px),
repeating-linear-gradient(to bottom, transparent 0 31px, var(--grid) 31px 32px),
var(--depth-3);
background: var(--tile);
position: relative; overflow: hidden;
}
.echogram svg { display: block; width: 100%; height: auto; }
.echogram figcaption {
.focus svg { display: block; width: 100%; height: auto; }
.focus .rest-node { fill: var(--rest); }
.focus .bracket { fill: none; stroke: var(--teal); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
.focus .finding { fill: var(--amber); }
.focus .halo { fill: none; stroke: var(--amber); stroke-width: 2; opacity: .3; }
.focus .readout { font-family: var(--mono); font-size: 11px; fill: var(--slate); }
.focus .readout .flag { fill: var(--amber); }
.focus .lead { stroke: var(--grid); stroke-width: 1; }
.focus figcaption {
position: absolute; top: .5rem; left: .75rem;
font-family: var(--mono); font-size: .65rem; color: var(--slate);
font-family: var(--mono); font-size: .65rem; color: var(--caption);
}
.sweep {
position: absolute; top: 0; bottom: 0; width: 1px;
background: var(--ping); opacity: .8;
box-shadow: 0 0 8px var(--ping);
animation: sweep 7s linear infinite;
}
@keyframes sweep { from { left: 0; } to { left: 100%; } }
@keyframes examine { 0%, 100% { opacity: .3; } 50% { opacity: .1; } }
.focus .halo { animation: examine 3.2s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.sweep { animation: none; left: 62%; }
.focus .halo { animation: none; }
html { scroll-behavior: auto; }
}
section { padding: 3.5rem 0 0; }
.eyebrow {
font-family: var(--mono); font-size: .7rem; letter-spacing: .25em;
text-transform: uppercase; color: var(--ping);
text-transform: uppercase; color: var(--teal);
}
h2 { font-size: 1.35rem; font-weight: 650; margin: .5rem 0 1rem; letter-spacing: -.01em; }
section > .col > p { color: var(--slate); max-width: 58ch; }
@@ -129,21 +150,26 @@
border: 1px solid var(--grid); border-radius: 3px; padding: .35rem .6rem;
color: var(--slate);
}
.tier b { color: var(--ok); font-weight: 600; }
.tier b { color: var(--teal); font-weight: 600; }
/* Install */
.buttons { display: flex; gap: .75rem; flex-wrap: wrap; margin: 1.5rem 0 1rem; }
.release {
font-family: var(--mono); font-size: .8rem; color: var(--slate);
margin-top: 1.25rem;
}
.release b { color: var(--amber); font-weight: 600; }
.buttons { display: flex; gap: .75rem; flex-wrap: wrap; margin: 1rem 0 1rem; }
.btn {
display: inline-block; padding: .7rem 1.3rem; border-radius: 4px;
font-weight: 600; text-decoration: none; font-size: .95rem;
}
.btn.primary { background: var(--ping); color: var(--depth-3); }
.btn.primary { background: var(--teal); color: var(--on-teal); }
.btn.primary:hover { filter: brightness(1.08); }
.btn.ghost { border: 1px solid var(--grid); color: var(--foam); }
.btn.ghost:hover { border-color: var(--slate); }
.note {
font-size: .85rem; color: var(--slate);
border-left: 2px solid var(--ping); padding-left: .9rem; max-width: 52ch;
border-left: 2px solid var(--teal); padding-left: .9rem; max-width: 52ch;
}
.checksum { font-family: var(--mono); font-size: .75rem; color: var(--slate); margin-top: 1rem; }
@@ -157,45 +183,51 @@
</head>
<body>
<div class="ruler" aria-hidden="true">
<span style="top:6%">0 m</span>
<span style="top:28%">─ 20</span>
<span style="top:50%">─ 40</span>
<span style="top:72%">─ 60</span>
<span style="top:94%">─ 80</span>
</div>
<div class="rule" aria-hidden="true"></div>
<header class="hero">
<div class="col">
<p class="wordmark"><b></b> echo·lot <span aria-hidden="true">/ˈɛçolo:t/ — echo sounder</span></p>
<h1>Depth soundings for your local network.</h1>
<p class="lede">An echo sounder maps the seabed by timing returns. <strong>Echolot</strong> does the
same to your network: free Android diagnostics for the layer where things actually break —
<picture>
<source srcset="/assets/wordmark-on-dark.svg" media="(prefers-color-scheme: dark)">
<img class="wordmark" src="/assets/wordmark-on-light.svg" alt="echolot" width="125" height="30">
</picture>
<h1>Measure, don't guess.</h1>
<p class="lede">Free Android diagnostics for the layer where networks actually break.
<strong>Echolot</strong> takes the failure you can feel and pins it to a fact you can show —
<strong>no root required</strong>. Built for people who know what a neighbor table is.</p>
<figure class="echogram">
<figcaption>trace · icmp.ping4 · rtt ms ↓ / t →</figcaption>
<svg viewBox="0 0 720 190" role="img" aria-label="Chart-recorder style trace of ping round-trip times, drawn like a sonar seabed profile">
<!-- echo returns: the profile -->
<polyline fill="none" stroke="#FFB454" stroke-width="1.5" opacity=".9"
points="0,138 40,136 80,139 120,135 160,137 200,141 240,138 260,120 280,96 300,88 320,94 340,118 360,134 400,136 440,133 480,158 500,171 520,168 540,150 560,139 600,137 640,140 680,136 720,138"/>
<!-- second, fainter return (multipath) -->
<polyline fill="none" stroke="#FFB454" stroke-width="1" opacity=".25"
points="0,148 40,146 80,149 120,145 160,147 200,151 240,148 260,132 280,110 300,101 320,107 340,129 360,144 400,146 440,143 480,168 500,180 520,177 540,160 560,149 600,147 640,150 680,146 720,148"/>
<!-- dropped probes -->
<g fill="#8AA5B8" font-family="monospace" font-size="9">
<text x="497" y="30">×</text><text x="507" y="30">×</text>
<text x="288" y="30">▲ spike: wifi→cell handover</text>
<figure class="focus">
<figcaption>focus · dhcp.rogue_detect · tier:app</figcaption>
<svg viewBox="0 0 720 240" role="img" aria-label="A grid of network nodes at rest; one node is framed by viewfinder brackets, highlighted as a finding: two DHCP servers answered the same DISCOVER">
<!-- the network, at rest -->
<g class="rest-node">
<circle cx="120" cy="60" r="3"/><circle cx="240" cy="60" r="3"/><circle cx="360" cy="60" r="3"/><circle cx="480" cy="60" r="3"/><circle cx="600" cy="60" r="3"/>
<circle cx="120" cy="120" r="3"/><circle cx="480" cy="120" r="3"/><circle cx="600" cy="120" r="3"/>
<circle cx="120" cy="180" r="3"/><circle cx="240" cy="180" r="3"/><circle cx="360" cy="180" r="3"/><circle cx="480" cy="180" r="3"/><circle cx="600" cy="180" r="3"/>
</g>
<!-- one node, under examination -->
<circle class="halo" cx="240" cy="120" r="11"/>
<circle class="finding" cx="240" cy="120" r="5"/>
<g class="bracket">
<path d="M 222 111 V 102 H 231"/>
<path d="M 249 102 H 258 V 111"/>
<path d="M 258 129 V 138 H 249"/>
<path d="M 231 138 H 222 V 129"/>
</g>
<!-- the readout -->
<line class="lead" x1="262" y1="120" x2="296" y2="120"/>
<g class="readout">
<text x="304" y="112">DISCOVER → 2 OFFERs</text>
<text x="304" y="130">192.168.1.1 gw · <tspan class="flag">192.168.1.223 — who is this?</tspan></text>
</g>
</svg>
<div class="sweep" aria-hidden="true"></div>
</figure>
</div>
</header>
<section id="what">
<div class="col">
<p class="eyebrow">What it sounds out</p>
<p class="eyebrow">What it measures</p>
<h2>Signal bars lie. Timings don't.</h2>
<p>Most wifi apps show you signal strength and call it a diagnosis. The failures that ruin
home and office networks live deeper: a second DHCP server nobody admits to, IPv6 router
@@ -234,15 +266,16 @@
<div class="col">
<p class="eyebrow">Install</p>
<h2>Get Echolot</h2>
<p class="release" id="release" hidden></p>
<div class="buttons">
<a class="btn primary" href="/apk">Download APK</a>
<a class="btn primary" id="dl-btn" href="/apk">Download APK</a>
<a class="btn ghost" href="/fdroid">F-Droid</a>
</div>
<p class="note"><strong>Pre-release.</strong> The capability prober is running on real
hardware; the production app is under construction. These links go live with the first
release — until then they loop back here. No mailing list, no tracker: check back, or watch
the <a href="/source">repository</a>.</p>
<p class="checksum">releases will ship with sha256sums + a signing key you can pin</p>
<p class="note" id="prerelease-note"><strong>Pre-release.</strong> The capability prober is
running on real hardware; the production app is under construction. These links go live with
the first release — until then they loop back here. No mailing list, no tracker: check back,
or watch the <a href="/source">repository</a>.</p>
<p class="checksum" id="checksum">releases will ship with sha256sums + a signing key you can pin</p>
</div>
</section>
@@ -253,5 +286,41 @@
</div>
</footer>
<script>
// Release readout: asks this site's own Worker (/api/latest, which proxies the
// Gitea "latest release" API, edge-cached). Progressive enhancement — with no
// JS, no network, or no release yet, the static pre-release copy above stands.
(async () => {
let rel;
try {
const res = await fetch("/api/latest");
if (!res.ok) return;
rel = await res.json();
} catch { return; }
if (!rel || !rel.available) return;
const line = document.getElementById("release");
const ver = document.createElement("b");
ver.textContent = rel.version;
line.append("» latest ", ver);
const date = (rel.published_at || "").slice(0, 10);
if (date) line.append(" · " + date);
if (rel.apk && rel.apk.size) {
line.append(" · " + (rel.apk.size / 1048576).toFixed(1) + " MiB");
}
line.hidden = false;
document.getElementById("prerelease-note").hidden = true;
if (rel.sha256) {
const c = document.getElementById("checksum");
c.textContent = "";
const a = document.createElement("a");
a.href = "/apk.sha256";
a.textContent = "sha256";
c.append(a, " · verify before you sideload");
}
})();
</script>
</body>
</html>
+47 -14
View File
@@ -3,28 +3,32 @@
// Everything under public/ is served straight from the edge without invoking
// this Worker. The Worker exists for the short stable URLs (/apk, /fdroid,
// /source) — short enough for a QR code — and to resolve "/apk" to the newest
// release asset at request time, so tagging a release in Gitea is the only
// publish step. No site redeploy, no URL to update.
// /source) — short enough for a QR code — and for /api/latest, which the
// homepage uses to show the current version. Both resolve the newest release
// from the Gitea API at request time, so tagging a release in Gitea is the
// only publish step. No site redeploy, no URL to update.
const STATIC_ROUTES = {
"/fdroid": "FDROID_URL",
"/source": "SOURCE_URL",
};
// Resolve the newest APK from the Gitea "latest release" API. Cached at the
// edge for 5 minutes so a release becomes visible quickly, while Gitea sees
// at most one API hit per POP per 5 min regardless of download traffic.
async function latestApkUrl(env) {
// Fetch the Gitea "latest release" object. Cached at the edge for 5 minutes so
// a new release becomes visible quickly, while Gitea sees at most one API hit
// per POP per 5 min regardless of traffic. Returns null on any failure —
// callers degrade to fallbacks rather than surfacing errors.
async function latestRelease(env) {
if (!env.GITEA_REPO_API) return null;
const res = await fetch(`${env.GITEA_REPO_API}/releases/latest`, {
headers: { Accept: "application/json", "User-Agent": "echolot-site" },
cf: { cacheTtl: 300, cacheEverything: true },
});
if (!res.ok) return null;
const rel = await res.json();
const apk = rel.assets?.find((a) => a.name?.endsWith(".apk"));
return apk?.browser_download_url ?? null;
return res.json();
}
function asset(rel, suffix) {
return rel?.assets?.find((a) => a.name?.endsWith(suffix)) ?? null;
}
function redirect(location) {
@@ -38,21 +42,50 @@ function redirect(location) {
});
}
function json(body, maxAge) {
return new Response(JSON.stringify(body), {
headers: {
"Content-Type": "application/json",
"Cache-Control": `public, max-age=${maxAge}`,
},
});
}
export default {
async fetch(request, env) {
const { pathname } = new URL(request.url);
const path = pathname.replace(/\/$/, "");
const fallback = new URL("/#install", request.url).toString();
if (path === "/apk" || path === "/download") {
// Order: live Gitea release → manual override → install section.
if (path === "/apk" || path === "/download" || path === "/apk.sha256") {
const suffix = path === "/apk.sha256" ? ".sha256" : ".apk";
let target = null;
try {
target = await latestApkUrl(env);
target = asset(await latestRelease(env), suffix)?.browser_download_url;
} catch {
// Gitea unreachable — fall through rather than 500 on a download link.
}
return redirect(target || env.DOWNLOAD_URL || fallback);
const override = suffix === ".apk" ? env.DOWNLOAD_URL : null;
return redirect(target || override || fallback);
}
if (path === "/api/latest") {
let rel = null;
try {
rel = await latestRelease(env);
} catch {}
const apk = asset(rel, ".apk");
if (!rel || !apk) return json({ available: false }, 60);
return json(
{
available: true,
version: rel.tag_name,
published_at: rel.published_at,
apk: { name: apk.name, size: apk.size, url: apk.browser_download_url },
sha256: Boolean(asset(rel, ".sha256")),
},
300,
);
}
const varName = STATIC_ROUTES[path];
+5 -5
View File
@@ -22,14 +22,14 @@
// its route fall back to the homepage's install section, so nothing 404s
// before the first release exists.
"vars": {
// Gitea repo API base, e.g. "https://git.example.net/api/v1/repos/mram/echolot".
// The repo (or at least its releases) must be publicly readable.
"GITEA_REPO_API": "",
// Manual override / fallback while GITEA_REPO_API is unset or unreachable.
// Gitea repo API base. The repo (or at least its releases) must be
// publicly readable for /apk and /api/latest to resolve.
"GITEA_REPO_API": "https://git.rambossek.at/api/v1/repos/EchoLot/echolot",
// Manual override / fallback while GITEA_REPO_API is unreachable.
"DOWNLOAD_URL": "",
// F-Droid listing, once it exists: https://f-droid.org/packages/app.echo_lot.app/
"FDROID_URL": "",
// Public source URL, the footer + /source target.
"SOURCE_URL": ""
"SOURCE_URL": "https://git.rambossek.at/EchoLot/echolot"
}
}