From 0dffbc27d63b0716055c89c8e43d75c57720052e Mon Sep 17 00:00:00 2001 From: thepra Date: Thu, 15 Oct 2026 04:39:03 +0200 Subject: [PATCH] The checks engine, the path and the cards: me-web? 0.1 reads the connection MeWeb.Core runs seven checks concurrently and reports each as it settles: online or behind a login page, this connection, the public IPv4 and IPv6 seen from v4./v6.meweb, the provider from a built-in map, the router (UPnP IGD, NAT-PMP and PCP, read-only), address sharing (router WAN against the echo, TTL hops only as a hint), and the history kept on the device. The app draws the path from this device to the internet and lights it node by node, with a card per check and a sheet listing every host it contacts. The provider map is iptoasn's table compressed to 2.1 MB by tools/knowledge.cs and built before every app release; eleven Italian provider profiles carry each fact with who said it, the source and the month. The server gains a download page (en, it) and /download/{platform} from the latest Gitea release. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB --- .gitea/scripts/release.sh | 3 +- .gitignore | 1 + CLAUDE.md | 21 +- MeWeb.slnx | 1 + PATTERNS.md | 8 +- README.md | 40 ++- data/isps/it-aruba.json | 33 ++ data/isps/it-dimensione.json | 27 ++ data/isps/it-ehiweb.json | 27 ++ data/isps/it-eolo.json | 28 ++ data/isps/it-fastweb.json | 32 ++ data/isps/it-iliad.json | 32 ++ data/isps/it-sky.json | 32 ++ data/isps/it-tim.json | 32 ++ data/isps/it-tiscali.json | 28 ++ data/isps/it-vodafone.json | 32 ++ data/isps/it-windtre.json | 19 ++ deploy/nginx/meweb.arasaka.software.conf | 3 +- deploy/nginx/meweb.arasaka.software.http.conf | 1 + src/MeWeb.Android/AndroidPlatform.cs | 83 +++++ src/MeWeb.Android/MainApplication.cs | 4 +- src/MeWeb.App/App.axaml | 1 + src/MeWeb.App/App.axaml.cs | 5 +- src/MeWeb.App/AppServices.cs | 25 +- src/MeWeb.App/MeWeb.App.csproj | 1 + src/MeWeb.App/Resources/Colors.axaml | 18 +- src/MeWeb.App/Resources/Icons.axaml | 29 ++ src/MeWeb.App/Resources/Styles.axaml | 270 +++++++++++++++- src/MeWeb.App/Services/Languages.cs | 2 +- src/MeWeb.App/Services/Localizer.cs | 12 +- src/MeWeb.App/Services/ServerClient.cs | 33 -- src/MeWeb.App/Services/Texts.cs | 52 +++ src/MeWeb.App/Strings/en.json | 160 ++++++++- src/MeWeb.App/Strings/it.json | 160 ++++++++- src/MeWeb.App/ViewModels/CardViewModel.cs | 70 ++++ src/MeWeb.App/ViewModels/MainViewModel.cs | 306 +++++++++++++++++- src/MeWeb.App/ViewModels/PathNodeViewModel.cs | 64 ++++ src/MeWeb.App/Views/MainView.axaml | 259 ++++++++++++--- src/MeWeb.App/Views/MainView.axaml.cs | 16 +- src/MeWeb.Core/Checks/CheckRunner.cs | 126 ++++++++ src/MeWeb.Core/Checks/Finding.cs | 70 ++++ src/MeWeb.Core/Checks/ServerProbe.cs | 120 +++++++ src/MeWeb.Core/Checks/Verdicts.cs | 255 +++++++++++++++ src/MeWeb.Core/History/HistoryStore.cs | 103 ++++++ src/MeWeb.Core/Knowledge/AsnTable.cs | 177 ++++++++++ src/MeWeb.Core/Knowledge/Providers.cs | 65 ++++ src/MeWeb.Core/MeWeb.Core.csproj | 17 + src/MeWeb.Core/Net/Addresses.cs | 65 ++++ src/MeWeb.Core/Net/LocalNetwork.cs | 170 ++++++++++ src/MeWeb.Core/Net/Route.cs | 63 ++++ src/MeWeb.Core/Router/Gateway.cs | 216 +++++++++++++ src/MeWeb.Core/Router/RouterReport.cs | 30 ++ src/MeWeb.Server/DownloadPage.cs | 137 ++++++++ src/MeWeb.Server/Hosting.cs | 6 + src/MeWeb.Server/Releases.cs | 93 ++++++ src/MeWeb.Server/ServerApi.cs | 20 ++ src/MeWeb.Shared/Endpoints.cs | 10 + tools/Screenshots/Program.cs | 43 ++- tools/knowledge.cs | 62 ++++ tools/release.cs | 22 ++ tools/strings.cs | 59 +++- 61 files changed, 3750 insertions(+), 149 deletions(-) create mode 100644 data/isps/it-aruba.json create mode 100644 data/isps/it-dimensione.json create mode 100644 data/isps/it-ehiweb.json create mode 100644 data/isps/it-eolo.json create mode 100644 data/isps/it-fastweb.json create mode 100644 data/isps/it-iliad.json create mode 100644 data/isps/it-sky.json create mode 100644 data/isps/it-tim.json create mode 100644 data/isps/it-tiscali.json create mode 100644 data/isps/it-vodafone.json create mode 100644 data/isps/it-windtre.json create mode 100644 src/MeWeb.Android/AndroidPlatform.cs create mode 100644 src/MeWeb.App/Resources/Icons.axaml delete mode 100644 src/MeWeb.App/Services/ServerClient.cs create mode 100644 src/MeWeb.App/Services/Texts.cs create mode 100644 src/MeWeb.App/ViewModels/CardViewModel.cs create mode 100644 src/MeWeb.App/ViewModels/PathNodeViewModel.cs create mode 100644 src/MeWeb.Core/Checks/CheckRunner.cs create mode 100644 src/MeWeb.Core/Checks/Finding.cs create mode 100644 src/MeWeb.Core/Checks/ServerProbe.cs create mode 100644 src/MeWeb.Core/Checks/Verdicts.cs create mode 100644 src/MeWeb.Core/History/HistoryStore.cs create mode 100644 src/MeWeb.Core/Knowledge/AsnTable.cs create mode 100644 src/MeWeb.Core/Knowledge/Providers.cs create mode 100644 src/MeWeb.Core/MeWeb.Core.csproj create mode 100644 src/MeWeb.Core/Net/Addresses.cs create mode 100644 src/MeWeb.Core/Net/LocalNetwork.cs create mode 100644 src/MeWeb.Core/Net/Route.cs create mode 100644 src/MeWeb.Core/Router/Gateway.cs create mode 100644 src/MeWeb.Core/Router/RouterReport.cs create mode 100644 src/MeWeb.Server/DownloadPage.cs create mode 100644 src/MeWeb.Server/Releases.cs create mode 100644 tools/knowledge.cs diff --git a/.gitea/scripts/release.sh b/.gitea/scripts/release.sh index 39c15f1..6d0243c 100755 --- a/.gitea/scripts/release.sh +++ b/.gitea/scripts/release.sh @@ -8,7 +8,8 @@ json() { python3 -c "import json,sys; d=json.load(sys.stdin); print($1)"; } if id=$(call "$api/releases/tags/$tag" 2>/dev/null | json 'd["id"]'); then echo "release $tag exists (id $id)" else - body=$(python3 -c 'import json,sys; print(json.dumps({"tag_name": sys.argv[1], "name": sys.argv[1], "draft": False, "prerelease": sys.argv[1].startswith("v0.")}))' "$tag") + notes=artifacts/release-notes.md + body=$(python3 -c 'import json,os,sys; notes=open(sys.argv[2]).read() if os.path.exists(sys.argv[2]) else ""; print(json.dumps({"tag_name": sys.argv[1], "name": sys.argv[1], "body": notes, "draft": False, "prerelease": sys.argv[1].startswith("v0.")}))' "$tag" "$notes") id=$(call -X POST -H 'Content-Type: application/json' -d "$body" "$api/releases" | json 'd["id"]') echo "created release $tag (id $id)" fi diff --git a/.gitignore b/.gitignore index fe64790..4ef3d9c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ appsettings.*.local.json /tools/cache/ /tools/logs/ /.claude/settings.local.json +/artifacts/ diff --git a/CLAUDE.md b/CLAUDE.md index 21eeb91..ca9f4bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,9 @@ dotnet build src/MeWeb.Android/MeWeb.Android.csproj \ dotnet run --project tools/Screenshots -- dist/screenshots [en,it,...] 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 -- merge keys.json # many keys: {"Key": {"en": "…", "it": "…"}} +dotnet run tools/strings.cs -- import fr fr.json # one language's drafts: {"Key": "…"} +dotnet run tools/knowledge.cs # provider map -> artifacts/knowledge/ip2asn.bin dotnet run tools/languages.cs # regenerate data/languages.json from CLDR dotnet publish src/MeWeb.Desktop -c Release -r win-x64 --self-contained -p:PublishSingleFile=true \ -p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true @@ -56,17 +59,33 @@ The Android release key: alias `meweb`, certificate SHA-256 | Path | What | |---|---| | `src/MeWeb.Shared` | `Endpoints.cs` (routes and hosts), wire contracts, `MeWebJsonContext`, generated `BuildInfo` | +| `src/MeWeb.Core` | the checks, no UI: `Net/` (address kinds, local network, TTL route), `Router/` (UPnP IGD, NAT-PMP, PCP, read-only), `Knowledge/` (provider map and profiles), `History/`, `Checks/` (`CheckRunner`, `Verdicts`, `ServerProbe`) | | `src/MeWeb.App` | Avalonia views and view models, `Services/Localizer.cs` (+ `{s:Tr Key}`), `Strings/.json`, `Resources/Colors.axaml` | | `src/MeWeb.Desktop` | desktop head (`meweb` executable), Windows manifest | | `src/MeWeb.Android` | Android head (`software.arasaka.meweb`), resources, manifest | -| `src/MeWeb.Server` | `meweb.arasaka.software`: `/api/info`, `/api/ip` | +| `src/MeWeb.Server` | `meweb.arasaka.software`: `/api/info`, `/api/ip`, the download page `/` (en, it) and `/download/{platform}` from the latest Gitea release | | `data/languages.json` | 113 languages: code, autonym, English name, `rtl`, `reviewed` or `draft` | +| `data/isps/*.json` | provider profiles: AS numbers, and per fact a value, who said it (`basis`), the source URL and the month | +| `tools/knowledge.cs` | downloads iptoasn's `ip2asn-combined.tsv.gz` (cached in `tools/cache`, 12 h) and writes the brotli table `artifacts/knowledge/ip2asn.bin` (~2 MB), embedded by `MeWeb.Core` when present; `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 | | `branding/` | the icon (SVG source, PNG, ICO) | ## Pitfalls +- **A TTL ping needs an empty payload off-root.** .NET on Linux falls back to the `ping` utility without + `CAP_NET_RAW` and refuses any custom buffer ("Unable to send custom ping payload"); `SendPingAsync(target, timeout, [], + new PingOptions(ttl, false))` works and reports `TimeExceeded` with the hop's address. +- **A 100.64.x.x hop is not proof of CGNAT.** The owner's Dimensione line has a public IPv4 on the FRITZ!Box and still + shows `100.64.255.255` as hop 2. Only the router's own WAN address compared with the echo decides; hops are a hint. +- **Routers answer the SSDP search they don't implement.** The FRITZ!Box 5530 answers an IGD:2 search while describing + itself as IGD:1; the version comes from the description's `deviceType`. Its PCP answers `NOT_AUTHORIZED` (2). +- **`HeadlessUnitTestSession.Dispatch` builds a fresh `Application` each call**, and `AttachDeveloperTools` throws the + second time: the screenshot tool does the real check and every shot inside one `Dispatch`. +- **Fluent's `Expander` paints its header from resources** (`ExpanderHeaderBackground`, …), not from `Background`; the + `Expander.explain` style overrides them in `Style.Resources`. +- **An address inside right-to-left text reorders around `→`.** `Texts.Isolate` wraps every literal value in U+2068/U+2069. + - **Avalonia 12 Android head:** `[Application] MainApplication : AvaloniaAndroidApplication` plus a non-generic `AvaloniaMainActivity`; the main view comes through `IActivityApplicationLifetime.MainViewFactory`, checked before `ISingleViewApplicationLifetime`. diff --git a/MeWeb.slnx b/MeWeb.slnx index dabeee2..813d5ee 100644 --- a/MeWeb.slnx +++ b/MeWeb.slnx @@ -1,6 +1,7 @@ + diff --git a/PATTERNS.md b/PATTERNS.md index 6ab1c98..dddfa01 100644 --- a/PATTERNS.md +++ b/PATTERNS.md @@ -89,7 +89,9 @@ Scope: `src/`, `tools/`. Out of scope: generated files (`obj/`, `*.g.cs`), third ## F. Data -25. **One data accessor per store**: `MongoContext` on the server, `KnowledgeStore` and `HistoryStore` in the App. +25. **One data accessor per store**: `MongoContext` on the server, `KnowledgeBase` and `HistoryStore` in `MeWeb.Core`. + No SQLite: the provider map is a sorted brotli table read into arrays, the history a small JSON file, so no + native library has to ship on four platforms. 26. **Entity → view is a hand-written `ToView()`.** No mapper library. 56. **A multi-row mutation is one server call.** 74. **No in-between DTOs — a response DTO is pure data; `WebResult` carries ok/error.** @@ -182,3 +184,7 @@ Scope: `src/`, `tools/`. Out of scope: generated files (`obj/`, `*.g.cs`), third link; the root `FlowDirection` follows `rtl`. 126. **`?` appears only in display text.** File names, package ids and paths use `MeWeb`, `meweb` and `software.arasaka.meweb`. +127. **A provider fact carries who said it, where and when.** Every value in `data/isps/*.json` has `basis` + (`operator`, `users`, `one-user`, `press`, `research`), `source` (a URL) and `date` (`yyyy-MM` or `yyyy`), and + the card shows all three. A fact nobody published is left out, not guessed; the values describe what is usual + for the provider, never the person's contract. diff --git a/README.md b/README.md index 1140142..2164444 100644 --- a/README.md +++ b/README.md @@ -3,21 +3,25 @@ **Can you run your own server from here?** 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. -> Status: early development (0.1). Nothing is released yet. +> Status: early development. Version 0.1 is the first release. ## What it checks -- whether you are online, or behind a login page; -- your public address, and whether your provider shares it with other customers (CGNAT) or puts more than one - router between you and the internet; -- whether your router lets an app open a port, and whether the outside world can actually reach you, over IPv4 and - IPv6; -- whether you can send mail directly, whether your address is on a residential blocklist, and what its reverse name - says; -- your DNS, your speed, and whether this device is a sensible server. +Version 0.1 checks, and draws as a path from this device to the internet: -Each check ends with what it means: a website at home, reaching home from outside, a mail server, a game server, -family photos and files — possible, possible with a workaround, or not possible from here. +- whether you are online, or behind a login page; +- how this device is connected, and the address your router gave it; +- your public IPv4 and IPv6 addresses, and which device holds them; +- your provider, found offline in a built-in map, with what is usual for it (shared IPv4, an address of your own, + IPv6, the mail port) and the source and date of each note; +- whether your router answers apps that ask it to open ports (UPnP, NAT-PMP, PCP), and which internet address it has; +- whether your provider shares your public IPv4 address with other customers (CGNAT) or there is a second router + in the way; +- how your public address has changed across your earlier checks. + +Planned for later versions, not in 0.1: whether the outside world can actually reach you, over IPv4 and IPv6; the +kind of NAT; sending mail, residential blocklists and the reverse name; DNS; speed; whether this device is a sensible +server; and sharing your findings to improve the provider map. ## Platforms @@ -25,9 +29,8 @@ Linux (deb, rpm), Windows, macOS and Android. Builds are published as releases o ## Privacy -me-web? sends nothing anywhere unless a check needs it, and lists every host it contacts. It has no telemetry. At -the end you can choose to share your findings to improve the next release's map of providers; you see exactly -what is sent first, and you can delete it afterwards. +me-web? sends nothing anywhere unless a check needs it, and lists every host it contacts ("What this app contacts" +in the app). It has no telemetry, no analytics and no crash reporting. Your checks are kept on your device only. ## Verifying the Android app @@ -35,6 +38,15 @@ The APK is signed with the key whose certificate SHA-256 is `5f:52:cf:f4:f5:2f:4e:0b:6d:a6:cd:04:4b:fc:ea:f7:8d:1d:42:db:24:f2:47:06:02:db:7f:19:cb:fd:f8:46` (`apksigner verify --print-certs MeWeb--android.apk`). Every release also carries `SHA256SUMS.txt`. +## Credits + +- Provider map: [iptoasn.com](https://iptoasn.com), Public Domain Dedication and License (PDDL 1.0), fetched for + every release; the release notes carry its date and SHA-256. +- Provider notes: each operator's own technical sheet (AGCOM 156/23 in Italy) and users' reports on + [FibraClick](https://forum.fibra.click), each cited in `data/isps/`. +- Icons: [Lucide](https://lucide.dev), ISC licence. +- Typeface: Inter, SIL Open Font License. + ## Building See `CLAUDE.md`. .NET 10, Avalonia 12. diff --git a/data/isps/it-aruba.json b/data/isps/it-aruba.json new file mode 100644 index 0000000..79f0c2f --- /dev/null +++ b/data/isps/it-aruba.json @@ -0,0 +1,33 @@ +{ + "name": "Aruba Fibra", + "country": "IT", + "asns": [ + 202613 + ], + "ipv4": { + "value": "shared", + "basis": "users", + "source": "https://forum.fibra.click/d/85993", + "date": "2026-10" + }, + "publicAddress": { + "value": "paid", + "basis": "users", + "source": "https://forum.fibra.click/d/85993", + "date": "2026-10", + "price": 3.0, + "currency": "EUR" + }, + "ipv6": { + "value": "native", + "basis": "users", + "source": "https://docs.google.com/spreadsheets/d/10cMvJ2l2AiMlKfSS3mPPHpK9QvrFe4iA1GHVVFxkFgM", + "date": "2024" + }, + "port25": { + "value": "open", + "basis": "one-user", + "source": "https://forum.fibra.click/d/52017", + "date": "2024-04" + } +} diff --git a/data/isps/it-dimensione.json b/data/isps/it-dimensione.json new file mode 100644 index 0000000..4fe19b8 --- /dev/null +++ b/data/isps/it-dimensione.json @@ -0,0 +1,27 @@ +{ + "name": "Dimensione", + "country": "IT", + "asns": [ + 202870 + ], + "ipv4": { + "value": "shared", + "basis": "users", + "source": "https://forum.fibra.click/d/65132", + "date": "2025-03" + }, + "publicAddress": { + "value": "paid", + "basis": "one-user", + "source": "https://forum.fibra.click/d/64099", + "date": "2025-02", + "price": 3.0, + "currency": "EUR" + }, + "ipv6": { + "value": "native", + "basis": "users", + "source": "https://docs.google.com/spreadsheets/d/10cMvJ2l2AiMlKfSS3mPPHpK9QvrFe4iA1GHVVFxkFgM", + "date": "2024" + } +} diff --git a/data/isps/it-ehiweb.json b/data/isps/it-ehiweb.json new file mode 100644 index 0000000..569219d --- /dev/null +++ b/data/isps/it-ehiweb.json @@ -0,0 +1,27 @@ +{ + "name": "Ehiweb", + "country": "IT", + "asns": [ + 43989 + ], + "ipv4": { + "value": "shared", + "basis": "users", + "source": "https://forum.fibra.click/d/53799", + "date": "2026-07" + }, + "publicAddress": { + "value": "paid", + "basis": "users", + "source": "https://forum.fibra.click/d/53799", + "date": "2026-07", + "price": 6.1, + "currency": "EUR" + }, + "ipv6": { + "value": "native", + "basis": "users", + "source": "https://docs.google.com/spreadsheets/d/10cMvJ2l2AiMlKfSS3mPPHpK9QvrFe4iA1GHVVFxkFgM", + "date": "2024" + } +} diff --git a/data/isps/it-eolo.json b/data/isps/it-eolo.json new file mode 100644 index 0000000..8f88a02 --- /dev/null +++ b/data/isps/it-eolo.json @@ -0,0 +1,28 @@ +{ + "name": "Eolo", + "country": "IT", + "asns": [ + 35612 + ], + "sheet": "https://www.eolo.it/pagine-legali/trasparenza-tecnica", + "ipv4": { + "value": "shared", + "basis": "operator", + "source": "https://www.eolo.it/pagine-legali/trasparenza-tecnica", + "date": "2026-09" + }, + "publicAddress": { + "value": "paid", + "basis": "press", + "source": "https://www.mondomobileweb.it/304712-eolo-casa-da-1990-euro-al-mese-per-12-mesi-ora-con-meno-giorni-di-prova-e-altre-novita/", + "date": "2025-07", + "price": 4.9, + "currency": "EUR" + }, + "ipv6": { + "value": "none", + "basis": "operator", + "source": "https://www.eolo.it/pagine-legali/trasparenza-tecnica", + "date": "2026-09" + } +} diff --git a/data/isps/it-fastweb.json b/data/isps/it-fastweb.json new file mode 100644 index 0000000..02deb73 --- /dev/null +++ b/data/isps/it-fastweb.json @@ -0,0 +1,32 @@ +{ + "name": "Fastweb", + "country": "IT", + "asns": [ + 12874 + ], + "sheet": "https://www.fastweb.it/downloads/PDF/famiglia/trasparenza-tecnica/Fibra_FTTH_NGN_GPON.pdf", + "ipv4": { + "value": "shared", + "basis": "operator", + "source": "https://www.fastweb.it/downloads/PDF/famiglia/trasparenza-tecnica/Fibra_FTTH_NGN_GPON.pdf", + "date": "2025-10" + }, + "publicAddress": { + "value": "free-on-request", + "basis": "operator", + "source": "https://www.fastweb.it/downloads/PDF/famiglia/trasparenza-tecnica/Fibra_FTTH_NGN_GPON.pdf", + "date": "2025-10" + }, + "ipv6": { + "value": "tunnel", + "basis": "users", + "source": "https://docs.google.com/spreadsheets/d/10cMvJ2l2AiMlKfSS3mPPHpK9QvrFe4iA1GHVVFxkFgM", + "date": "2024" + }, + "port25": { + "value": "blocked-on-request", + "basis": "operator", + "source": "https://www.fastweb.it/adsl-fibra-ottica/fastweb-informa/", + "date": "2022-09" + } +} diff --git a/data/isps/it-iliad.json b/data/isps/it-iliad.json new file mode 100644 index 0000000..8d53da9 --- /dev/null +++ b/data/isps/it-iliad.json @@ -0,0 +1,32 @@ +{ + "name": "Iliad Italia", + "country": "IT", + "asns": [ + 29447 + ], + "sheet": "https://www.iliad.it/trasparenza-tariffaria-fibra.html", + "ipv4": { + "value": "shared-ports", + "basis": "users", + "source": "https://forum.fibra.click/d/65796", + "date": "2025-03" + }, + "publicAddress": { + "value": "free-on-request", + "basis": "users", + "source": "https://forum.fibra.click/d/31600", + "date": "2025" + }, + "ipv6": { + "value": "native", + "basis": "users", + "source": "https://docs.google.com/spreadsheets/d/10cMvJ2l2AiMlKfSS3mPPHpK9QvrFe4iA1GHVVFxkFgM", + "date": "2024" + }, + "port25": { + "value": "blocked", + "basis": "one-user", + "source": "https://forum.fibra.click/d/29525", + "date": "2022-09" + } +} diff --git a/data/isps/it-sky.json b/data/isps/it-sky.json new file mode 100644 index 0000000..017a007 --- /dev/null +++ b/data/isps/it-sky.json @@ -0,0 +1,32 @@ +{ + "name": "Sky Wifi", + "country": "IT", + "asns": [ + 210278 + ], + "sheet": "https://www.sky.it/info/trasparenza-tecnica", + "ipv4": { + "value": "shared-until-forward", + "basis": "users", + "source": "https://forum.fibra.click/d/78733", + "date": "2026-10" + }, + "publicAddress": { + "value": "none", + "basis": "users", + "source": "https://forum.fibra.click/d/24997", + "date": "2026-10" + }, + "ipv6": { + "value": "native", + "basis": "users", + "source": "https://docs.google.com/spreadsheets/d/10cMvJ2l2AiMlKfSS3mPPHpK9QvrFe4iA1GHVVFxkFgM", + "date": "2024" + }, + "port25": { + "value": "open", + "basis": "one-user", + "source": "https://forum.fibra.click/d/28340", + "date": "2022" + } +} diff --git a/data/isps/it-tim.json b/data/isps/it-tim.json new file mode 100644 index 0000000..a3d668e --- /dev/null +++ b/data/isps/it-tim.json @@ -0,0 +1,32 @@ +{ + "name": "TIM", + "country": "IT", + "asns": [ + 3269 + ], + "sheet": "https://www.tim.it/assistenza/info-consumatori/fisso/pagina-di-trasparenza-tecnica", + "ipv4": { + "value": "public-dynamic", + "basis": "operator", + "source": "https://www.tim.it/assistenza/info-consumatori/fisso/pagina-di-trasparenza-tecnica", + "date": "2024-09" + }, + "publicAddress": { + "value": "business-only", + "basis": "users", + "source": "https://forum.fibra.click/d/34622", + "date": "2022" + }, + "ipv6": { + "value": "none", + "basis": "operator", + "source": "https://www.tim.it/assistenza/info-consumatori/fisso/pagina-di-trasparenza-tecnica", + "date": "2024-09" + }, + "port25": { + "value": "open", + "basis": "one-user", + "source": "https://forum.fibra.click/d/28340", + "date": "2022-02" + } +} diff --git a/data/isps/it-tiscali.json b/data/isps/it-tiscali.json new file mode 100644 index 0000000..1d9c7e5 --- /dev/null +++ b/data/isps/it-tiscali.json @@ -0,0 +1,28 @@ +{ + "name": "Tiscali", + "country": "IT", + "asns": [ + 8612 + ], + "sheet": "https://casa.tiscali.it/comunicazioni/trasparenza_tecnica/", + "ipv4": { + "value": "public-dynamic", + "basis": "users", + "source": "https://forum.fibra.click/d/45987", + "date": "2023-11" + }, + "publicAddress": { + "value": "paid", + "basis": "users", + "source": "https://forum.fibra.click/d/45987", + "date": "2023-11", + "price": 3.0, + "currency": "EUR" + }, + "ipv6": { + "value": "none", + "basis": "operator", + "source": "https://casa.tiscali.it/comunicazioni/trasparenza_tecnica/", + "date": "2026-05" + } +} diff --git a/data/isps/it-vodafone.json b/data/isps/it-vodafone.json new file mode 100644 index 0000000..c009e0b --- /dev/null +++ b/data/isps/it-vodafone.json @@ -0,0 +1,32 @@ +{ + "name": "Vodafone Italia", + "country": "IT", + "asns": [ + 30722 + ], + "sheet": "https://a-wb.vodafone.it/content/dam/webaem/privati-supporto/supporto-consumer/contratti-e-aggiornamenti/trasparenza_tecnica_consumer.pdf", + "ipv4": { + "value": "shared-until-forward", + "basis": "users", + "source": "https://forum.fibra.click/d/25881", + "date": "2022" + }, + "publicAddress": { + "value": "not-found", + "basis": "research", + "source": "https://a-wb.vodafone.it/content/dam/webaem/privati-supporto/supporto-consumer/contratti-e-aggiornamenti/trasparenza_tecnica_consumer.pdf", + "date": "2026-07" + }, + "ipv6": { + "value": "none", + "basis": "operator", + "source": "https://a-wb.vodafone.it/content/dam/webaem/privati-supporto/supporto-consumer/contratti-e-aggiornamenti/trasparenza_tecnica_consumer.pdf", + "date": "2026-07" + }, + "port25": { + "value": "closed", + "basis": "one-user", + "source": "https://forum.fibra.click/d/28340", + "date": "2022" + } +} diff --git a/data/isps/it-windtre.json b/data/isps/it-windtre.json new file mode 100644 index 0000000..e4be6b0 --- /dev/null +++ b/data/isps/it-windtre.json @@ -0,0 +1,19 @@ +{ + "name": "WindTre", + "country": "IT", + "asns": [ + 1267 + ], + "ipv4": { + "value": "public-dynamic", + "basis": "users", + "source": "https://forum.fibra.click/d/45987", + "date": "2025-03" + }, + "publicAddress": { + "value": "none", + "basis": "users", + "source": "https://forum.fibra.click/d/45987", + "date": "2023-11" + } +} diff --git a/deploy/nginx/meweb.arasaka.software.conf b/deploy/nginx/meweb.arasaka.software.conf index 761a879..3d21d9b 100644 --- a/deploy/nginx/meweb.arasaka.software.conf +++ b/deploy/nginx/meweb.arasaka.software.conf @@ -2,6 +2,7 @@ server { listen 80; listen [::]:80; server_name meweb.arasaka.software v4.meweb.arasaka.software v6.meweb.arasaka.software; + access_log off; location ^~ /.well-known/acme-challenge/ { root /var/www/acme; @@ -28,7 +29,7 @@ server { add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "DENY" always; - access_log /var/log/nginx/meweb.arasaka.software.access.log; + access_log off; error_log /var/log/nginx/meweb.arasaka.software.error.log; client_max_body_size 128k; diff --git a/deploy/nginx/meweb.arasaka.software.http.conf b/deploy/nginx/meweb.arasaka.software.http.conf index 6a604e0..c0a73bc 100644 --- a/deploy/nginx/meweb.arasaka.software.http.conf +++ b/deploy/nginx/meweb.arasaka.software.http.conf @@ -2,6 +2,7 @@ server { listen 80; listen [::]:80; server_name meweb.arasaka.software v4.meweb.arasaka.software v6.meweb.arasaka.software; + access_log off; location ^~ /.well-known/acme-challenge/ { root /var/www/acme; diff --git a/src/MeWeb.Android/AndroidPlatform.cs b/src/MeWeb.Android/AndroidPlatform.cs new file mode 100644 index 0000000..4d5beb8 --- /dev/null +++ b/src/MeWeb.Android/AndroidPlatform.cs @@ -0,0 +1,83 @@ +using Android.Content; +using Android.Net; +using Android.Net.Wifi; + +using MeWeb.Core.Net; + +namespace MeWeb.Android; + +public class AndroidPlatform : SystemPlatform +{ + static Context Context => global::Android.App.Application.Context; + + public override DeviceKind Device => DeviceKind.Phone; + + public override IDisposable AllowMulticast() + { + if (Context.GetSystemService(Context.WifiService) is not WifiManager wifi) return base.AllowMulticast(); + var multicast = wifi.CreateMulticastLock("meweb"); + multicast.SetReferenceCounted(false); + multicast.Acquire(); + return new Held(multicast); + } + + public override LocalSnapshot ReadNetwork() + { + var snapshot = new LocalSnapshot(); + if (Context.GetSystemService(Context.ConnectivityService) is not ConnectivityManager connectivity) return base.ReadNetwork(); + var network = connectivity.ActiveNetwork; + if (network is null) return snapshot; + var link = connectivity.GetLinkProperties(network); + var capabilities = connectivity.GetNetworkCapabilities(network); + if (link is null) return base.ReadNetwork(); + + var item = new LocalInterface + { + Name = link.InterfaceName, + Kind = capabilities switch + { + not null when capabilities.HasTransport(TransportType.Wifi) => LinkKind.Wifi, + not null when capabilities.HasTransport(TransportType.Cellular) => LinkKind.Mobile, + not null when capabilities.HasTransport(TransportType.Ethernet) => LinkKind.Cable, + _ => LinkKind.Unknown, + }, + }; + foreach (var address in link.LinkAddresses) + { + var text = Usable(address.Address?.HostAddress); + if (text is not null) item.Addresses.Add(text); + } + foreach (var route in link.Routes) + { + var gateway = route.Gateway?.HostAddress; + if (!route.IsDefaultRoute || gateway is null || gateway is "0.0.0.0" or "::") continue; + if (!item.Gateways.Contains(gateway)) item.Gateways.Add(gateway); + } + item.Gateways = item.Gateways.OrderBy(g => g.Contains(':') ? 1 : 0).ToList(); + if (item.Kind == LinkKind.Wifi) item.SpeedMbps = WifiSpeed(capabilities); + snapshot.Interfaces.Add(item); + snapshot.Dns = link.DnsServers.Select(d => d.HostAddress).Where(d => d is not null).Distinct().ToList(); + return snapshot; + } + + static string Usable(string text) + { + var address = Addresses.Parse(text?.Split('%')[0]); + if (address is null) return null; + return Addresses.Classify(address) is AddressKind.Loopback or AddressKind.LinkLocal or AddressKind.Unspecified ? null : address.ToString(); + } + + static long WifiSpeed(NetworkCapabilities capabilities) + { + if (OperatingSystem.IsAndroidVersionAtLeast(29) && capabilities?.TransportInfo is WifiInfo info) return Math.Max(0, info.LinkSpeed); + return Context.GetSystemService(Context.WifiService) is WifiManager wifi ? Math.Max(0, wifi.ConnectionInfo?.LinkSpeed ?? 0) : 0; + } + + sealed class Held(WifiManager.MulticastLock multicast) : IDisposable + { + public void Dispose() + { + if (multicast.IsHeld) multicast.Release(); + } + } +} diff --git a/src/MeWeb.Android/MainApplication.cs b/src/MeWeb.Android/MainApplication.cs index a6fed73..acc749a 100644 --- a/src/MeWeb.Android/MainApplication.cs +++ b/src/MeWeb.Android/MainApplication.cs @@ -9,9 +9,7 @@ namespace MeWeb.Android; [Application] public class MainApplication : AvaloniaAndroidApplication { - protected MainApplication(nint javaReference, JniHandleOwnership transfer) : base(javaReference, transfer) - { - } + protected MainApplication(nint javaReference, JniHandleOwnership transfer) : base(javaReference, transfer) => MeWeb.App.App.Platform = new AndroidPlatform(); protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) => base.CustomizeAppBuilder(builder).WithInterFont(); } diff --git a/src/MeWeb.App/App.axaml b/src/MeWeb.App/App.axaml index 7f97cec..973829a 100644 --- a/src/MeWeb.App/App.axaml +++ b/src/MeWeb.App/App.axaml @@ -7,6 +7,7 @@ + diff --git a/src/MeWeb.App/App.axaml.cs b/src/MeWeb.App/App.axaml.cs index e4fb375..a093398 100644 --- a/src/MeWeb.App/App.axaml.cs +++ b/src/MeWeb.App/App.axaml.cs @@ -6,6 +6,7 @@ using Avalonia.Markup.Xaml; using MeWeb.App.Services; using MeWeb.App.ViewModels; using MeWeb.App.Views; +using MeWeb.Core.Net; using Microsoft.Extensions.DependencyInjection; @@ -15,6 +16,8 @@ public partial class App : Application { public static IServiceProvider Services { get; private set; } + public static IPlatform Platform { get; set; } = new SystemPlatform(); + public override void Initialize() { AvaloniaXamlLoader.Load(this); @@ -25,7 +28,7 @@ public partial class App : Application public override void OnFrameworkInitializationCompleted() { - Services = new ServiceCollection().AddMeWebApp().BuildServiceProvider(); + Services = new ServiceCollection().AddMeWebApp(Platform).BuildServiceProvider(); var stored = AppSettings.Read(); Localizer.Instance.SetLanguage(Languages.Find(stored.Language) ?? Languages.ForCulture(System.Globalization.CultureInfo.CurrentUICulture)); diff --git a/src/MeWeb.App/AppServices.cs b/src/MeWeb.App/AppServices.cs index 6298194..567e5c2 100644 --- a/src/MeWeb.App/AppServices.cs +++ b/src/MeWeb.App/AppServices.cs @@ -1,5 +1,8 @@ -using MeWeb.App.Services; using MeWeb.App.ViewModels; +using MeWeb.Core.Checks; +using MeWeb.Core.History; +using MeWeb.Core.Knowledge; +using MeWeb.Core.Net; using MeWeb.Shared; using Microsoft.Extensions.DependencyInjection; @@ -8,14 +11,24 @@ namespace MeWeb.App; public static class AppServices { - public static IServiceCollection AddMeWebApp(this IServiceCollection services) + public const string Secure = "secure"; + public const string Plain = "plain"; + + public static IServiceCollection AddMeWebApp(this IServiceCollection services, IPlatform platform) { - services.AddHttpClient(ServerClient.Name, http => + var agent = $"me-web/{BuildInfo.Version}"; + services.AddHttpClient(Secure, http => http.DefaultRequestHeaders.UserAgent.ParseAdd(agent)); + services.AddHttpClient(Plain, http => http.DefaultRequestHeaders.UserAgent.ParseAdd(agent)) + .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { AllowAutoRedirect = false }); + services.AddSingleton(platform); + services.AddSingleton(sp => { - http.BaseAddress = new Uri(Endpoints.Base); - http.DefaultRequestHeaders.UserAgent.ParseAdd($"me-web/{BuildInfo.Version}"); + var factory = sp.GetRequiredService(); + return new ServerProbe(factory.CreateClient(Secure), factory.CreateClient(Plain)); }); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(new HistoryStore(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "MeWeb"))); + services.AddSingleton(); services.AddTransient(); return services; } diff --git a/src/MeWeb.App/MeWeb.App.csproj b/src/MeWeb.App/MeWeb.App.csproj index f8f1918..a1651cc 100644 --- a/src/MeWeb.App/MeWeb.App.csproj +++ b/src/MeWeb.App/MeWeb.App.csproj @@ -27,5 +27,6 @@ + diff --git a/src/MeWeb.App/Resources/Colors.axaml b/src/MeWeb.App/Resources/Colors.axaml index 699413e..02f2552 100644 --- a/src/MeWeb.App/Resources/Colors.axaml +++ b/src/MeWeb.App/Resources/Colors.axaml @@ -6,14 +6,21 @@ #1B141C #2C222D #352A36 - #3A2E3B + #4A3C4B #F0EAEF - #A79AA6 + #B3A6B2 #6FCF97 #F2C14E - #E85D6C + #F06B79 + #2E6FCF97 + #2EF2C14E + #33F06B79 + #33B3A6B2 + #E6140F15 + + @@ -23,4 +30,9 @@ + + + + + diff --git a/src/MeWeb.App/Resources/Icons.axaml b/src/MeWeb.App/Resources/Icons.axaml new file mode 100644 index 0000000..7ebcb09 --- /dev/null +++ b/src/MeWeb.App/Resources/Icons.axaml @@ -0,0 +1,29 @@ + + M 18 5 a 2 2 0 0 1 2 2 v 8.526 a 2 2 0 0 0 .212 .897 l 1.068 2.127 a 1 1 0 0 1 -.9 1.45 H 3.62 a 1 1 0 0 1 -.9 -1.45 l 1.068 -2.127 A 2 2 0 0 0 4 15.526 V 7 a 2 2 0 0 1 2 -2 z M 20.054 15.987 H 3.946 + M 7 2 H 17 A 2 2 0 0 1 19 4 V 20 A 2 2 0 0 1 17 22 H 7 A 2 2 0 0 1 5 20 V 4 A 2 2 0 0 1 7 2 Z M 12 18 h .01 + M 12 20 h .01 M 2 8.82 a 15 15 0 0 1 20 0 M 5 12.859 a 10 10 0 0 1 14 0 M 8.5 16.429 a 5 5 0 0 1 7 0 + M 17 19 a 1 1 0 0 1 -1 -1 v -2 a 2 2 0 0 1 2 -2 h 2 a 2 2 0 0 1 2 2 v 2 a 1 1 0 0 1 -1 1 z M 17 21 v -2 M 19 14 V 6.5 a 1 1 0 0 0 -7 0 v 11 a 1 1 0 0 1 -7 0 V 10 M 21 21 v -2 M 3 5 V 3 M 4 10 a 2 2 0 0 1 -2 -2 V 6 a 1 1 0 0 1 1 -1 h 4 a 1 1 0 0 1 1 1 v 2 a 2 2 0 0 1 -2 2 z M 7 5 V 3 + M 4 14 H 20 A 2 2 0 0 1 22 16 V 20 A 2 2 0 0 1 20 22 H 4 A 2 2 0 0 1 2 20 V 16 A 2 2 0 0 1 4 14 Z M 6.01 18 H 6 M 10.01 18 H 10 M 15 10 v 4 M 17.84 7.17 a 4 4 0 0 0 -5.66 0 M 20.66 4.34 a 8 8 0 0 0 -11.31 0 + M 10 12 h 4 M 10 8 h 4 M 14 21 v -3 a 2 2 0 0 0 -4 0 v 3 M 6 10 H 4 a 2 2 0 0 0 -2 2 v 7 a 2 2 0 0 0 2 2 h 16 a 2 2 0 0 0 2 -2 V 9 a 2 2 0 0 0 -2 -2 h -2 M 6 21 V 5 a 2 2 0 0 1 2 -2 h 8 a 2 2 0 0 1 2 2 v 16 + M 2 12 A 10 10 0 1 0 22 12 A 10 10 0 1 0 2 12 Z M 12 2 a 14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0 -20 M 2 12 h 20 + M 17 16 H 21 A 1 1 0 0 1 22 17 V 21 A 1 1 0 0 1 21 22 H 17 A 1 1 0 0 1 16 21 V 17 A 1 1 0 0 1 17 16 Z M 3 16 H 7 A 1 1 0 0 1 8 17 V 21 A 1 1 0 0 1 7 22 H 3 A 1 1 0 0 1 2 21 V 17 A 1 1 0 0 1 3 16 Z M 10 2 H 14 A 1 1 0 0 1 15 3 V 7 A 1 1 0 0 1 14 8 H 10 A 1 1 0 0 1 9 7 V 3 A 1 1 0 0 1 10 2 Z M 5 16 v -3 a 1 1 0 0 1 1 -1 h 12 a 1 1 0 0 1 1 1 v 3 M 12 12 V 8 + M 4.9 16.1 C 1 12.2 1 5.8 4.9 1.9 M 7.8 4.7 a 6.14 6.14 0 0 0 -.8 7.5 M 10 9 A 2 2 0 1 0 14 9 A 2 2 0 1 0 10 9 Z M 16.2 4.8 c 2 2 2.26 5.11 .8 7.47 M 19.1 1.9 a 9.96 9.96 0 0 1 0 14.1 M 9.5 18 h 5 m 8 22 4 -11 4 11 + M 3 12 a 9 9 0 1 0 9 -9 9.75 9.75 0 0 0 -6.74 2.74 L 3 8 M 3 3 v 5 h 5 M 12 7 v 5 l 4 2 + M 2 12 A 10 10 0 1 0 22 12 A 10 10 0 1 0 2 12 Z M 12 16 v -4 M 12 8 h .01 + M 2 12 A 10 10 0 1 0 22 12 A 10 10 0 1 0 2 12 Z m 16 9 -5.5 5.5 L 8 12 + m 21.73 18 -8 -14 a 2 2 0 0 0 -3.48 0 l -8 14 A 2 2 0 0 0 4 21 h 16 a 2 2 0 0 0 1.73 -3 M 12 9 v 4 M 12 17 h .01 + M 2 12 A 10 10 0 1 0 22 12 A 10 10 0 1 0 2 12 Z m 15 9 -6 6 m 9 9 6 6 + M 2 12 A 10 10 0 1 0 22 12 A 10 10 0 1 0 2 12 Z M 9.09 9 a 3 3 0 0 1 5.83 1 c 0 2 -3 3 -3 3 M 12 17 h .01 + M 3 12 a 9 9 0 0 1 9 -9 9.75 9.75 0 0 1 6.74 2.74 L 21 8 M 21 3 v 5 h -5 M 21 12 a 9 9 0 0 1 -9 9 9.75 9.75 0 0 1 -6.74 -2.74 L 3 16 M 8 16 H 3 v 5 + m 5 8 6 6 m 4 14 6 -6 2 -3 M 2 5 h 12 M 7 2 h 1 m 22 22 -5 -10 -5 10 M 14 18 h 6 + M 12 22 v -5 M 15 8 V 2 M 17 8 a 1 1 0 0 1 1 1 v 4 a 4 4 0 0 1 -4 4 h -4 a 4 4 0 0 1 -4 -4 V 9 a 1 1 0 0 1 1 -1 z M 9 8 V 2 + M 12.83 2.18 a 2 2 0 0 0 -1.66 0 L 2.6 6.08 a 1 1 0 0 0 0 1.83 l 8.58 3.91 a 2 2 0 0 0 1.66 0 l 8.58 -3.9 a 1 1 0 0 0 0 -1.83 z M 2 12 a 1 1 0 0 0 .58 .91 l 8.6 3.91 a 2 2 0 0 0 1.65 0 l 8.58 -3.9 A 1 1 0 0 0 22 12 M 2 17 a 1 1 0 0 0 .58 .91 l 8.6 3.91 a 2 2 0 0 0 1.65 0 l 8.58 -3.9 A 1 1 0 0 0 22 17 + M 20 10 c 0 4.993 -5.539 10.193 -7.399 11.799 a 1 1 0 0 1 -1.202 0 C 9.539 20.193 4 14.993 4 10 a 8 8 0 0 1 16 0 M 9 10 A 3 3 0 1 0 15 10 A 3 3 0 1 0 9 10 Z + m 6 9 6 6 6 -6 + M 4 2 H 20 A 2 2 0 0 1 22 4 V 8 A 2 2 0 0 1 20 10 H 4 A 2 2 0 0 1 2 8 V 4 A 2 2 0 0 1 4 2 Z M 4 14 H 20 A 2 2 0 0 1 22 16 V 20 A 2 2 0 0 1 20 22 H 4 A 2 2 0 0 1 2 20 V 16 A 2 2 0 0 1 4 14 Z M 6 6 L 6.01 6 M 6 18 L 6.01 18 + M 20 13 c 0 5 -3.5 7.5 -7.66 8.95 a 1 1 0 0 1 -.67 -.01 C 7.5 20.5 4 18 4 13 V 6 a 1 1 0 0 1 1 -1 c 2 0 4.5 -1.2 6.24 -2.72 a 1.17 1.17 0 0 1 1.52 0 C 14.51 3.81 17 5 19 5 a 1 1 0 0 1 1 1 z m 9 12 2 2 4 -4 + M 3 19 A 3 3 0 1 0 9 19 A 3 3 0 1 0 3 19 Z M 9 19 h 8.5 a 3.5 3.5 0 0 0 0 -7 h -11 a 3.5 3.5 0 0 1 0 -7 H 15 M 15 5 A 3 3 0 1 0 21 5 A 3 3 0 1 0 15 5 Z + M 7 7 h 10 v 10 M 7 17 17 7 + M 12 15 V 3 M 21 15 v 4 a 2 2 0 0 1 -2 2 H 5 a 2 2 0 0 1 -2 -2 v -4 m 7 10 5 5 5 -5 + diff --git a/src/MeWeb.App/Resources/Styles.axaml b/src/MeWeb.App/Resources/Styles.axaml index 31cbb7e..cfa0ba7 100644 --- a/src/MeWeb.App/Resources/Styles.axaml +++ b/src/MeWeb.App/Resources/Styles.axaml @@ -1,12 +1,12 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MeWeb.App/Services/Languages.cs b/src/MeWeb.App/Services/Languages.cs index 5f9c672..183f682 100644 --- a/src/MeWeb.App/Services/Languages.cs +++ b/src/MeWeb.App/Services/Languages.cs @@ -30,7 +30,7 @@ public static class Languages var found = Find(current.Name); if (found is not null) return found; } - return Fallback; + return Find(Fallback.Code) ?? Fallback; } static List Load() diff --git a/src/MeWeb.App/Services/Localizer.cs b/src/MeWeb.App/Services/Localizer.cs index 3141a61..5dd2ebb 100644 --- a/src/MeWeb.App/Services/Localizer.cs +++ b/src/MeWeb.App/Services/Localizer.cs @@ -27,7 +27,17 @@ public sealed class Localizer : INotifyPropertyChanged public string this[string key] => current.GetValueOrDefault(key) ?? english.GetValueOrDefault(key) ?? key; - public string Format(string key, params object[] args) => string.Format(Culture, this[key], args); + public string Format(string key, params object[] args) + { + try + { + return string.Format(Culture, this[key], args); + } + catch (FormatException) + { + return this[key]; + } + } public void SetLanguage(Language language) { diff --git a/src/MeWeb.App/Services/ServerClient.cs b/src/MeWeb.App/Services/ServerClient.cs deleted file mode 100644 index 247eb1b..0000000 --- a/src/MeWeb.App/Services/ServerClient.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Net.Http.Json; - -using MeWeb.Shared; - -namespace MeWeb.App.Services; - -public enum Reachability -{ - Unknown, - Online, - Offline, -} - -public class ServerClient(IHttpClientFactory factory) -{ - public const string Name = "server"; - - public async Task<(Reachability State, ViewInfo Info)> Probe(CancellationToken ct) - { - try - { - var http = factory.CreateClient(Name); - using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); - timeout.CancelAfter(TimeSpan.FromSeconds(6)); - var info = await http.GetFromJsonAsync(Endpoints.Server.GET_Info, MeWebJsonContext.Default.ViewInfo, timeout.Token); - return (Reachability.Online, info); - } - catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !ct.IsCancellationRequested) - { - return (Reachability.Offline, null); - } - } -} diff --git a/src/MeWeb.App/Services/Texts.cs b/src/MeWeb.App/Services/Texts.cs new file mode 100644 index 0000000..be3e754 --- /dev/null +++ b/src/MeWeb.App/Services/Texts.cs @@ -0,0 +1,52 @@ +using System.Globalization; + +using MeWeb.Core.Checks; + +namespace MeWeb.App.Services; + +public static class Texts +{ + public static string Summary(Finding finding) => finding?.Summary is null ? "" : Localizer.Instance.Format(finding.Summary, finding.Args.Select(Argument).ToArray()); + + public static string Value(Fact fact) + { + if (fact.Text is not null) return Isolate(fact.Text); + if (fact.Key is null) return fact.Date is null ? "" : Date(fact.Date); + var args = fact.Amount is null ? fact.Args.Select(Argument).ToArray() : new object[] { Money(fact.Amount.Value, fact.Currency) }; + return Localizer.Instance.Format(fact.Key, args); + } + + public static string Source(Fact fact) + { + var parts = new List(); + if (fact.Basis is not null) parts.Add(Localizer.Instance[fact.Basis]); + if (fact.Date is not null && fact.Key is not null) parts.Add(Date(fact.Date)); + return string.Join(", ", parts); + } + + public static string Money(decimal amount, string currency) + { + var format = (NumberFormatInfo)Localizer.Instance.Culture.NumberFormat.Clone(); + format.CurrencySymbol = currency switch + { + "EUR" => "€", + "USD" => "$", + "GBP" => "£", + _ => currency ?? "", + }; + return amount.ToString("C2", format); + } + + public static string Date(string text) + { + var culture = Localizer.Instance.Culture; + if (DateTime.TryParseExact(text, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var day)) return day.ToString("d", culture); + if (DateTime.TryParseExact(text, "yyyy-MM", CultureInfo.InvariantCulture, DateTimeStyles.None, out var month)) return month.ToString("Y", culture); + return text; + } + + public static string Isolate(string text) => string.IsNullOrEmpty(text) ? text : $"\u2068{text}\u2069"; + + static object Argument(string text) => + DateTime.TryParseExact(text, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var day) ? day.ToString("d", Localizer.Instance.Culture) : text; +} diff --git a/src/MeWeb.App/Strings/en.json b/src/MeWeb.App/Strings/en.json index e1ba265..a14e255 100644 --- a/src/MeWeb.App/Strings/en.json +++ b/src/MeWeb.App/Strings/en.json @@ -1,11 +1,169 @@ { + "AboutLine": "me-web? is free software (AGPL-3.0) published by Arasaka di Hvozdikov Yevheniy, Busto Arsizio, Italy.", + "AddressesBoth": "The internet sees you over both IPv4 and IPv6.", + "AddressesNone": "Your public address couldn't be read.", + "AddressesV4Only": "The internet sees you over IPv4 only: this connection has no IPv6.", + "AddressesV6Only": "The internet sees you over IPv6 only.", "AppTagline": "Find out whether you can run your own server from this connection, and what stands in the way.", + "BasisOneUser": "one user's report", + "BasisOperator": "operator", + "BasisPress": "press", + "BasisResearch": "me-web? research", + "BasisUsers": "users' reports", + "CardAddresses": "Your public addresses", + "CardConnection": "This connection", + "CardHistory": "Earlier checks", + "CardNat": "Address sharing", + "CardOnline": "Internet connection", + "CardProvider": "Your provider", + "CardRouter": "Your router", "Check": "Check", + "CheckAgain": "Check again", + "CheckFailed": "This check stopped unexpectedly ({0}).", + "Checking": "Checking…", + "ClearHistory": "Forget earlier checks", + "Close": "Close", + "ConnectionCable": "Connected by cable.", + "ConnectionMobile": "Connected by mobile data.", + "ConnectionNone": "No network adapter is connected.", + "ConnectionOther": "Connected through a network adapter.", + "ConnectionWifi": "Connected by Wi-Fi.", + "ContactDns": "Your name server (DNS): to look up meweb.arasaka.software.", + "ContactLinks": "Links you choose to open (sources, corrections, downloads) open in your browser.", + "ContactRoute": "The routers on the way to meweb.arasaka.software: a few short test messages with a limited lifetime (ping) show the first steps of the route.", + "ContactRouter": "Your router, on your own network: asked whether it lets apps open ports and which internet address it has. Nothing is changed.", + "ContactServer": "meweb.arasaka.software, run by the publisher in Italy: to see whether you are online, which public addresses the internet sees, and whether a newer version exists. The me-web? server stores nothing about your checks.", + "Contacts": "What this app contacts", + "ContactsIntro": "me-web? talks only to these, and only when it checks:", "DraftNotice": "This translation is a machine draft.", + "ExplainAddresses": "The address the rest of the internet sees when this device talks to it. With IPv4 the devices at home usually share one address, held by the router; with IPv6 each device usually has its own.", + "ExplainConnection": "How this device is connected right now, and the address your router gave it. Addresses starting with 192.168, 10, or 172.16 to 172.31 only work inside your home (private addresses).", + "ExplainHistory": "Each check is saved on this device only. If your public address stays the same for weeks it is probably fixed (static); if it changes, you need a name that follows it (dynamic DNS).", + "ExplainNat": "To host something at home, the internet has to reach your router. That works when your router holds the public address. When your provider shares one address among many customers (CGNAT), nobody can reach you from outside over IPv4 unless the provider gives you an address of your own; a tunnel or IPv6 are the ways around it.", + "ExplainOnline": "me-web? asks its own server, meweb.arasaka.software, over a secure connection and over a plain one. If the secure one fails and the plain one is sent somewhere else, a login page (captive portal) is in the way, as in hotels and on trains.", + "ExplainProvider": "Found without going online, in the provider map inside me-web?, from your public address. The notes come from the operator's own technical sheet or from users' reports, each with its source and date: they say what is usual, not what your contract says.", + "ExplainRouter": "Game consoles, calls and file sharing ask the router to open a port for them, using UPnP, NAT-PMP or PCP. me-web? only asks your router whether it answers and which internet address it has; it opens nothing.", + "FactAdapter": "Network adapter", + "FactAddressesSeen": "Public IPv4 addresses seen", + "FactChecks": "Checks", + "FactDns": "Name servers (DNS)", + "FactGateway": "Router address", + "FactHint": "Hint", + "FactIpv6": "IPv6", + "FactLinkSpeed": "Link speed", + "FactLocalAddress": "Address on your network", + "FactLocalIpv6": "IPv6 addresses of this device", + "FactNatPmp": "NAT-PMP", + "FactNetwork": "Network", + "FactNetworkIpv6": "Network for IPv6", + "FactPcp": "PCP", + "FactProviderIpv4": "IPv4 address", + "FactProviderIpv6": "IPv6", + "FactProviderPort25": "Mail port 25", + "FactProviderPublic": "An address of your own", + "FactProviderSheet": "Technical sheet", + "FactPublicV4": "Public IPv4", + "FactPublicV6": "Public IPv6", + "FactRedirectedTo": "Sent to", + "FactRoute": "First steps of the route", + "FactRouter": "Router", + "FactRouterWan": "Router's internet address", + "FactSince": "Since", + "FactUpnp": "UPnP", + "FactWayOut": "Way around it", + "FactWhoHasIpv6": "IPv6 address held by", + "FactWhoHasIt": "IPv4 address held by", + "HintSharedHop": "A router of your provider uses a shared-space address (100.64.x.x). That happens with address sharing and without it, so on its own it proves nothing.", + "HistoryChanged": "Changes of your public IPv4 address since {1}: {2} (checks: {0}).", + "HistoryCleared": "Earlier checks forgotten.", + "HistoryFirst": "This is the first check from this connection. Check again on other days to see whether your address changes.", + "HistoryStable": "Your public IPv4 address has stayed the same since {1} (checks: {0}).", + "IconsCredit": "Icons: Lucide (ISC).", "Language": "Language", + "LinkCable": "Cable", + "LinkMobile": "Mobile data", + "LinkOther": "Network", + "LinkWifi": "Wi-Fi", + "MapCredit": "Provider map: iptoasn.com (PDDL), data of {0}.", + "MapMissing": "This copy has no provider map.", + "NatDirect": "This device holds your public IPv4 address itself.", + "NatDouble": "Two routers in a row: your router sits behind another one (double NAT).", + "NatHomeRouter": "One router, and it holds your public IPv4 address.", + "NatNoIpv4": "This connection has no IPv4.", + "NatShared": "Your provider shares one public IPv4 address among many customers (CGNAT).", + "NatTranslated": "Your address is translated again after your router: the address your router has isn't the one the internet sees.", + "NatUnknown": "Can't tell from here whether your public IPv4 address is yours alone: your router didn't say which address it has.", + "OnlineCaptive": "A login page is in the way: open your browser and sign in to this network.", + "OnlineGood": "This device reaches the internet.", + "OnlineNoNames": "The network is up, but names such as meweb.arasaka.software can't be looked up (DNS).", + "OnlineNoNetwork": "This device isn't connected to any network.", + "OnlineOffline": "This device is on a network, but doesn't reach the internet.", + "OnlineRewritten": "The internet answers, but something on the way changes plain web pages.", + "OutcomeBad": "In the way", + "OutcomeCaution": "Needs attention", + "OutcomeGood": "Good", + "OutcomeInfo": "Information", + "OutcomeRunning": "Checking", + "OutcomeUnknown": "Can't tell", + "OutcomeWaiting": "Waiting", + "PathAnotherRouter": "Another router", + "PathInternet": "Internet", + "PathMobileNetwork": "Mobile network", + "PathProvider": "Your provider", + "PathRouter": "Your router", + "PathSharedGateway": "Your provider's shared exit", + "PathThisComputer": "This computer", + "PathThisPhone": "This phone", + "PathTitle": "From this device to the internet", + "PathTranslation": "Another address translation", + "PrivacyLine": "No telemetry, no analytics, no crash reports. Your checks are kept on this device only.", + "ProviderIpv4PublicDynamic": "usually public, and it can change", + "ProviderIpv4Shared": "usually shared with other customers (CGNAT)", + "ProviderIpv4SharedPorts": "usually shared, each customer getting a slice of the ports", + "ProviderIpv4SharedUntilForward": "shared until you open a port on the provider's router, then yours alone", + "ProviderIpv6Native": "offered", + "ProviderIpv6None": "not offered", + "ProviderIpv6Tunnel": "through a tunnel (6rd)", + "ProviderListed": "Your provider is {0}.", + "ProviderNoAddress": "Without a public address the provider can't be found.", + "ProviderNoMap": "This copy of me-web? has no provider map.", + "ProviderNotFound": "The provider map doesn't know this address.", + "ProviderPort25Blocked": "blocked", + "ProviderPort25BlockedOnRequest": "blocked, opened if you ask", + "ProviderPort25Closed": "closed", + "ProviderPort25Open": "open", + "ProviderPublicBusinessOnly": "business lines only", + "ProviderPublicFreeOnRequest": "free if you ask for it", + "ProviderPublicNone": "not offered to homes", + "ProviderPublicNotFound": "no offer for homes was found", + "ProviderPublicPaid": "a static one from about {0} a month", + "ProviderUnlisted": "Your provider is {0}. me-web? has no notes on it yet.", + "ResultsTitle": "What me-web? found", + "RouterMobile": "On mobile data there is no home router: your provider's network comes first.", + "RouterNone": "No home router was found on this connection.", + "RouterOpens": "Your router answers apps that ask it to open ports.", + "RouterRefuses": "Your router answers, but refuses apps that ask it to open ports.", + "RouterSilent": "Your router doesn't answer apps that ask it to open ports.", + "SourceCode": "Source code", + "StatusCaptive": "A login page is in the way", "StatusChecking": "Checking the connection…", "StatusOffline": "Not connected to the internet", "StatusOnline": "Connected to the internet", "SuggestCorrection": "Suggest a correction", - "VersionLine": "me-web? {0} · free software (AGPL-3.0)" + "UpdateAvailable": "me-web? {0} is available.", + "UpdateOpen": "Download", + "ValueAnswered": "answered", + "ValueCount": "{0}", + "ValueIpv6Direct": "each device has its own public address", + "ValueIpv6NotThisDevice": "something other than this device", + "ValueIpv6Translated": "translated on the way", + "ValueIpv6Way": "Over IPv6 a device at home can be reached directly, if your router's firewall lets it.", + "ValueMbps": "{0} Mbit/s", + "ValueOnThisDevice": "this device", + "ValueOpenSheet": "Open the operator's sheet", + "ValueRefused": "refused (code {0})", + "ValueSharedAtHome": "your router, shared by every device at home", + "ValueSilent": "no answer", + "VersionLine": "me-web? {0} · free software (AGPL-3.0)", + "WhatIsThis": "What does this mean?" } diff --git a/src/MeWeb.App/Strings/it.json b/src/MeWeb.App/Strings/it.json index a0307d8..8bc5c3a 100644 --- a/src/MeWeb.App/Strings/it.json +++ b/src/MeWeb.App/Strings/it.json @@ -1,11 +1,169 @@ { + "AboutLine": "me-web? è software libero (AGPL-3.0) pubblicato da Arasaka di Hvozdikov Yevheniy, Busto Arsizio.", + "AddressesBoth": "Internet la vede sia in IPv4 sia in IPv6.", + "AddressesNone": "Non è stato possibile leggere il suo indirizzo pubblico.", + "AddressesV4Only": "Internet la vede solo in IPv4: questa connessione non ha IPv6.", + "AddressesV6Only": "Internet la vede solo in IPv6.", "AppTagline": "Scopra se da questa connessione può ospitare un server suo, e che cosa lo impedisce.", + "BasisOneUser": "segnalazione di un utente", + "BasisOperator": "operatore", + "BasisPress": "stampa", + "BasisResearch": "ricerca di me-web?", + "BasisUsers": "segnalazioni degli utenti", + "CardAddresses": "I suoi indirizzi pubblici", + "CardConnection": "Questa connessione", + "CardHistory": "Verifiche precedenti", + "CardNat": "Condivisione dell'indirizzo", + "CardOnline": "Connessione a internet", + "CardProvider": "Il suo operatore", + "CardRouter": "Il suo router", "Check": "Verifica", + "CheckAgain": "Verifica di nuovo", + "CheckFailed": "Questa verifica si è interrotta in modo inatteso ({0}).", + "Checking": "Verifica in corso…", + "ClearHistory": "Dimentica le verifiche precedenti", + "Close": "Chiudi", + "ConnectionCable": "Collegato via cavo.", + "ConnectionMobile": "Collegato con i dati mobili.", + "ConnectionNone": "Nessuna scheda di rete è collegata.", + "ConnectionOther": "Collegato tramite una scheda di rete.", + "ConnectionWifi": "Collegato via Wi-Fi.", + "ContactDns": "Il suo server dei nomi (DNS): per trovare meweb.arasaka.software.", + "ContactLinks": "I collegamenti che sceglie di aprire (fonti, correzioni, download) si aprono nel suo browser.", + "ContactRoute": "I router lungo la strada verso meweb.arasaka.software: alcuni brevi messaggi di prova a durata limitata (ping) mostrano i primi passi del percorso.", + "ContactRouter": "Il suo router, nella sua rete: gli si chiede se lascia aprire porte alle app e quale indirizzo internet ha. Non si cambia nulla.", + "ContactServer": "meweb.arasaka.software, gestito dall'editore in Italia: per sapere se è in linea, quali indirizzi pubblici vede internet e se esiste una versione più recente. Il server di me-web? non conserva nulla delle sue verifiche.", + "Contacts": "Con chi parla questa app", + "ContactsIntro": "me-web? parla solo con questi, e solo quando fa una verifica:", "DraftNotice": "Questa traduzione è una bozza automatica.", + "ExplainAddresses": "L'indirizzo che il resto di internet vede quando questo dispositivo gli parla. In IPv4 i dispositivi di casa di solito condividono un indirizzo, che ha il router; in IPv6 di solito ogni dispositivo ha il suo.", + "ExplainConnection": "Come è collegato in questo momento il dispositivo, e l'indirizzo che gli ha dato il router. Gli indirizzi che iniziano con 192.168, 10 o da 172.16 a 172.31 valgono solo dentro casa (indirizzi privati).", + "ExplainHistory": "Ogni verifica resta salvata solo su questo dispositivo. Se l'indirizzo pubblico rimane uguale per settimane, probabilmente è fisso (statico); se cambia, serve un nome che lo segua (DNS dinamico).", + "ExplainNat": "Per ospitare qualcosa a casa, internet deve poter raggiungere il suo router. Funziona quando l'indirizzo pubblico ce l'ha il router. Quando l'operatore condivide un indirizzo tra molti clienti (CGNAT), da fuori nessuno la raggiunge in IPv4, a meno che l'operatore non le dia un indirizzo tutto suo; le alternative sono un tunnel o IPv6.", + "ExplainOnline": "me-web? interroga il proprio server, meweb.arasaka.software, con una connessione protetta e con una non protetta. Se quella protetta non va e quella non protetta viene mandata altrove, c'è di mezzo una pagina di accesso (captive portal), come negli alberghi e sui treni.", + "ExplainProvider": "Trovato senza andare in rete, nella mappa degli operatori contenuta in me-web?, a partire dal suo indirizzo pubblico. Le note vengono dalla scheda tecnica dell'operatore o da segnalazioni degli utenti, ciascuna con fonte e data: dicono che cosa è normale, non che cosa dice il suo contratto.", + "ExplainRouter": "Console, chiamate e condivisione di file chiedono al router di aprire una porta, con UPnP, NAT-PMP o PCP. me-web? chiede al router solo se risponde e quale indirizzo internet ha; non apre nulla.", + "FactAdapter": "Scheda di rete", + "FactAddressesSeen": "Indirizzi IPv4 pubblici visti", + "FactChecks": "Verifiche", + "FactDns": "Server dei nomi (DNS)", + "FactGateway": "Indirizzo del router", + "FactHint": "Indizio", + "FactIpv6": "IPv6", + "FactLinkSpeed": "Velocità del collegamento", + "FactLocalAddress": "Indirizzo nella sua rete", + "FactLocalIpv6": "Indirizzi IPv6 di questo dispositivo", + "FactNatPmp": "NAT-PMP", + "FactNetwork": "Rete", + "FactNetworkIpv6": "Rete per IPv6", + "FactPcp": "PCP", + "FactProviderIpv4": "Indirizzo IPv4", + "FactProviderIpv6": "IPv6", + "FactProviderPort25": "Porta 25 della posta", + "FactProviderPublic": "Un indirizzo tutto suo", + "FactProviderSheet": "Scheda tecnica", + "FactPublicV4": "IPv4 pubblico", + "FactPublicV6": "IPv6 pubblico", + "FactRedirectedTo": "Reindirizzato a", + "FactRoute": "Primi passi del percorso", + "FactRouter": "Router", + "FactRouterWan": "Indirizzo internet del router", + "FactSince": "Dal", + "FactUpnp": "UPnP", + "FactWayOut": "Alternativa", + "FactWhoHasIpv6": "Indirizzo IPv6 assegnato a", + "FactWhoHasIt": "Indirizzo IPv4 assegnato a", + "HintSharedHop": "Un router dell'operatore usa un indirizzo dello spazio condiviso (100.64.x.x). Succede sia con la condivisione degli indirizzi sia senza, quindi da solo non prova nulla.", + "HistoryChanged": "Cambi del suo indirizzo IPv4 pubblico dal {1}: {2} (verifiche: {0}).", + "HistoryCleared": "Verifiche precedenti dimenticate.", + "HistoryFirst": "È la prima verifica da questa connessione. Verifichi di nuovo in altri giorni per vedere se l'indirizzo cambia.", + "HistoryStable": "Il suo indirizzo IPv4 pubblico è rimasto lo stesso dal {1} (verifiche: {0}).", + "IconsCredit": "Icone: Lucide (ISC).", "Language": "Lingua", + "LinkCable": "Cavo", + "LinkMobile": "Dati mobili", + "LinkOther": "Rete", + "LinkWifi": "Wi-Fi", + "MapCredit": "Mappa degli operatori: iptoasn.com (PDDL), dati del {0}.", + "MapMissing": "Questa copia non contiene la mappa degli operatori.", + "NatDirect": "L'indirizzo IPv4 pubblico è assegnato direttamente a questo dispositivo.", + "NatDouble": "Due router in fila: il suo router sta dietro a un altro (doppio NAT).", + "NatHomeRouter": "Un solo router, ed è lui ad avere il suo indirizzo IPv4 pubblico.", + "NatNoIpv4": "Questa connessione non ha IPv4.", + "NatShared": "Il suo operatore condivide un solo indirizzo IPv4 pubblico tra molti clienti (CGNAT).", + "NatTranslated": "Il suo indirizzo viene tradotto di nuovo dopo il router: l'indirizzo del router non è quello che vede internet.", + "NatUnknown": "Da qui non si può dire se l'indirizzo IPv4 pubblico è solo suo: il router non ha detto quale indirizzo ha.", + "OnlineCaptive": "Una pagina di accesso blocca la connessione: apra il browser ed entri in questa rete.", + "OnlineGood": "Questo dispositivo raggiunge internet.", + "OnlineNoNames": "La rete funziona, ma i nomi come meweb.arasaka.software non si risolvono (DNS).", + "OnlineNoNetwork": "Questo dispositivo non è collegato a nessuna rete.", + "OnlineOffline": "Questo dispositivo è in rete, ma non raggiunge internet.", + "OnlineRewritten": "Internet risponde, ma qualcosa lungo la strada modifica le pagine web non protette.", + "OutcomeBad": "Ostacolo", + "OutcomeCaution": "Attenzione", + "OutcomeGood": "Bene", + "OutcomeInfo": "Informazione", + "OutcomeRunning": "In verifica", + "OutcomeUnknown": "Non determinabile", + "OutcomeWaiting": "In attesa", + "PathAnotherRouter": "Un altro router", + "PathInternet": "Internet", + "PathMobileNetwork": "Rete mobile", + "PathProvider": "Il suo operatore", + "PathRouter": "Il suo router", + "PathSharedGateway": "Uscita condivisa dell'operatore", + "PathThisComputer": "Questo computer", + "PathThisPhone": "Questo telefono", + "PathTitle": "Da questo dispositivo a internet", + "PathTranslation": "Un'altra traduzione degli indirizzi", + "PrivacyLine": "Niente telemetria, niente statistiche, niente rapporti sugli arresti anomali. Le sue verifiche restano solo su questo dispositivo.", + "ProviderIpv4PublicDynamic": "di solito pubblico, e può cambiare", + "ProviderIpv4Shared": "di solito condiviso con altri clienti (CGNAT)", + "ProviderIpv4SharedPorts": "di solito condiviso, con una parte delle porte per ciascun cliente", + "ProviderIpv4SharedUntilForward": "condiviso finché non apre una porta sul router dell'operatore, poi solo suo", + "ProviderIpv6Native": "offerto", + "ProviderIpv6None": "non offerto", + "ProviderIpv6Tunnel": "tramite un tunnel (6rd)", + "ProviderListed": "Il suo operatore è {0}.", + "ProviderNoAddress": "Senza un indirizzo pubblico non si può risalire all'operatore.", + "ProviderNoMap": "Questa copia di me-web? non contiene la mappa degli operatori.", + "ProviderNotFound": "La mappa degli operatori non conosce questo indirizzo.", + "ProviderPort25Blocked": "bloccata", + "ProviderPort25BlockedOnRequest": "bloccata, aperta su richiesta", + "ProviderPort25Closed": "chiusa", + "ProviderPort25Open": "aperta", + "ProviderPublicBusinessOnly": "solo per le linee aziendali", + "ProviderPublicFreeOnRequest": "gratis se lo chiede", + "ProviderPublicNone": "non offerto ai privati", + "ProviderPublicNotFound": "non è stata trovata un'offerta per la casa", + "ProviderPublicPaid": "statico, da circa {0} al mese", + "ProviderUnlisted": "Il suo operatore è {0}. me-web? non ha ancora note su questo operatore.", + "ResultsTitle": "Che cosa ha trovato me-web?", + "RouterMobile": "Con i dati mobili non c'è un router di casa: si passa subito dalla rete dell'operatore.", + "RouterNone": "Su questa connessione non è stato trovato un router di casa.", + "RouterOpens": "Il suo router risponde alle app che gli chiedono di aprire porte.", + "RouterRefuses": "Il suo router risponde, ma rifiuta le app che gli chiedono di aprire porte.", + "RouterSilent": "Il suo router non risponde alle app che gli chiedono di aprire porte.", + "SourceCode": "Codice sorgente", + "StatusCaptive": "Una pagina di accesso blocca la connessione", "StatusChecking": "Verifico la connessione…", "StatusOffline": "Non connesso a internet", "StatusOnline": "Connesso a internet", "SuggestCorrection": "Proponi una correzione", - "VersionLine": "me-web? {0} · software libero (AGPL-3.0)" + "UpdateAvailable": "È disponibile me-web? {0}.", + "UpdateOpen": "Scarica", + "ValueAnswered": "ha risposto", + "ValueCount": "{0}", + "ValueIpv6Direct": "ogni dispositivo ha il suo indirizzo pubblico", + "ValueIpv6NotThisDevice": "qualcosa di diverso da questo dispositivo", + "ValueIpv6Translated": "tradotto lungo la strada", + "ValueIpv6Way": "In IPv6 un dispositivo di casa si può raggiungere direttamente, se il firewall del router lo permette.", + "ValueMbps": "{0} Mbit/s", + "ValueOnThisDevice": "questo dispositivo", + "ValueOpenSheet": "Apri la scheda dell'operatore", + "ValueRefused": "ha rifiutato (codice {0})", + "ValueSharedAtHome": "il router, condiviso da tutti i dispositivi di casa", + "ValueSilent": "nessuna risposta", + "VersionLine": "me-web? {0} · software libero (AGPL-3.0)", + "WhatIsThis": "Che cosa significa?" } diff --git a/src/MeWeb.App/ViewModels/CardViewModel.cs b/src/MeWeb.App/ViewModels/CardViewModel.cs new file mode 100644 index 0000000..27d3418 --- /dev/null +++ b/src/MeWeb.App/ViewModels/CardViewModel.cs @@ -0,0 +1,70 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +using MeWeb.App.Services; +using MeWeb.Core.Checks; + +namespace MeWeb.App.ViewModels; + +public partial class FactViewModel(Fact fact, CheckId card, Action open) : ObservableObject +{ + public string AutomationId => $"fact-{card.ToString().ToLowerInvariant()}-{fact.Label}"; + public string Label => Localizer.Instance[fact.Label]; + public string Value => Texts.Value(fact); + public string Source => Texts.Source(fact); + public bool ShowLink => fact.Link is not null; + public bool ShowSource => ShowLink && Source.Length > 0; + public bool ShowPlainLink => ShowLink && Source.Length == 0; + public bool ShowValue => !ShowPlainLink; + + [RelayCommand] + void Open() => open(fact.Link); +} + +public partial class CardViewModel(CheckId id, Action open) : ObservableObject +{ + public CheckId Id { get; } = id; + + public string AutomationId => $"card-{Id.ToString().ToLowerInvariant()}"; + + public string ExplainAutomationId => $"explain-{Id.ToString().ToLowerInvariant()}"; + + public Geometry Icon => Application.Current?.FindResource(Id switch + { + CheckId.Online => "IconGlobe", + CheckId.Connection => "IconNetwork", + CheckId.Addresses => "IconMapPin", + CheckId.Provider => "IconBuilding2", + CheckId.Router => "IconRouter", + CheckId.Nat => "IconLayers", + _ => "IconHistory", + }) as Geometry; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Outcome), nameof(Summary), nameof(Facts), nameof(OutcomeFormatted), nameof(ShowRunning), nameof(ShowSettled), + nameof(IsGood), nameof(IsCaution), nameof(IsBad), nameof(IsUnknown), nameof(IsInfo))] + public partial Finding Finding { get; set; } + + [ObservableProperty] + public partial bool IsExpanded { get; set; } + + public Outcome Outcome => Finding?.Outcome ?? Outcome.Waiting; + public string Title => Localizer.Instance[$"Card{Id}"]; + public string Summary => Texts.Summary(Finding); + public string Explain => Localizer.Instance[$"Explain{Id}"]; + public string OutcomeFormatted => Localizer.Instance[$"Outcome{Outcome}"]; + public IReadOnlyList Facts => Finding?.Facts.Select(f => new FactViewModel(f, Id, open)).ToList() ?? []; + public bool ShowRunning => Outcome is Outcome.Running or Outcome.Waiting; + public bool ShowSettled => !ShowRunning; + public bool IsGood => Outcome == Outcome.Good; + public bool IsCaution => Outcome == Outcome.Caution; + public bool IsBad => Outcome == Outcome.Bad; + public bool IsUnknown => Outcome == Outcome.Unknown; + public bool IsInfo => Outcome == Outcome.Info; + + public void Refresh() => OnPropertyChanged(string.Empty); +} diff --git a/src/MeWeb.App/ViewModels/MainViewModel.cs b/src/MeWeb.App/ViewModels/MainViewModel.cs index d291b12..ee74e57 100644 --- a/src/MeWeb.App/ViewModels/MainViewModel.cs +++ b/src/MeWeb.App/ViewModels/MainViewModel.cs @@ -1,61 +1,336 @@ +using System.Collections.ObjectModel; + using Avalonia.Media; using Avalonia.Platform.Storage; +using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using MeWeb.App.Services; +using MeWeb.Core.Checks; +using MeWeb.Core.History; +using MeWeb.Core.Knowledge; +using MeWeb.Core.Net; using MeWeb.Shared; namespace MeWeb.App.ViewModels; -public partial class MainViewModel(ServerClient server) : ObservableObject +public enum Reachability +{ + Unknown, + Online, + Captive, + Offline, +} + +public partial class MainViewModel : ObservableObject { public const string CorrectionsUrl = "https://git.thepra.dev/Arasaka/MeWeb/issues"; + public const string SourceUrl = "https://git.thepra.dev/Arasaka/MeWeb"; + static readonly CheckId[] CardOrder = [CheckId.Online, CheckId.Nat, CheckId.Router, CheckId.Addresses, CheckId.Provider, CheckId.Connection, CheckId.History]; + + readonly ServerProbe server; + readonly CheckRunner runner; + readonly KnowledgeBase knowledge; + readonly HistoryStore history; + readonly IPlatform platform; + bool revealing; + bool instant; + + public MainViewModel(ServerProbe server, CheckRunner runner, KnowledgeBase knowledge, HistoryStore history, IPlatform platform) + { + this.server = server; + this.runner = runner; + this.knowledge = knowledge; + this.history = history; + this.platform = platform; + Cards = new ObservableCollection(CardOrder.Select(id => new CardViewModel(id, OpenLink))); + } public IReadOnlyList Languages { get; } = Services.Languages.All; + public ObservableCollection Cards { get; } + + public ObservableCollection Path { get; } = []; + public ILauncher Launcher { get; set; } [ObservableProperty] - [NotifyPropertyChangedFor(nameof(StatusFormatted), nameof(ShowOnline), nameof(ShowOffline), nameof(ShowChecking))] + [NotifyPropertyChangedFor(nameof(StatusFormatted), nameof(ShowOnline), nameof(ShowOffline), nameof(ShowCaptive), nameof(ShowChecking))] public partial Reachability Status { get; set; } = Reachability.Unknown; [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowDraftNotice), nameof(Flow))] public partial Language SelectedLanguage { get; set; } = Localizer.Instance.Language; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CheckFormatted))] + public partial bool HasResults { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CheckFormatted))] + public partial bool IsChecking { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(UpdateFormatted), nameof(ShowUpdate))] + public partial string LatestVersion { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(MapFormatted))] + public partial bool ShowContacts { get; set; } + + [ObservableProperty] + public partial bool HistoryCleared { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsWide), nameof(IsNarrow), nameof(CardWidth))] + public partial double AvailableWidth { get; set; } = 1100; + + public bool IsWide => AvailableWidth >= 840; + public bool IsNarrow => !IsWide; + + public double CardWidth + { + get + { + var usable = Math.Max(296, AvailableWidth - 32); + var columns = Math.Max(1, (int)(usable / 360)); + return Math.Floor(usable / columns); + } + } + public bool ShowChecking => Status == Reachability.Unknown; public bool ShowOnline => Status == Reachability.Online; public bool ShowOffline => Status == Reachability.Offline; + public bool ShowCaptive => Status == Reachability.Captive; public bool ShowDraftNotice => SelectedLanguage?.IsDraft == true; + public bool ShowUpdate => LatestVersion is not null; public FlowDirection Flow => Localizer.Instance.IsRightToLeft ? FlowDirection.RightToLeft : FlowDirection.LeftToRight; - public string StatusFormatted => Status switch + public string StatusFormatted => Localizer.Instance[Status switch { - Reachability.Online => Localizer.Instance["StatusOnline"], - Reachability.Offline => Localizer.Instance["StatusOffline"], - _ => Localizer.Instance["StatusChecking"], - }; + Reachability.Online => "StatusOnline", + Reachability.Offline => "StatusOffline", + Reachability.Captive => "StatusCaptive", + _ => "StatusChecking", + }]; + public string CheckFormatted => Localizer.Instance[IsChecking ? "Checking" : HasResults ? "CheckAgain" : "Check"]; public string VersionFormatted => Localizer.Instance.Format("VersionLine", BuildInfo.Version); + public string UpdateFormatted => LatestVersion is null ? "" : Localizer.Instance.Format("UpdateAvailable", LatestVersion); + public string MapFormatted => !ShowContacts ? "" : knowledge.HasAsnTable ? Localizer.Instance.Format("MapCredit", knowledge.Asn.SourceDate.ToString("d", Localizer.Instance.Culture)) : Localizer.Instance["MapMissing"]; [RelayCommand] - async Task Refresh(CancellationToken ct) + async Task Start(CancellationToken ct) { Status = Reachability.Unknown; - var (state, _) = await server.Probe(ct); - Status = state; + var report = await server.Online(ct); + Status = report.State switch + { + OnlineState.Online or OnlineState.Rewritten => Reachability.Online, + OnlineState.Captive => Reachability.Captive, + _ => Reachability.Offline, + }; + NoteVersion(report.Server); } [RelayCommand] - async Task OpenCorrections() + async Task Check(CancellationToken ct) { - if (Launcher is null) return; - await Launcher.LaunchUriAsync(new Uri(CorrectionsUrl)); + IsChecking = true; + HasResults = true; + HistoryCleared = false; + foreach (var card in Cards) card.Finding = Finding.Running(card.Id); + ResetPath(platform.Device); + try + { + await runner.Run((finding, run) => Dispatcher.UIThread.Post(() => Apply(finding, run)), ct); + } + finally + { + IsChecking = false; + } } + public void Replay(RunReport run) + { + HasResults = true; + instant = true; + foreach (var card in Cards) card.Finding = Finding.Running(card.Id); + ResetPath(run.Device); + foreach (var finding in run.Findings.OrderBy(f => f.Id)) Apply(finding, run); + instant = false; + } + + [RelayCommand] + void ToggleContacts() => ShowContacts = !ShowContacts; + + [RelayCommand] + void ClearHistory() + { + history.Clear(); + HistoryCleared = true; + } + + [RelayCommand] + void OpenCorrections() => OpenLink(CorrectionsUrl); + + [RelayCommand] + void OpenSource() => OpenLink(SourceUrl); + + [RelayCommand] + void OpenDownloads() => OpenLink(Endpoints.Base); + + void OpenLink(string url) + { + if (Launcher is null || !Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps) return; + _ = Launcher.LaunchUriAsync(uri); + } + + void NoteVersion(ViewInfo info) + { + if (info is null || !Version.TryParse(info.Version, out var latest) || !Version.TryParse(BuildInfo.Version, out var mine)) return; + LatestVersion = latest > mine ? info.Version : null; + } + + void Apply(Finding finding, RunReport run) + { + var card = Cards.FirstOrDefault(c => c.Id == finding.Id); + if (card is not null) card.Finding = finding; + if (finding.Outcome == Outcome.Running) return; + + switch (finding.Id) + { + case CheckId.Online: + Status = finding.Outcome switch + { + Outcome.Good => Reachability.Online, + Outcome.Caution when run.Online?.State == OnlineState.Captive => Reachability.Captive, + Outcome.Caution => Reachability.Online, + _ => Reachability.Offline, + }; + NoteVersion(run.Online?.Server); + Settle(NodeRole.Internet, finding.Outcome == Outcome.Good ? Outcome.Good : finding.Outcome); + break; + case CheckId.Connection: + var device = Node(NodeRole.Device); + if (device is not null) device.Subtitle = run.Local?.LocalV4 ?? run.Local?.GlobalV6.FirstOrDefault(); + var kind = run.Local?.Active?.Kind ?? LinkKind.Unknown; + var next = Path.Count > 1 ? Path[1] : null; + if (next is not null) next.LinkKey = kind switch + { + LinkKind.Wifi => "LinkWifi", + LinkKind.Cable => "LinkCable", + LinkKind.Mobile => "LinkMobile", + _ => "LinkOther", + }; + var router = Node(NodeRole.Router); + if (router is not null && kind == LinkKind.Mobile) + { + router.IconKey = "IconRadioTower"; + router.TitleKey = "PathMobileNetwork"; + } + Settle(NodeRole.Device, finding.Outcome == Outcome.Bad ? Outcome.Bad : Outcome.Good); + break; + case CheckId.Router: + var routerNode = Node(NodeRole.Router); + if (routerNode is not null && run.Router is not null) + { + if (run.Router.Label is not null) routerNode.TitleText = run.Router.Label; + routerNode.Subtitle = run.Router.WanAddress ?? run.Router.Gateway; + } + Settle(NodeRole.Router, finding.Outcome == Outcome.Info ? Outcome.Good : finding.Outcome); + break; + case CheckId.Nat: + var extra = run.Nat switch + { + NatKind.DoubleNat => new PathNodeViewModel(NodeRole.Extra, "IconRouter", "PathAnotherRouter") { State = Outcome.Running }, + NatKind.Shared => new PathNodeViewModel(NodeRole.Extra, "IconLayers", "PathSharedGateway") { State = Outcome.Running }, + NatKind.Translated => new PathNodeViewModel(NodeRole.Extra, "IconLayers", "PathTranslation") { State = Outcome.Running }, + _ => null, + }; + if (extra is not null) + { + var at = Path.IndexOf(Node(NodeRole.Provider)); + Path.Insert(at < 0 ? Path.Count : at, extra); + Renumber(); + Settle(NodeRole.Extra, finding.Outcome); + } + break; + case CheckId.Provider: + var providerNode = Node(NodeRole.Provider); + var network = run.NetworkV4 ?? run.NetworkV6; + if (providerNode is not null && network is not null) + { + providerNode.TitleText = run.Provider?.Name ?? network.Name; + providerNode.Subtitle = $"AS{network.Number}"; + } + Settle(NodeRole.Provider, finding.Outcome == Outcome.Info ? Outcome.Good : finding.Outcome); + break; + case CheckId.Addresses: + var internet = Node(NodeRole.Internet); + if (internet is not null) internet.Subtitle = run.PublicV4 ?? run.PublicV6; + break; + } + } + + void ResetPath(DeviceKind device) + { + Path.Clear(); + Path.Add(new PathNodeViewModel(NodeRole.Device, device == DeviceKind.Phone ? "IconSmartphone" : "IconLaptop", device == DeviceKind.Phone ? "PathThisPhone" : "PathThisComputer")); + Path.Add(new PathNodeViewModel(NodeRole.Router, "IconRouter", "PathRouter")); + Path.Add(new PathNodeViewModel(NodeRole.Provider, "IconBuilding2", "PathProvider")); + Path.Add(new PathNodeViewModel(NodeRole.Internet, "IconGlobe", "PathInternet")); + foreach (var node in Path) node.State = Outcome.Running; + pending.Clear(); + Renumber(); + } + + readonly Dictionary pending = []; + + void Settle(NodeRole role, Outcome outcome) + { + var node = Node(role); + if (node is null) return; + if (instant) + { + node.State = outcome; + return; + } + pending[node] = outcome; + _ = Reveal(); + } + + async Task Reveal() + { + if (revealing) return; + revealing = true; + try + { + while (true) + { + var next = Path.FirstOrDefault(n => !n.IsLit); + if (next is null || !pending.TryGetValue(next, out var outcome)) break; + await Task.Delay(320); + next.State = outcome; + pending.Remove(next); + } + } + finally + { + revealing = false; + } + } + + void Renumber() + { + for (var i = 0; i < Path.Count; i++) Path[i].ShowConnector = i > 0; + } + + PathNodeViewModel Node(NodeRole role) => Path.FirstOrDefault(n => n.Role == role); + partial void OnSelectedLanguageChanged(Language value) { if (value is null || value.Code == Localizer.Instance.Language.Code) return; @@ -63,8 +338,13 @@ public partial class MainViewModel(ServerClient server) : ObservableObject var settings = AppSettings.Read(); settings.Language = value.Code; AppSettings.Write(settings); + foreach (var card in Cards) card.Refresh(); + foreach (var node in Path) node.Refresh(); OnPropertyChanged(nameof(StatusFormatted)); + OnPropertyChanged(nameof(CheckFormatted)); OnPropertyChanged(nameof(VersionFormatted)); + OnPropertyChanged(nameof(UpdateFormatted)); + OnPropertyChanged(nameof(MapFormatted)); OnPropertyChanged(nameof(Flow)); } } diff --git a/src/MeWeb.App/ViewModels/PathNodeViewModel.cs b/src/MeWeb.App/ViewModels/PathNodeViewModel.cs new file mode 100644 index 0000000..c493ab5 --- /dev/null +++ b/src/MeWeb.App/ViewModels/PathNodeViewModel.cs @@ -0,0 +1,64 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +using CommunityToolkit.Mvvm.ComponentModel; + +using MeWeb.App.Services; +using MeWeb.Core.Checks; + +namespace MeWeb.App.ViewModels; + +public enum NodeRole +{ + Device, + Router, + Extra, + Provider, + Internet, +} + +public partial class PathNodeViewModel(NodeRole role, string icon, string titleKey) : ObservableObject +{ + public NodeRole Role { get; } = role; + + public string AutomationId => $"path-{Role.ToString().ToLowerInvariant()}"; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Icon))] + public partial string IconKey { get; set; } = icon; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Title))] + public partial string TitleKey { get; set; } = titleKey; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(Title))] + public partial string TitleText { get; set; } + + [ObservableProperty] + public partial string Subtitle { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(LinkLabel))] + public partial string LinkKey { get; set; } + + [ObservableProperty] + public partial bool ShowConnector { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsLit), nameof(IsRunning), nameof(IsGood), nameof(IsCaution), nameof(IsBad), nameof(IsUnknown))] + public partial Outcome State { get; set; } = Outcome.Waiting; + + public Geometry Icon => Application.Current?.FindResource(IconKey) as Geometry; + public string Title => TitleText ?? Localizer.Instance[TitleKey]; + public string LinkLabel => LinkKey is null ? "" : Localizer.Instance[LinkKey]; + public bool IsLit => State is not (Outcome.Waiting or Outcome.Running); + public bool IsRunning => State == Outcome.Running; + public bool IsGood => State is Outcome.Good or Outcome.Info; + public bool IsCaution => State == Outcome.Caution; + public bool IsBad => State == Outcome.Bad; + public bool IsUnknown => State == Outcome.Unknown; + + public void Refresh() => OnPropertyChanged(string.Empty); +} diff --git a/src/MeWeb.App/Views/MainView.axaml b/src/MeWeb.App/Views/MainView.axaml index aa43aec..5c72f86 100644 --- a/src/MeWeb.App/Views/MainView.axaml +++ b/src/MeWeb.App/Views/MainView.axaml @@ -6,45 +6,224 @@ x:DataType="vm:MainViewModel" FlowDirection="{Binding Flow}" Background="{StaticResource GroundBrush}"> - - - - - - - - - - - - - - - - + + + + + + + - - -