diff --git a/CLAUDE.md b/CLAUDE.md index 94681b2..8fd0757 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,7 @@ dotnet run tools/setup.cs -- --group packaging # pinned nfpm into to dotnet build src/MeWeb.Android/MeWeb.Android.csproj \ -p:JavaSdkDirectory=$PWD/tools/jdk/linux-x64/jdk-21.0.12+8 -p:AndroidSdkDirectory=$HOME/Android/Sdk dotnet run --project tools/Screenshots -- dist/screenshots [en,it,...] +dotnet run --project tools/Screenshots -- --store # store set: fastlane/metadata/android/{en-US,it-IT}/images/phoneScreenshots tools/release.sh notes # artifacts/release-notes.md alone, nothing built dotnet run tools/strings.cs -- check # every language against English dotnet run tools/strings.cs -- set Key values.json # one key, every language at once @@ -76,7 +77,7 @@ The Android release key: alias `meweb`, certificate SHA-256 | `src/MeWeb.Server/KnowledgeFeeder.cs` | refreshes the open network data under `/var/lib/meweb/knowledge` every six hours (iptoasn daily, DB-IP monthly, eight RouteViews RIBs weekly through `bzcat` (one or two collectors per continent, `Collectors`; each counts once per network in `UpstreamTally`, and a path's first AS, the collector's own neighbour, is never taken for an upstream), the AOSP carrier list weekly, the address registries' block files weekly (`Registries`: RIPE's and APNIC's `inetnum` and `inet6num` split files, AFRINIC's whole dump, LACNIC's `lacnic.db`)), builds `ip2asn.bin`, `networks.bin`, `carriers.json`, `registry.bin` (every block of the four, `RegistryBuilder`) and `SOURCES.txt` with `MeWeb.Core/Knowledge/Sources.cs`, and serves them at `/api/knowledge` | | `tools/knowledge.cs` | takes the server's knowledge files (`MEWEB_KNOWLEDGE_URL`, default the public server; the release sets `http://127.0.0.1:5480` after deploying the server first) into `artifacts/knowledge/`, checking each SHA-256, and falls back to building `ip2asn.bin` alone from iptoasn (`--local` forces that); `MeWeb.Core` embeds whatever is there; `tools/release.cs` runs it before every app build | | `tools/setup.cs` + `setup.json` | pinned build tools | -| `tools/Screenshots` | headless renders of every view in every language; it runs a real check, in a throwaway app folder (`AppServices.Folder`), so the developer's own history and settings stay untouched | +| `tools/Screenshots` | headless renders of every view in every language; it runs a real check, in a throwaway app folder (`AppServices.Folder`), so the developer's own history and settings stay untouched. `--store` runs no check: it replays the invented run in `StoreRun.cs` (a phone on home Wi-Fi behind CGNAT with its own IPv6, documentation addresses, "Example Net" on AS64500, findings computed by `Verdicts`) on a fake `android` platform and writes the six 1080×1920 store screenshots per language, laid out at 405×720 and rendered at 1080/405. Headless rounds a window's logical size up to whole units before scaling, so a store size must be a whole number of units (411.4×731.4 at 2.625 came out 1082×1922) | | `branding/` | the icon (SVG source, PNG, ICO) | ## Pitfalls @@ -171,8 +172,11 @@ The Android release key: alias `meweb`, certificate SHA-256 - **The rpm is signed here, before the release is published**, by `.gitea/scripts/sign-rpm.sh` with the Arasaka org secret `PACKAGES_SIGNING_KEY` (it refuses any key but `F92C166E…3FB2`), which then rewrites `SHA256SUMS.txt`, so the README's promise holds for the file on the release page too; the publisher sees the signature and keeps it. - - **The F-Droid listing stays in `packaging/fdroid/metadata/`** (English and Italian); the publisher reads it at the - release's tag. The repository's own settings and icon moved there. + - **The store listing is `fastlane/metadata/android/`** (`en-US` and `it-IT`: name, short and full description, + icon, banner, the six screenshots from `tools/Screenshots --store`), in Google Play's layout. The publisher reads + it from `master`, not from a release tag, so F-Droid shows a change within a quarter of an hour. The banner is + drawn by `Arasaka/app-stores`' `listing/banner.py meweb`. The F-Droid repository's own settings and icon live in + the publisher's repository. - **The APK's signing key also signs the F-Droid index**, so `MEWEB_KEYSTORE_B64` exists twice: here, to sign the APK, and as `thepra`'s `FDROID_KEYSTORE_B64`. The publisher refuses an APK signed by anything but `5f52cff4…f846`. - `packaging/arasaka-repository.sh` is written into both maintainer scripts by `tools/release.cs`; it is diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000..de936b4 --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,15 @@ +Your photos, your files, a website or a game server, kept at home instead of on someone else's computer. Whether that can work depends on your connection, and “me-web?” tells you, in plain words. + +It checks your connection and your device one step at a time, from this phone to your router, your provider and the internet, and then says what you could host at home, what stands in the way, and what would fix it: + +• a website, reaching home from outside, family photos and files, a game or voice server, a fediverse or blog server, a mail server: possible, possible with a workaround (and which one), or can't tell; +• whether your provider shares your public address with other customers (CGNAT), or a second router is in the way; +• whether your router opens ports for the apps that ask it to (UPnP, NAT-PMP, PCP); +• on mobile data, the operator and the type of network (5G, 4G); +• only if you agree: whether the outside world can really reach this device, with ports opened for ten minutes and closed afterwards; +• whether this connection could send mail directly; +• how your public address has changed across your earlier checks. + +What it knows about providers is inside the app, not fetched from a server. Your findings stay on your phone unless you choose to share them, after reading exactly what is sent, and you can delete them at any time. + +Free software (AGPL-3.0), no ads, no telemetry, no account. diff --git a/fastlane/metadata/android/en-US/images/featureGraphic.png b/fastlane/metadata/android/en-US/images/featureGraphic.png new file mode 100644 index 0000000..86bb4ac Binary files /dev/null and b/fastlane/metadata/android/en-US/images/featureGraphic.png differ diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png new file mode 100644 index 0000000..3db8315 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png new file mode 100644 index 0000000..435f6fa Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png new file mode 100644 index 0000000..79d0814 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png new file mode 100644 index 0000000..9e586d0 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png new file mode 100644 index 0000000..8c5600d Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png new file mode 100644 index 0000000..f9a6970 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png new file mode 100644 index 0000000..e614e35 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png differ diff --git a/packaging/fdroid/metadata/software.arasaka.meweb/en-US/summary.txt b/fastlane/metadata/android/en-US/short_description.txt similarity index 100% rename from packaging/fdroid/metadata/software.arasaka.meweb/en-US/summary.txt rename to fastlane/metadata/android/en-US/short_description.txt diff --git a/packaging/fdroid/metadata/software.arasaka.meweb/en-US/name.txt b/fastlane/metadata/android/en-US/title.txt similarity index 100% rename from packaging/fdroid/metadata/software.arasaka.meweb/en-US/name.txt rename to fastlane/metadata/android/en-US/title.txt diff --git a/fastlane/metadata/android/it-IT/full_description.txt b/fastlane/metadata/android/it-IT/full_description.txt new file mode 100644 index 0000000..c8e3bf4 --- /dev/null +++ b/fastlane/metadata/android/it-IT/full_description.txt @@ -0,0 +1,15 @@ +Le sue foto, i suoi file, un sito o un server per giocare, tenuti a casa invece che sul computer di qualcun altro. Se si può dipende dalla sua connessione, e «me-web?» glielo dice in parole semplici. + +Controlla la connessione e il dispositivo un passo alla volta, da questo telefono al router, al suo operatore e a internet, e poi le dice che cosa potrebbe ospitare a casa, che cosa lo impedisce e che cosa lo risolverebbe: + +• un sito, raggiungere casa da fuori, foto e file di famiglia, un server per giochi o per chiamate vocali, un server del fediverso o un blog, un server di posta: si può, si può con un accorgimento (e quale), oppure non si può dire; +• se l'operatore condivide il suo indirizzo pubblico con altri clienti (CGNAT), o se c'è un secondo router di mezzo; +• se il router apre le porte alle app che glielo chiedono (UPnP, NAT-PMP, PCP); +• con i dati mobili, l'operatore e il tipo di rete (5G, 4G); +• solo se lei è d'accordo: se dall'esterno si raggiunge davvero questo dispositivo, con le porte aperte per dieci minuti e richiuse subito dopo; +• se questa connessione può inviare posta direttamente; +• come è cambiato il suo indirizzo pubblico nei controlli precedenti. + +Quello che sa degli operatori è dentro l'app, non scaricato da un server. I risultati restano sul suo telefono, a meno che non scelga di condividerli dopo aver letto esattamente che cosa viene inviato, e può cancellarli quando vuole. + +Software libero (AGPL-3.0), niente pubblicità, niente telemetria, nessun account. diff --git a/fastlane/metadata/android/it-IT/images/phoneScreenshots/1.png b/fastlane/metadata/android/it-IT/images/phoneScreenshots/1.png new file mode 100644 index 0000000..1adf719 Binary files /dev/null and b/fastlane/metadata/android/it-IT/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/it-IT/images/phoneScreenshots/2.png b/fastlane/metadata/android/it-IT/images/phoneScreenshots/2.png new file mode 100644 index 0000000..ac01a89 Binary files /dev/null and b/fastlane/metadata/android/it-IT/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/it-IT/images/phoneScreenshots/3.png b/fastlane/metadata/android/it-IT/images/phoneScreenshots/3.png new file mode 100644 index 0000000..5cd2d14 Binary files /dev/null and b/fastlane/metadata/android/it-IT/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/it-IT/images/phoneScreenshots/4.png b/fastlane/metadata/android/it-IT/images/phoneScreenshots/4.png new file mode 100644 index 0000000..26c9fe1 Binary files /dev/null and b/fastlane/metadata/android/it-IT/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/it-IT/images/phoneScreenshots/5.png b/fastlane/metadata/android/it-IT/images/phoneScreenshots/5.png new file mode 100644 index 0000000..eb6bdf3 Binary files /dev/null and b/fastlane/metadata/android/it-IT/images/phoneScreenshots/5.png differ diff --git a/fastlane/metadata/android/it-IT/images/phoneScreenshots/6.png b/fastlane/metadata/android/it-IT/images/phoneScreenshots/6.png new file mode 100644 index 0000000..8d224d3 Binary files /dev/null and b/fastlane/metadata/android/it-IT/images/phoneScreenshots/6.png differ diff --git a/packaging/fdroid/metadata/software.arasaka.meweb/it/summary.txt b/fastlane/metadata/android/it-IT/short_description.txt similarity index 100% rename from packaging/fdroid/metadata/software.arasaka.meweb/it/summary.txt rename to fastlane/metadata/android/it-IT/short_description.txt diff --git a/packaging/fdroid/metadata/software.arasaka.meweb/it/name.txt b/fastlane/metadata/android/it-IT/title.txt similarity index 100% rename from packaging/fdroid/metadata/software.arasaka.meweb/it/name.txt rename to fastlane/metadata/android/it-IT/title.txt diff --git a/packaging/fdroid/metadata/software.arasaka.meweb.yml b/packaging/fdroid/metadata/software.arasaka.meweb.yml deleted file mode 100644 index aaf5f30..0000000 --- a/packaging/fdroid/metadata/software.arasaka.meweb.yml +++ /dev/null @@ -1,10 +0,0 @@ -Categories: - - Connectivity - - Internet -License: AGPL-3.0-only -AuthorName: Arasaka di Hvozdikov Yevheniy -WebSite: https://meweb.arasaka.software -SourceCode: https://git.thepra.dev/Arasaka/MeWeb -IssueTracker: https://git.thepra.dev/Arasaka/MeWeb/issues -Changelog: https://git.thepra.dev/Arasaka/MeWeb/releases -AutoName: me-web? diff --git a/packaging/fdroid/metadata/software.arasaka.meweb/en-US/description.txt b/packaging/fdroid/metadata/software.arasaka.meweb/en-US/description.txt deleted file mode 100644 index 356e37a..0000000 --- a/packaging/fdroid/metadata/software.arasaka.meweb/en-US/description.txt +++ /dev/null @@ -1 +0,0 @@ -“me-web?” checks your connection and your device and tells you, in plain words, what you could host at home, what stands in the way, and what would fix it. Free software, no telemetry, no account. diff --git a/packaging/fdroid/metadata/software.arasaka.meweb/it/description.txt b/packaging/fdroid/metadata/software.arasaka.meweb/it/description.txt deleted file mode 100644 index ba38bd8..0000000 --- a/packaging/fdroid/metadata/software.arasaka.meweb/it/description.txt +++ /dev/null @@ -1 +0,0 @@ -«me-web?» controlla la sua connessione e il suo dispositivo e le dice, in parole semplici, che cosa potrebbe ospitare a casa, che cosa lo impedisce e che cosa lo risolverebbe. Software libero, niente telemetria, nessun account. diff --git a/tools/Screenshots/Program.cs b/tools/Screenshots/Program.cs index a00b675..279a00d 100644 --- a/tools/Screenshots/Program.cs +++ b/tools/Screenshots/Program.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; + using Avalonia; using Avalonia.Controls; using Avalonia.Headless; @@ -10,18 +12,57 @@ using MeWeb.App.Services; using MeWeb.App.ViewModels; using MeWeb.App.Views; using MeWeb.Core.Checks; +using MeWeb.Core.Knowledge; +using MeWeb.Screenshots; using Microsoft.Extensions.DependencyInjection; -var output = args.Length > 0 ? args[0] : Path.Combine("dist", "screenshots"); -var codes = args.Length > 1 ? args[1].Split(',', StringSplitOptions.RemoveEmptyEntries) : Languages.All.Select(l => l.Code).ToArray(); +var store = args.Length > 0 && args[0] == "--store"; +var output = store ? args.Length > 1 ? args[1] : Path.Combine("fastlane", "metadata", "android") : args.Length > 0 ? args[0] : Path.Combine("dist", "screenshots"); +var codes = !store && args.Length > 1 ? args[1].Split(',', StringSplitOptions.RemoveEmptyEntries) : Languages.All.Select(l => l.Code).ToArray(); Directory.CreateDirectory(output); var data = Directory.CreateTempSubdirectory("meweb-screenshots-"); AppServices.Folder = data.FullName; +if (store) App.Platform = new StorePhone(); var session = HeadlessUnitTestSession.StartNew(typeof(ScreenshotEntry)); await session.Dispatch(async () => { + if (store) + { + var invented = StoreRun.Finished(App.Services.GetRequiredService()); + var early = StoreRun.Early(invented); + foreach (var (code, locale) in new[] { ("en", "en-US"), ("it", "it-IT") }) + { + Localizer.Instance.SetLanguage(Languages.Find(code)); + var folder = Path.Combine(locale, "images", "phoneScreenshots"); + Phone(Path.Combine(folder, "2"), model => model.Replay(invented), + view => view.GetVisualDescendants().OfType().First(t => t.Text == Localizer.Instance["UsesTitle"])); + Phone(Path.Combine(folder, "3"), model => model.Replay(invented)); + Phone(Path.Combine(folder, "1"), model => model.Status = Reachability.Online); + Phone(Path.Combine(folder, "4"), model => + { + model.Replay(invented); + model.Cards.First(c => c.Id == CheckId.Nat).IsExpanded = true; + }, view => view.GetVisualDescendants().OfType().First(b => b.Classes.Contains("card") && b.DataContext is CardViewModel { Id: CheckId.Nat })); + Phone(Path.Combine(folder, "5"), model => + { + model.Replay(early); + model.Status = Reachability.Online; + model.IsChecking = true; + model.AskReach = false; + model.OfferShare = false; + model.Uses.Clear(); + foreach (var id in Enum.GetValues()) model.Uses.Add(new UseViewModel(id, null)); + }); + Phone(Path.Combine(folder, "6"), model => + { + model.Replay(invented); + model.OpenShareCommand.Execute(null); + }, view => view.GetVisualDescendants().OfType().First(t => t.Text == Localizer.Instance["ShareExact"])); + } + return 0; + } var run = await App.Services.GetRequiredService().Run((_, _) => { }, CancellationToken.None); Console.WriteLine($"run: {string.Join(", ", run.Findings.Select(f => $"{f.Id}={f.Outcome}"))}"); foreach (var (width, height, label) in new[] { (1100, 1500, "desktop"), (400, 8000, "phone") }) @@ -54,7 +95,15 @@ await session.Dispatch(async () => data.Delete(true); Environment.Exit(0); -void Shoot(int width, int height, string name, Action prepare, Action arrange = null) +void Phone(string name, Action prepare, Func top = null) => Shoot(405, 720, name, prepare, top is null ? null : view => +{ + var target = top(view); + var scroller = target.FindAncestorOfType(); + var at = target.TranslatePoint(default, (Visual)scroller.Content); + scroller.Offset = new Vector(0, Math.Max(0, (at?.Y ?? 0) - 8)); +}, 1080 / 405.0); + +void Shoot(int width, int height, string name, Action prepare, Action arrange = null, double scale = 1) { var fresh = AppSettings.Read(); fresh.StorySeen = false; @@ -63,7 +112,19 @@ void Shoot(int width, int height, string name, Action prepare, Ac var view = new MainView { DataContext = model }; var window = new Window { Width = width, Height = height, Content = view }; window.Show(); + window.SetRenderScaling(scale); model.AvailableWidth = width; + if (store) + { + var patience = Stopwatch.StartNew(); + do + { + Dispatcher.UIThread.RunJobs(); + Thread.Sleep(20); + } + while (model.Status == Reachability.Unknown && patience.Elapsed < TimeSpan.FromSeconds(20)); + model.LatestVersion = null; + } prepare(model); for (var i = 0; i < 6; i++) Dispatcher.UIThread.RunJobs(); if (arrange is not null) @@ -75,6 +136,7 @@ void Shoot(int width, int height, string name, Action prepare, Ac Dispatcher.UIThread.RunJobs(); var frame = window.CaptureRenderedFrame(); var path = Path.Combine(output, $"{name}.png"); + Directory.CreateDirectory(Path.GetDirectoryName(path)); if (frame is not null) { using var file = File.Create(path); diff --git a/tools/Screenshots/StoreRun.cs b/tools/Screenshots/StoreRun.cs new file mode 100644 index 0000000..087f753 --- /dev/null +++ b/tools/Screenshots/StoreRun.cs @@ -0,0 +1,112 @@ +using MeWeb.Core.Checks; +using MeWeb.Core.History; +using MeWeb.Core.Knowledge; +using MeWeb.Core.Mail; +using MeWeb.Core.Net; +using MeWeb.Core.Router; +using MeWeb.Shared; + +namespace MeWeb.Screenshots; + +public class StorePhone : SystemPlatform +{ + public override DeviceKind Device => DeviceKind.Phone; + + public override string Name => "android"; +} + +public static class StoreRun +{ + const string PhoneAddress = "192.168.1.23"; + const string PhoneAddressV6 = "2001:db8:4d2:1a00:5c3e:9bff:fe12:3456"; + const string RouterInside = "192.168.1.1"; + const string RouterOutside = "100.64.12.34"; + const string SharedExit = "203.0.113.45"; + const uint ExampleAsn = 64500; + const string ExampleProvider = "Example Net"; + + public static RunReport Finished(KnowledgeBase knowledge) + { + var run = new RunReport + { + StartedAt = new DateTimeOffset(2026, 11, 2, 18, 30, 0, TimeSpan.Zero), + Device = DeviceKind.Phone, + Online = new OnlineReport { State = OnlineState.Online }, + Local = new LocalSnapshot + { + Interfaces = [new LocalInterface { Name = "wlan0", Kind = LinkKind.Wifi, SpeedMbps = 866, Addresses = [PhoneAddress, PhoneAddressV6], Gateways = [RouterInside, "fe80::1"] }], + Dns = [RouterInside], + }, + PublicV4 = SharedExit, + PublicV6 = PhoneAddressV6, + NetworkV4 = new AsnEntry { Number = ExampleAsn, Name = ExampleProvider }, + NetworkV6 = new AsnEntry { Number = ExampleAsn, Name = ExampleProvider }, + BlockV4 = new RegistryBlock { Name = "EXAMPLENET-HOME", Status = BlockStatus.Assigned }, + BlockV6 = new RegistryBlock { Name = "EXAMPLENET-HOME", Status = BlockStatus.Assigned }, + Router = new RouterReport { Gateway = RouterInside, Upnp = Answer.Answered, UpnpVersion = 2, WanAddress = RouterOutside }, + Hops = + [ + new Hop { Distance = 1, Address = RouterInside, Kind = AddressKind.Private }, + new Hop { Distance = 2, Address = "100.64.0.1", Kind = AddressKind.Shared }, + new Hop { Distance = 3, Address = "198.51.100.17", Kind = AddressKind.Public }, + new Hop { Distance = 4, Address = "198.51.100.90", Kind = AddressKind.Public }, + new Hop { Distance = 5, Address = "192.0.2.80", Kind = AddressKind.Public, IsDestination = true }, + ], + Stun = new StunReport + { + LocalPort = 49731, + FirstServer = $"{Endpoints.SecondStunHost}:{Endpoints.SecondStunPort}", + FirstMapped = $"{SharedExit}:40071", + SecondServer = $"{Endpoints.StunHost}:{Endpoints.StunPort}", + SecondMapped = $"{SharedExit}:40071", + Behaviour = NatBehaviour.Easy, + Ports = new PortReport { Pattern = PortPattern.Block, Low = 40071, High = 40086, BlockLow = 38912, BlockSize = 2048, Hairpin = false, LifetimeDone = true, KeptAtLeast = 120 }, + }, + Mail = new MailReport + { + Port25 = PortState.Blocked, + ReverseName = "203-0-113-45.home.example.net", + ReverseLooksDynamic = true, + ReverseConfirmed = true, + Listings = + [ + new Listing { List = "Spamhaus ZEN", State = ListingState.Listed, Residential = true, Code = "127.0.0.10" }, + new Listing { List = "SpamCop", State = ListingState.Clean }, + new Listing { List = "Barracuda", State = ListingState.Clean }, + ], + }, + Dns = new DnsReport { Resolvers = [RouterInside], Validates = true, Hijacks = false, Ipv6Names = true }, + DeviceReport = new DeviceReport { Kind = DeviceKind.Phone, System = "Android 15 (API 35, Arm64)", Cores = 8, MemoryMb = 7680, FreeDiskGb = 64, OnBattery = true }, + Speed = new SpeedReport { DownMbps = 312.4, UpMbps = 48.7, DownBytes = 12 * 1024 * 1024, UpBytes = 4 * 1024 * 1024, RoundTripMs = 21 }, + History = new HistorySummary { Checks = 3, Since = new DateTimeOffset(2026, 10, 19, 9, 0, 0, TimeSpan.Zero), AddressesSeen = 1, Changes = 0 }, + }; + var (nat, kind) = Verdicts.Nat(run.Local, run.Router, run.PublicV4, run.PublicV6, run.Hops); + run.Nat = kind; + run.Findings = + [ + Verdicts.Online(run.Online, run.Local), + Verdicts.Connection(run.Local, null, knowledge), + Verdicts.Addresses(run.PublicV4, run.PublicV6, run.Local, run.Router), + Verdicts.Provider(knowledge, run.NetworkV4, run.NetworkV6, run.BlockV4, run.BlockV6, true, false, run), + Verdicts.Router(run.Router, run.Local), + nat, + Verdicts.Behaviour(run.Stun, run.Nat, false), + Verdicts.Mail(run.Mail, true), + Verdicts.Dns(run.Dns), + Verdicts.Speed(run.Speed), + Verdicts.Device(run.DeviceReport), + Verdicts.History(run.History), + ]; + run.Uses = Uses.Decide(run); + return run; + } + + public static RunReport Early(RunReport finished) => new() + { + StartedAt = finished.StartedAt, + Device = finished.Device, + Local = finished.Local, + DeviceReport = finished.DeviceReport, + Findings = [.. finished.Findings.Where(f => f.Id is CheckId.Connection or CheckId.Device)], + }; +}