F-Droid page: screenshots, a banner and a fuller description
Build / Desktop, server and tools (push) Successful in 1m5s

The app's page in F-Droid had a stretched icon across the top, no pictures and two lines of text. It now shows six
screenshots in English and Italian (the opening screen, what you can host, the path from the phone to the internet,
one finding explained, a check under way and exactly what sharing would send), a banner, the icon at full size and
a description of what the app checks and what stays on the phone.

The screenshots are drawn from an invented connection, not the computer that makes them: documentation addresses,
a made-up provider, nothing that belongs to anyone. One command redraws them after a change to the screens:
dotnet run --project tools/Screenshots -- --store. The listing is in the layout Google Play uses, so the same files
will serve there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4YtEKqxKjKyHkFzuRVMNA
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-11-08 20:53:50 +01:00
1 parent 8c079e43c9
commit 5b2210a8a7
26 files changed
+214 -18

No files matched your search

+7 -3
View File
@@ -13,6 +13,7 @@ dotnet run tools/setup.cs -- --group packaging # pinned nfpm into to
dotnet build src/MeWeb.Android/MeWeb.Android.csproj \ 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 -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 -- 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 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 -- check # every language against English
dotnet run tools/strings.cs -- set Key values.json # one key, every language at once 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` | | `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/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/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) | | `branding/` | the icon (SVG source, PNG, ICO) |
## Pitfalls ## 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 - **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 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 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 - **The store listing is `fastlane/metadata/android/`** (`en-US` and `it-IT`: name, short and full description,
release's tag. The repository's own settings and icon moved there. 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 - **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`. 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 - `packaging/arasaka-repository.sh` is written into both maintainer scripts by `tools/release.cs`; it is
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 631 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

@@ -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?
@@ -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.
@@ -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.
+65 -3
View File
@@ -1,3 +1,5 @@
using System.Diagnostics;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Headless; using Avalonia.Headless;
@@ -10,18 +12,57 @@ using MeWeb.App.Services;
using MeWeb.App.ViewModels; using MeWeb.App.ViewModels;
using MeWeb.App.Views; using MeWeb.App.Views;
using MeWeb.Core.Checks; using MeWeb.Core.Checks;
using MeWeb.Core.Knowledge;
using MeWeb.Screenshots;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
var output = args.Length > 0 ? args[0] : Path.Combine("dist", "screenshots"); var store = args.Length > 0 && args[0] == "--store";
var codes = args.Length > 1 ? args[1].Split(',', StringSplitOptions.RemoveEmptyEntries) : Languages.All.Select(l => l.Code).ToArray(); 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); Directory.CreateDirectory(output);
var data = Directory.CreateTempSubdirectory("meweb-screenshots-"); var data = Directory.CreateTempSubdirectory("meweb-screenshots-");
AppServices.Folder = data.FullName; AppServices.Folder = data.FullName;
if (store) App.Platform = new StorePhone();
var session = HeadlessUnitTestSession.StartNew(typeof(ScreenshotEntry)); var session = HeadlessUnitTestSession.StartNew(typeof(ScreenshotEntry));
await session.Dispatch(async () => await session.Dispatch(async () =>
{ {
if (store)
{
var invented = StoreRun.Finished(App.Services.GetRequiredService<KnowledgeBase>());
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<TextBlock>().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<Border>().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<UseId>()) model.Uses.Add(new UseViewModel(id, null));
});
Phone(Path.Combine(folder, "6"), model =>
{
model.Replay(invented);
model.OpenShareCommand.Execute(null);
}, view => view.GetVisualDescendants().OfType<TextBlock>().First(t => t.Text == Localizer.Instance["ShareExact"]));
}
return 0;
}
var run = await App.Services.GetRequiredService<CheckRunner>().Run((_, _) => { }, CancellationToken.None); var run = await App.Services.GetRequiredService<CheckRunner>().Run((_, _) => { }, CancellationToken.None);
Console.WriteLine($"run: {string.Join(", ", run.Findings.Select(f => $"{f.Id}={f.Outcome}"))}"); 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") }) foreach (var (width, height, label) in new[] { (1100, 1500, "desktop"), (400, 8000, "phone") })
@@ -54,7 +95,15 @@ await session.Dispatch(async () =>
data.Delete(true); data.Delete(true);
Environment.Exit(0); Environment.Exit(0);
void Shoot(int width, int height, string name, Action<MainViewModel> prepare, Action<MainView> arrange = null) void Phone(string name, Action<MainViewModel> prepare, Func<MainView, Control> top = null) => Shoot(405, 720, name, prepare, top is null ? null : view =>
{
var target = top(view);
var scroller = target.FindAncestorOfType<ScrollViewer>();
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<MainViewModel> prepare, Action<MainView> arrange = null, double scale = 1)
{ {
var fresh = AppSettings.Read(); var fresh = AppSettings.Read();
fresh.StorySeen = false; fresh.StorySeen = false;
@@ -63,7 +112,19 @@ void Shoot(int width, int height, string name, Action<MainViewModel> prepare, Ac
var view = new MainView { DataContext = model }; var view = new MainView { DataContext = model };
var window = new Window { Width = width, Height = height, Content = view }; var window = new Window { Width = width, Height = height, Content = view };
window.Show(); window.Show();
window.SetRenderScaling(scale);
model.AvailableWidth = width; 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); prepare(model);
for (var i = 0; i < 6; i++) Dispatcher.UIThread.RunJobs(); for (var i = 0; i < 6; i++) Dispatcher.UIThread.RunJobs();
if (arrange is not null) if (arrange is not null)
@@ -75,6 +136,7 @@ void Shoot(int width, int height, string name, Action<MainViewModel> prepare, Ac
Dispatcher.UIThread.RunJobs(); Dispatcher.UIThread.RunJobs();
var frame = window.CaptureRenderedFrame(); var frame = window.CaptureRenderedFrame();
var path = Path.Combine(output, $"{name}.png"); var path = Path.Combine(output, $"{name}.png");
Directory.CreateDirectory(Path.GetDirectoryName(path));
if (frame is not null) if (frame is not null)
{ {
using var file = File.Create(path); using var file = File.Create(path);
+112
View File
@@ -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)],
};
}