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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
16a1cecaae
commit
0dffbc27d6
61 files changed
+3715
-114
No files matched your search
@@ -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
|
if id=$(call "$api/releases/tags/$tag" 2>/dev/null | json 'd["id"]'); then
|
||||||
echo "release $tag exists (id $id)"
|
echo "release $tag exists (id $id)"
|
||||||
else
|
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"]')
|
id=$(call -X POST -H 'Content-Type: application/json' -d "$body" "$api/releases" | json 'd["id"]')
|
||||||
echo "created release $tag (id $id)"
|
echo "created release $tag (id $id)"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -19,3 +19,4 @@ appsettings.*.local.json
|
|||||||
/tools/cache/
|
/tools/cache/
|
||||||
/tools/logs/
|
/tools/logs/
|
||||||
/.claude/settings.local.json
|
/.claude/settings.local.json
|
||||||
|
/artifacts/
|
||||||
@@ -15,6 +15,9 @@ dotnet build src/MeWeb.Android/MeWeb.Android.csproj \
|
|||||||
dotnet run --project tools/Screenshots -- dist/screenshots [en,it,...]
|
dotnet run --project tools/Screenshots -- dist/screenshots [en,it,...]
|
||||||
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
|
||||||
|
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 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 \
|
dotnet publish src/MeWeb.Desktop -c Release -r win-x64 --self-contained -p:PublishSingleFile=true \
|
||||||
-p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true
|
-p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true
|
||||||
@@ -56,17 +59,33 @@ The Android release key: alias `meweb`, certificate SHA-256
|
|||||||
| Path | What |
|
| Path | What |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `src/MeWeb.Shared` | `Endpoints.cs` (routes and hosts), wire contracts, `MeWebJsonContext`, generated `BuildInfo` |
|
| `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/<code>.json`, `Resources/Colors.axaml` |
|
| `src/MeWeb.App` | Avalonia views and view models, `Services/Localizer.cs` (+ `{s:Tr Key}`), `Strings/<code>.json`, `Resources/Colors.axaml` |
|
||||||
| `src/MeWeb.Desktop` | desktop head (`meweb` executable), Windows manifest |
|
| `src/MeWeb.Desktop` | desktop head (`meweb` executable), Windows manifest |
|
||||||
| `src/MeWeb.Android` | Android head (`software.arasaka.meweb`), resources, 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/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/setup.cs` + `setup.json` | pinned build tools |
|
||||||
| `tools/Screenshots` | headless renders of every view in every language |
|
| `tools/Screenshots` | headless renders of every view in every language |
|
||||||
| `branding/` | the icon (SVG source, PNG, ICO) |
|
| `branding/` | the icon (SVG source, PNG, ICO) |
|
||||||
|
|
||||||
## Pitfalls
|
## 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<App>` plus a non-generic
|
- **Avalonia 12 Android head:** `[Application] MainApplication : AvaloniaAndroidApplication<App>` plus a non-generic
|
||||||
`AvaloniaMainActivity`; the main view comes through `IActivityApplicationLifetime.MainViewFactory`, checked before
|
`AvaloniaMainActivity`; the main view comes through `IActivityApplicationLifetime.MainViewFactory`, checked before
|
||||||
`ISingleViewApplicationLifetime`.
|
`ISingleViewApplicationLifetime`.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
<Folder Name="/src/">
|
<Folder Name="/src/">
|
||||||
<Project Path="src/MeWeb.Shared/MeWeb.Shared.csproj" />
|
<Project Path="src/MeWeb.Shared/MeWeb.Shared.csproj" />
|
||||||
|
<Project Path="src/MeWeb.Core/MeWeb.Core.csproj" />
|
||||||
<Project Path="src/MeWeb.App/MeWeb.App.csproj" />
|
<Project Path="src/MeWeb.App/MeWeb.App.csproj" />
|
||||||
<Project Path="src/MeWeb.Desktop/MeWeb.Desktop.csproj" />
|
<Project Path="src/MeWeb.Desktop/MeWeb.Desktop.csproj" />
|
||||||
<Project Path="src/MeWeb.Android/MeWeb.Android.csproj" />
|
<Project Path="src/MeWeb.Android/MeWeb.Android.csproj" />
|
||||||
|
|||||||
+7
-1
@@ -89,7 +89,9 @@ Scope: `src/`, `tools/`. Out of scope: generated files (`obj/`, `*.g.cs`), third
|
|||||||
|
|
||||||
## F. Data
|
## 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.
|
26. **Entity → view is a hand-written `ToView()`.** No mapper library.
|
||||||
56. **A multi-row mutation is one server call.**
|
56. **A multi-row mutation is one server call.**
|
||||||
74. **No in-between DTOs — a response DTO is pure data; `WebResult` carries ok/error.**
|
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`.
|
link; the root `FlowDirection` follows `rtl`.
|
||||||
126. **`?` appears only in display text.** File names, package ids and paths use `MeWeb`, `meweb` and
|
126. **`?` appears only in display text.** File names, package ids and paths use `MeWeb`, `meweb` and
|
||||||
`software.arasaka.meweb`.
|
`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.
|
||||||
@@ -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
|
**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.
|
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
|
## What it checks
|
||||||
|
|
||||||
- whether you are online, or behind a login page;
|
Version 0.1 checks, and draws as a path from this device to the internet:
|
||||||
- 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.
|
|
||||||
|
|
||||||
Each check ends with what it means: a website at home, reaching home from outside, a mail server, a game server,
|
- whether you are online, or behind a login page;
|
||||||
family photos and files — possible, possible with a workaround, or not possible from here.
|
- 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
|
## Platforms
|
||||||
|
|
||||||
@@ -25,9 +29,8 @@ Linux (deb, rpm), Windows, macOS and Android. Builds are published as releases o
|
|||||||
|
|
||||||
## Privacy
|
## Privacy
|
||||||
|
|
||||||
me-web? sends nothing anywhere unless a check needs it, and lists every host it contacts. It has no telemetry. At
|
me-web? sends nothing anywhere unless a check needs it, and lists every host it contacts ("What this app contacts"
|
||||||
the end you can choose to share your findings to improve the next release's map of providers; you see exactly
|
in the app). It has no telemetry, no analytics and no crash reporting. Your checks are kept on your device only.
|
||||||
what is sent first, and you can delete it afterwards.
|
|
||||||
|
|
||||||
## Verifying the Android app
|
## 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`
|
`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-<version>-android.apk`). Every release also carries `SHA256SUMS.txt`.
|
(`apksigner verify --print-certs MeWeb-<version>-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
|
## Building
|
||||||
|
|
||||||
See `CLAUDE.md`. .NET 10, Avalonia 12.
|
See `CLAUDE.md`. .NET 10, Avalonia 12.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ server {
|
|||||||
listen 80;
|
listen 80;
|
||||||
listen [::]:80;
|
listen [::]:80;
|
||||||
server_name meweb.arasaka.software v4.meweb.arasaka.software v6.meweb.arasaka.software;
|
server_name meweb.arasaka.software v4.meweb.arasaka.software v6.meweb.arasaka.software;
|
||||||
|
access_log off;
|
||||||
|
|
||||||
location ^~ /.well-known/acme-challenge/ {
|
location ^~ /.well-known/acme-challenge/ {
|
||||||
root /var/www/acme;
|
root /var/www/acme;
|
||||||
@@ -28,7 +29,7 @@ server {
|
|||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header X-Frame-Options "DENY" 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;
|
error_log /var/log/nginx/meweb.arasaka.software.error.log;
|
||||||
|
|
||||||
client_max_body_size 128k;
|
client_max_body_size 128k;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ server {
|
|||||||
listen 80;
|
listen 80;
|
||||||
listen [::]:80;
|
listen [::]:80;
|
||||||
server_name meweb.arasaka.software v4.meweb.arasaka.software v6.meweb.arasaka.software;
|
server_name meweb.arasaka.software v4.meweb.arasaka.software v6.meweb.arasaka.software;
|
||||||
|
access_log off;
|
||||||
|
|
||||||
location ^~ /.well-known/acme-challenge/ {
|
location ^~ /.well-known/acme-challenge/ {
|
||||||
root /var/www/acme;
|
root /var/www/acme;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,9 +9,7 @@ namespace MeWeb.Android;
|
|||||||
[Application]
|
[Application]
|
||||||
public class MainApplication : AvaloniaAndroidApplication<MeWeb.App.App>
|
public class MainApplication : AvaloniaAndroidApplication<MeWeb.App.App>
|
||||||
{
|
{
|
||||||
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();
|
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) => base.CustomizeAppBuilder(builder).WithInterFont();
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
<ResourceDictionary>
|
<ResourceDictionary>
|
||||||
<ResourceDictionary.MergedDictionaries>
|
<ResourceDictionary.MergedDictionaries>
|
||||||
<ResourceInclude Source="avares://MeWeb.App/Resources/Colors.axaml" />
|
<ResourceInclude Source="avares://MeWeb.App/Resources/Colors.axaml" />
|
||||||
|
<ResourceInclude Source="avares://MeWeb.App/Resources/Icons.axaml" />
|
||||||
</ResourceDictionary.MergedDictionaries>
|
</ResourceDictionary.MergedDictionaries>
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
</Application.Resources>
|
</Application.Resources>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using Avalonia.Markup.Xaml;
|
|||||||
using MeWeb.App.Services;
|
using MeWeb.App.Services;
|
||||||
using MeWeb.App.ViewModels;
|
using MeWeb.App.ViewModels;
|
||||||
using MeWeb.App.Views;
|
using MeWeb.App.Views;
|
||||||
|
using MeWeb.Core.Net;
|
||||||
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
@@ -15,6 +16,8 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
public static IServiceProvider Services { get; private set; }
|
public static IServiceProvider Services { get; private set; }
|
||||||
|
|
||||||
|
public static IPlatform Platform { get; set; } = new SystemPlatform();
|
||||||
|
|
||||||
public override void Initialize()
|
public override void Initialize()
|
||||||
{
|
{
|
||||||
AvaloniaXamlLoader.Load(this);
|
AvaloniaXamlLoader.Load(this);
|
||||||
@@ -25,7 +28,7 @@ public partial class App : Application
|
|||||||
|
|
||||||
public override void OnFrameworkInitializationCompleted()
|
public override void OnFrameworkInitializationCompleted()
|
||||||
{
|
{
|
||||||
Services = new ServiceCollection().AddMeWebApp().BuildServiceProvider();
|
Services = new ServiceCollection().AddMeWebApp(Platform).BuildServiceProvider();
|
||||||
var stored = AppSettings.Read();
|
var stored = AppSettings.Read();
|
||||||
Localizer.Instance.SetLanguage(Languages.Find(stored.Language) ?? Languages.ForCulture(System.Globalization.CultureInfo.CurrentUICulture));
|
Localizer.Instance.SetLanguage(Languages.Find(stored.Language) ?? Languages.ForCulture(System.Globalization.CultureInfo.CurrentUICulture));
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
using MeWeb.App.Services;
|
|
||||||
using MeWeb.App.ViewModels;
|
using MeWeb.App.ViewModels;
|
||||||
|
using MeWeb.Core.Checks;
|
||||||
|
using MeWeb.Core.History;
|
||||||
|
using MeWeb.Core.Knowledge;
|
||||||
|
using MeWeb.Core.Net;
|
||||||
using MeWeb.Shared;
|
using MeWeb.Shared;
|
||||||
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -8,14 +11,24 @@ namespace MeWeb.App;
|
|||||||
|
|
||||||
public static class AppServices
|
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);
|
var factory = sp.GetRequiredService<IHttpClientFactory>();
|
||||||
http.DefaultRequestHeaders.UserAgent.ParseAdd($"me-web/{BuildInfo.Version}");
|
return new ServerProbe(factory.CreateClient(Secure), factory.CreateClient(Plain));
|
||||||
});
|
});
|
||||||
services.AddSingleton<ServerClient>();
|
services.AddSingleton<KnowledgeBase>();
|
||||||
|
services.AddSingleton(new HistoryStore(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "MeWeb")));
|
||||||
|
services.AddSingleton<CheckRunner>();
|
||||||
services.AddTransient<MainViewModel>();
|
services.AddTransient<MainViewModel>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,5 +27,6 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\MeWeb.Shared\MeWeb.Shared.csproj" />
|
<ProjectReference Include="..\MeWeb.Shared\MeWeb.Shared.csproj" />
|
||||||
|
<ProjectReference Include="..\MeWeb.Core\MeWeb.Core.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -6,14 +6,21 @@
|
|||||||
<Color x:Key="Ground">#1B141C</Color>
|
<Color x:Key="Ground">#1B141C</Color>
|
||||||
<Color x:Key="Surface">#2C222D</Color>
|
<Color x:Key="Surface">#2C222D</Color>
|
||||||
<Color x:Key="SurfaceRaised">#352A36</Color>
|
<Color x:Key="SurfaceRaised">#352A36</Color>
|
||||||
<Color x:Key="Line">#3A2E3B</Color>
|
<Color x:Key="Line">#4A3C4B</Color>
|
||||||
<Color x:Key="TextStrong">#F0EAEF</Color>
|
<Color x:Key="TextStrong">#F0EAEF</Color>
|
||||||
<Color x:Key="TextSoft">#A79AA6</Color>
|
<Color x:Key="TextSoft">#B3A6B2</Color>
|
||||||
<Color x:Key="Good">#6FCF97</Color>
|
<Color x:Key="Good">#6FCF97</Color>
|
||||||
<Color x:Key="Caution">#F2C14E</Color>
|
<Color x:Key="Caution">#F2C14E</Color>
|
||||||
<Color x:Key="Bad">#E85D6C</Color>
|
<Color x:Key="Bad">#F06B79</Color>
|
||||||
|
<Color x:Key="GoodTint">#2E6FCF97</Color>
|
||||||
|
<Color x:Key="CautionTint">#2EF2C14E</Color>
|
||||||
|
<Color x:Key="BadTint">#33F06B79</Color>
|
||||||
|
<Color x:Key="NeutralTint">#33B3A6B2</Color>
|
||||||
|
<Color x:Key="Scrim">#E6140F15</Color>
|
||||||
|
|
||||||
<SolidColorBrush x:Key="BrandRedBrush" Color="{StaticResource BrandRed}" />
|
<SolidColorBrush x:Key="BrandRedBrush" Color="{StaticResource BrandRed}" />
|
||||||
|
<SolidColorBrush x:Key="BrandRedDarkBrush" Color="{StaticResource BrandRedDark}" />
|
||||||
|
<SolidColorBrush x:Key="BrandRedLightBrush" Color="{StaticResource BrandRedLight}" />
|
||||||
<SolidColorBrush x:Key="GroundBrush" Color="{StaticResource Ground}" />
|
<SolidColorBrush x:Key="GroundBrush" Color="{StaticResource Ground}" />
|
||||||
<SolidColorBrush x:Key="SurfaceBrush" Color="{StaticResource Surface}" />
|
<SolidColorBrush x:Key="SurfaceBrush" Color="{StaticResource Surface}" />
|
||||||
<SolidColorBrush x:Key="SurfaceRaisedBrush" Color="{StaticResource SurfaceRaised}" />
|
<SolidColorBrush x:Key="SurfaceRaisedBrush" Color="{StaticResource SurfaceRaised}" />
|
||||||
@@ -23,4 +30,9 @@
|
|||||||
<SolidColorBrush x:Key="GoodBrush" Color="{StaticResource Good}" />
|
<SolidColorBrush x:Key="GoodBrush" Color="{StaticResource Good}" />
|
||||||
<SolidColorBrush x:Key="CautionBrush" Color="{StaticResource Caution}" />
|
<SolidColorBrush x:Key="CautionBrush" Color="{StaticResource Caution}" />
|
||||||
<SolidColorBrush x:Key="BadBrush" Color="{StaticResource Bad}" />
|
<SolidColorBrush x:Key="BadBrush" Color="{StaticResource Bad}" />
|
||||||
|
<SolidColorBrush x:Key="GoodTintBrush" Color="{StaticResource GoodTint}" />
|
||||||
|
<SolidColorBrush x:Key="CautionTintBrush" Color="{StaticResource CautionTint}" />
|
||||||
|
<SolidColorBrush x:Key="BadTintBrush" Color="{StaticResource BadTint}" />
|
||||||
|
<SolidColorBrush x:Key="NeutralTintBrush" Color="{StaticResource NeutralTint}" />
|
||||||
|
<SolidColorBrush x:Key="ScrimBrush" Color="{StaticResource Scrim}" />
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<ResourceDictionary xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<StreamGeometry x:Key="IconLaptop">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</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconSmartphone">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</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconWifi">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</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconCable">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</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconRouter">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</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconBuilding2">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</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconGlobe">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</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconNetwork">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</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconRadioTower">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</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconHistory">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</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconInfo">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</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconCircleCheck">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</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconTriangleAlert">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</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconCircleX">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</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconCircleHelp">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</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconRefreshCw">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</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconLanguages">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</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconPlug">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</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconLayers">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</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconMapPin">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</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconChevronDown">m 6 9 6 6 6 -6</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconServer">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</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconShieldCheck">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</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconRoute">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</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconArrowUpRight">M 7 7 h 10 v 10 M 7 17 17 7</StreamGeometry>
|
||||||
|
<StreamGeometry x:Key="IconDownload">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</StreamGeometry>
|
||||||
|
</ResourceDictionary>
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<Styles xmlns="https://github.com/avaloniaui"
|
<Styles xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
<Style Selector="TextBlock.title">
|
<Style Selector="TextBlock.title">
|
||||||
<Setter Property="FontSize" Value="34" />
|
<Setter Property="FontSize" Value="30" />
|
||||||
<Setter Property="FontWeight" Value="Bold" />
|
<Setter Property="FontWeight" Value="Bold" />
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextStrongBrush}" />
|
<Setter Property="Foreground" Value="{StaticResource TextStrongBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TextBlock.lead">
|
<Style Selector="TextBlock.lead">
|
||||||
<Setter Property="FontSize" Value="18" />
|
<Setter Property="FontSize" Value="17" />
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextSoftBrush}" />
|
<Setter Property="Foreground" Value="{StaticResource TextSoftBrush}" />
|
||||||
<Setter Property="TextWrapping" Value="Wrap" />
|
<Setter Property="TextWrapping" Value="Wrap" />
|
||||||
<Setter Property="TextAlignment" Value="Center" />
|
<Setter Property="TextAlignment" Value="Center" />
|
||||||
@@ -16,11 +16,26 @@
|
|||||||
<Setter Property="FontWeight" Value="SemiBold" />
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextStrongBrush}" />
|
<Setter Property="Foreground" Value="{StaticResource TextStrongBrush}" />
|
||||||
<Setter Property="TextAlignment" Value="Center" />
|
<Setter Property="TextAlignment" Value="Center" />
|
||||||
|
<Setter Property="TextWrapping" Value="Wrap" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.section">
|
||||||
|
<Setter Property="FontSize" Value="19" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextStrongBrush}" />
|
||||||
|
<Setter Property="Margin" Value="8,0" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TextBlock.small">
|
<Style Selector="TextBlock.small">
|
||||||
<Setter Property="FontSize" Value="13" />
|
<Setter Property="FontSize" Value="13" />
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextSoftBrush}" />
|
<Setter Property="Foreground" Value="{StaticResource TextSoftBrush}" />
|
||||||
|
<Setter Property="TextWrapping" Value="Wrap" />
|
||||||
</Style>
|
</Style>
|
||||||
|
<Style Selector="TextBlock.body">
|
||||||
|
<Setter Property="FontSize" Value="15" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextStrongBrush}" />
|
||||||
|
<Setter Property="TextWrapping" Value="Wrap" />
|
||||||
|
<Setter Property="LineHeight" Value="22" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Button.primary">
|
<Style Selector="Button.primary">
|
||||||
<Setter Property="Background" Value="{StaticResource BrandRedBrush}" />
|
<Setter Property="Background" Value="{StaticResource BrandRedBrush}" />
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextStrongBrush}" />
|
<Setter Property="Foreground" Value="{StaticResource TextStrongBrush}" />
|
||||||
@@ -30,27 +45,268 @@
|
|||||||
<Setter Property="CornerRadius" Value="28" />
|
<Setter Property="CornerRadius" Value="28" />
|
||||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||||
</Style>
|
</Style>
|
||||||
|
<Style Selector="Button.primary:pointerover /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource BrandRedLightBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource GroundBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.primary:disabled /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource BrandRedDarkBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextStrongBrush}" />
|
||||||
|
</Style>
|
||||||
<Style Selector="Button.link">
|
<Style Selector="Button.link">
|
||||||
<Setter Property="Background" Value="Transparent" />
|
<Setter Property="Background" Value="Transparent" />
|
||||||
<Setter Property="Foreground" Value="{StaticResource BrandRedBrush}" />
|
<Setter Property="Foreground" Value="{StaticResource BrandRedLightBrush}" />
|
||||||
<Setter Property="Padding" Value="4,2" />
|
<Setter Property="Padding" Value="0,2" />
|
||||||
<Setter Property="FontSize" Value="13" />
|
<Setter Property="FontSize" Value="13" />
|
||||||
<Setter Property="Cursor" Value="Hand" />
|
<Setter Property="Cursor" Value="Hand" />
|
||||||
</Style>
|
</Style>
|
||||||
|
<Style Selector="Button.link:pointerover /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextStrongBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.quiet">
|
||||||
|
<Setter Property="Background" Value="{StaticResource SurfaceRaisedBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextStrongBrush}" />
|
||||||
|
<Setter Property="CornerRadius" Value="18" />
|
||||||
|
<Setter Property="Padding" Value="14,8" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.round">
|
||||||
|
<Setter Property="Background" Value="{StaticResource SurfaceRaisedBrush}" />
|
||||||
|
<Setter Property="CornerRadius" Value="20" />
|
||||||
|
<Setter Property="Width" Value="40" />
|
||||||
|
<Setter Property="Height" Value="40" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Path.icon">
|
||||||
|
<Setter Property="Stroke" Value="{StaticResource TextStrongBrush}" />
|
||||||
|
<Setter Property="StrokeThickness" Value="2" />
|
||||||
|
<Setter Property="StrokeLineCap" Value="Round" />
|
||||||
|
<Setter Property="StrokeJoin" Value="Round" />
|
||||||
|
<Setter Property="Width" Value="24" />
|
||||||
|
<Setter Property="Height" Value="24" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Ellipse.pulse">
|
<Style Selector="Ellipse.pulse">
|
||||||
|
<Setter Property="Fill" Value="{StaticResource BrandRedBrush}" />
|
||||||
<Style.Animations>
|
<Style.Animations>
|
||||||
<Animation Duration="0:0:1.6" IterationCount="Infinite">
|
<Animation Duration="0:0:1.6" IterationCount="Infinite">
|
||||||
<KeyFrame Cue="0%">
|
<KeyFrame Cue="0%">
|
||||||
<Setter Property="Opacity" Value="0.85" />
|
<Setter Property="Opacity" Value="0.7" />
|
||||||
<Setter Property="ScaleTransform.ScaleX" Value="0.8" />
|
<Setter Property="ScaleTransform.ScaleX" Value="0.8" />
|
||||||
<Setter Property="ScaleTransform.ScaleY" Value="0.8" />
|
<Setter Property="ScaleTransform.ScaleY" Value="0.8" />
|
||||||
</KeyFrame>
|
</KeyFrame>
|
||||||
<KeyFrame Cue="100%">
|
<KeyFrame Cue="100%">
|
||||||
<Setter Property="Opacity" Value="0" />
|
<Setter Property="Opacity" Value="0" />
|
||||||
<Setter Property="ScaleTransform.ScaleX" Value="1.5" />
|
<Setter Property="ScaleTransform.ScaleX" Value="1.45" />
|
||||||
<Setter Property="ScaleTransform.ScaleY" Value="1.5" />
|
<Setter Property="ScaleTransform.ScaleY" Value="1.45" />
|
||||||
</KeyFrame>
|
</KeyFrame>
|
||||||
</Animation>
|
</Animation>
|
||||||
</Style.Animations>
|
</Style.Animations>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.banner">
|
||||||
|
<Setter Property="Background" Value="{StaticResource SurfaceRaisedBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource BrandRedBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="12" />
|
||||||
|
<Setter Property="Padding" Value="16,10" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Ellipse.disc">
|
||||||
|
<Setter Property="Fill" Value="{StaticResource SurfaceRaisedBrush}" />
|
||||||
|
<Setter Property="Stroke" Value="{StaticResource LineBrush}" />
|
||||||
|
<Setter Property="StrokeThickness" Value="3" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Ellipse.disc.good">
|
||||||
|
<Setter Property="Stroke" Value="{StaticResource GoodBrush}" />
|
||||||
|
<Setter Property="Fill" Value="{StaticResource GoodTintBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Ellipse.disc.caution">
|
||||||
|
<Setter Property="Stroke" Value="{StaticResource CautionBrush}" />
|
||||||
|
<Setter Property="Fill" Value="{StaticResource CautionTintBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Ellipse.disc.bad">
|
||||||
|
<Setter Property="Stroke" Value="{StaticResource BadBrush}" />
|
||||||
|
<Setter Property="Fill" Value="{StaticResource BadTintBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Ellipse.disc.unknown">
|
||||||
|
<Setter Property="Stroke" Value="{StaticResource TextSoftBrush}" />
|
||||||
|
<Setter Property="Fill" Value="{StaticResource NeutralTintBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector=":is(Panel).node">
|
||||||
|
<Setter Property="Opacity" Value="0.45" />
|
||||||
|
<Setter Property="RenderTransform" Value="scale(0.9)" />
|
||||||
|
<Setter Property="Transitions">
|
||||||
|
<Transitions>
|
||||||
|
<DoubleTransition Property="Opacity" Duration="0:0:0.45" />
|
||||||
|
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.45" Easing="BackEaseOut" />
|
||||||
|
</Transitions>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style Selector=":is(Panel).node.lit">
|
||||||
|
<Setter Property="Opacity" Value="1" />
|
||||||
|
<Setter Property="RenderTransform" Value="scale(1)" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.nodeTitle">
|
||||||
|
<Setter Property="FontSize" Value="14" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextStrongBrush}" />
|
||||||
|
<Setter Property="TextWrapping" Value="Wrap" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.nodeSubtitle">
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextSoftBrush}" />
|
||||||
|
<Setter Property="TextWrapping" Value="Wrap" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.linkLabel">
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextSoftBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Line.connector">
|
||||||
|
<Setter Property="Stroke" Value="{StaticResource LineBrush}" />
|
||||||
|
<Setter Property="StrokeThickness" Value="3" />
|
||||||
|
<Setter Property="StrokeLineCap" Value="Round" />
|
||||||
|
<Setter Property="StrokeDashArray" Value="1.5,1.5" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Line.connector.flowing">
|
||||||
|
<Setter Property="Stroke" Value="{StaticResource BrandRedBrush}" />
|
||||||
|
<Style.Animations>
|
||||||
|
<Animation Duration="0:0:0.5" IterationCount="Infinite">
|
||||||
|
<KeyFrame Cue="0%">
|
||||||
|
<Setter Property="StrokeDashOffset" Value="0" />
|
||||||
|
</KeyFrame>
|
||||||
|
<KeyFrame Cue="100%">
|
||||||
|
<Setter Property="StrokeDashOffset" Value="-3" />
|
||||||
|
</KeyFrame>
|
||||||
|
</Animation>
|
||||||
|
</Style.Animations>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Line.connector.lit">
|
||||||
|
<Setter Property="Stroke" Value="{StaticResource TextSoftBrush}" />
|
||||||
|
<Setter Property="StrokeDashArray" Value="1,0" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.card">
|
||||||
|
<Setter Property="Background" Value="{StaticResource SurfaceBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="4,0,0,0" />
|
||||||
|
<Setter Property="CornerRadius" Value="14" />
|
||||||
|
<Setter Property="Padding" Value="18,16" />
|
||||||
|
<Setter Property="Margin" Value="8" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Top" />
|
||||||
|
<Setter Property="Transitions">
|
||||||
|
<Transitions>
|
||||||
|
<BrushTransition Property="BorderBrush" Duration="0:0:0.4" />
|
||||||
|
</Transitions>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.card.good">
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource GoodBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.card.caution">
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource CautionBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.card.bad">
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource BadBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.card.unknown, Border.card.info">
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource TextSoftBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.chip">
|
||||||
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
|
<Setter Property="Padding" Value="9,3" />
|
||||||
|
<Setter Property="Background" Value="{StaticResource NeutralTintBrush}" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.chip TextBlock">
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextStrongBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.card.good Border.chip">
|
||||||
|
<Setter Property="Background" Value="{StaticResource GoodTintBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.card.good Border.chip TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource GoodBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.card.caution Border.chip">
|
||||||
|
<Setter Property="Background" Value="{StaticResource CautionTintBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.card.caution Border.chip TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource CautionBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.card.bad Border.chip">
|
||||||
|
<Setter Property="Background" Value="{StaticResource BadTintBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.card.bad Border.chip TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource BadBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.cardTitle">
|
||||||
|
<Setter Property="FontSize" Value="17" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextStrongBrush}" />
|
||||||
|
<Setter Property="TextWrapping" Value="Wrap" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Property="Margin" Value="10,0" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.summary">
|
||||||
|
<Setter Property="FontSize" Value="15" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextStrongBrush}" />
|
||||||
|
<Setter Property="TextWrapping" Value="Wrap" />
|
||||||
|
<Setter Property="LineHeight" Value="21" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.factLabel">
|
||||||
|
<Setter Property="FontSize" Value="13" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextSoftBrush}" />
|
||||||
|
<Setter Property="TextWrapping" Value="Wrap" />
|
||||||
|
<Setter Property="Margin" Value="0,1,10,0" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="SelectableTextBlock.factValue">
|
||||||
|
<Setter Property="FontSize" Value="13" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextStrongBrush}" />
|
||||||
|
<Setter Property="TextWrapping" Value="Wrap" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ProgressBar.thin">
|
||||||
|
<Setter Property="MinHeight" Value="3" />
|
||||||
|
<Setter Property="Height" Value="3" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource BrandRedBrush}" />
|
||||||
|
<Setter Property="Background" Value="{StaticResource SurfaceRaisedBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Expander.explain">
|
||||||
|
<Style.Resources>
|
||||||
|
<SolidColorBrush x:Key="ExpanderHeaderBackground" Color="{StaticResource SurfaceRaised}" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderHeaderBackgroundPointerOver" Color="{StaticResource Line}" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderHeaderBackgroundPressed" Color="{StaticResource Line}" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderHeaderBorderBrush" Color="Transparent" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderHeaderBorderBrushPointerOver" Color="Transparent" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderHeaderBorderBrushPressed" Color="Transparent" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderHeaderForeground" Color="{StaticResource TextStrong}" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderHeaderForegroundPointerOver" Color="{StaticResource TextStrong}" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderHeaderForegroundPressed" Color="{StaticResource TextStrong}" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderContentBackground" Color="{StaticResource SurfaceRaised}" />
|
||||||
|
<SolidColorBrush x:Key="ExpanderContentBorderBrush" Color="Transparent" />
|
||||||
|
</Style.Resources>
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="FontSize" Value="13" />
|
||||||
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.scrim">
|
||||||
|
<Setter Property="Background" Value="{StaticResource ScrimBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.sheet">
|
||||||
|
<Setter Property="Background" Value="{StaticResource SurfaceBrush}" />
|
||||||
|
<Setter Property="CornerRadius" Value="16" />
|
||||||
|
<Setter Property="Padding" Value="22,18" />
|
||||||
|
<Setter Property="MaxWidth" Value="660" />
|
||||||
|
<Setter Property="Margin" Value="16" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
</Styles>
|
</Styles>
|
||||||
@@ -30,7 +30,7 @@ public static class Languages
|
|||||||
var found = Find(current.Name);
|
var found = Find(current.Name);
|
||||||
if (found is not null) return found;
|
if (found is not null) return found;
|
||||||
}
|
}
|
||||||
return Fallback;
|
return Find(Fallback.Code) ?? Fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<Language> Load()
|
static List<Language> Load()
|
||||||
|
|||||||
@@ -27,7 +27,17 @@ public sealed class Localizer : INotifyPropertyChanged
|
|||||||
|
|
||||||
public string this[string key] => current.GetValueOrDefault(key) ?? english.GetValueOrDefault(key) ?? key;
|
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)
|
public void SetLanguage(Language language)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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.",
|
"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",
|
"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.",
|
"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",
|
"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…",
|
"StatusChecking": "Checking the connection…",
|
||||||
"StatusOffline": "Not connected to the internet",
|
"StatusOffline": "Not connected to the internet",
|
||||||
"StatusOnline": "Connected to the internet",
|
"StatusOnline": "Connected to the internet",
|
||||||
"SuggestCorrection": "Suggest a correction",
|
"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?"
|
||||||
}
|
}
|
||||||
@@ -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.",
|
"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",
|
"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.",
|
"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",
|
"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…",
|
"StatusChecking": "Verifico la connessione…",
|
||||||
"StatusOffline": "Non connesso a internet",
|
"StatusOffline": "Non connesso a internet",
|
||||||
"StatusOnline": "Connesso a internet",
|
"StatusOnline": "Connesso a internet",
|
||||||
"SuggestCorrection": "Proponi una correzione",
|
"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?"
|
||||||
}
|
}
|
||||||
@@ -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<string> 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<string> 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<FactViewModel> 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);
|
||||||
|
}
|
||||||
@@ -1,60 +1,335 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
|
||||||
using MeWeb.App.Services;
|
using MeWeb.App.Services;
|
||||||
|
using MeWeb.Core.Checks;
|
||||||
|
using MeWeb.Core.History;
|
||||||
|
using MeWeb.Core.Knowledge;
|
||||||
|
using MeWeb.Core.Net;
|
||||||
using MeWeb.Shared;
|
using MeWeb.Shared;
|
||||||
|
|
||||||
namespace MeWeb.App.ViewModels;
|
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 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<CardViewModel>(CardOrder.Select(id => new CardViewModel(id, OpenLink)));
|
||||||
|
}
|
||||||
|
|
||||||
public IReadOnlyList<Language> Languages { get; } = Services.Languages.All;
|
public IReadOnlyList<Language> Languages { get; } = Services.Languages.All;
|
||||||
|
|
||||||
|
public ObservableCollection<CardViewModel> Cards { get; }
|
||||||
|
|
||||||
|
public ObservableCollection<PathNodeViewModel> Path { get; } = [];
|
||||||
|
|
||||||
public ILauncher Launcher { get; set; }
|
public ILauncher Launcher { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[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;
|
public partial Reachability Status { get; set; } = Reachability.Unknown;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(ShowDraftNotice), nameof(Flow))]
|
[NotifyPropertyChangedFor(nameof(ShowDraftNotice), nameof(Flow))]
|
||||||
public partial Language SelectedLanguage { get; set; } = Localizer.Instance.Language;
|
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 ShowChecking => Status == Reachability.Unknown;
|
||||||
public bool ShowOnline => Status == Reachability.Online;
|
public bool ShowOnline => Status == Reachability.Online;
|
||||||
public bool ShowOffline => Status == Reachability.Offline;
|
public bool ShowOffline => Status == Reachability.Offline;
|
||||||
|
public bool ShowCaptive => Status == Reachability.Captive;
|
||||||
public bool ShowDraftNotice => SelectedLanguage?.IsDraft == true;
|
public bool ShowDraftNotice => SelectedLanguage?.IsDraft == true;
|
||||||
|
public bool ShowUpdate => LatestVersion is not null;
|
||||||
|
|
||||||
public FlowDirection Flow => Localizer.Instance.IsRightToLeft ? FlowDirection.RightToLeft : FlowDirection.LeftToRight;
|
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.Online => "StatusOnline",
|
||||||
Reachability.Offline => Localizer.Instance["StatusOffline"],
|
Reachability.Offline => "StatusOffline",
|
||||||
_ => Localizer.Instance["StatusChecking"],
|
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 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]
|
[RelayCommand]
|
||||||
async Task Refresh(CancellationToken ct)
|
async Task Start(CancellationToken ct)
|
||||||
{
|
{
|
||||||
Status = Reachability.Unknown;
|
Status = Reachability.Unknown;
|
||||||
var (state, _) = await server.Probe(ct);
|
var report = await server.Online(ct);
|
||||||
Status = state;
|
Status = report.State switch
|
||||||
|
{
|
||||||
|
OnlineState.Online or OnlineState.Rewritten => Reachability.Online,
|
||||||
|
OnlineState.Captive => Reachability.Captive,
|
||||||
|
_ => Reachability.Offline,
|
||||||
|
};
|
||||||
|
NoteVersion(report.Server);
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
async Task OpenCorrections()
|
async Task Check(CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (Launcher is null) return;
|
IsChecking = true;
|
||||||
await Launcher.LaunchUriAsync(new Uri(CorrectionsUrl));
|
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<PathNodeViewModel, Outcome> 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)
|
partial void OnSelectedLanguageChanged(Language value)
|
||||||
{
|
{
|
||||||
@@ -63,8 +338,13 @@ public partial class MainViewModel(ServerClient server) : ObservableObject
|
|||||||
var settings = AppSettings.Read();
|
var settings = AppSettings.Read();
|
||||||
settings.Language = value.Code;
|
settings.Language = value.Code;
|
||||||
AppSettings.Write(settings);
|
AppSettings.Write(settings);
|
||||||
|
foreach (var card in Cards) card.Refresh();
|
||||||
|
foreach (var node in Path) node.Refresh();
|
||||||
OnPropertyChanged(nameof(StatusFormatted));
|
OnPropertyChanged(nameof(StatusFormatted));
|
||||||
|
OnPropertyChanged(nameof(CheckFormatted));
|
||||||
OnPropertyChanged(nameof(VersionFormatted));
|
OnPropertyChanged(nameof(VersionFormatted));
|
||||||
|
OnPropertyChanged(nameof(UpdateFormatted));
|
||||||
|
OnPropertyChanged(nameof(MapFormatted));
|
||||||
OnPropertyChanged(nameof(Flow));
|
OnPropertyChanged(nameof(Flow));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -6,10 +6,18 @@
|
|||||||
x:DataType="vm:MainViewModel"
|
x:DataType="vm:MainViewModel"
|
||||||
FlowDirection="{Binding Flow}"
|
FlowDirection="{Binding Flow}"
|
||||||
Background="{StaticResource GroundBrush}">
|
Background="{StaticResource GroundBrush}">
|
||||||
<DockPanel Margin="24,18">
|
<Grid>
|
||||||
<Grid DockPanel.Dock="Top" ColumnDefinitions="*,Auto">
|
<DockPanel>
|
||||||
|
<Grid DockPanel.Dock="Top" ColumnDefinitions="*,Auto,Auto" Margin="20,14,20,6">
|
||||||
<TextBlock Grid.Column="0" Classes="title" Text="me-web?" VerticalAlignment="Center" />
|
<TextBlock Grid.Column="0" Classes="title" Text="me-web?" VerticalAlignment="Center" />
|
||||||
<ComboBox Grid.Column="1" MinWidth="180" VerticalAlignment="Center"
|
<Button Grid.Column="1" Classes="round" Margin="0,0,10,0" VerticalAlignment="Center"
|
||||||
|
AutomationProperties.AutomationId="main-contacts"
|
||||||
|
AutomationProperties.Name="{s:Tr Contacts}"
|
||||||
|
ToolTip.Tip="{s:Tr Contacts}"
|
||||||
|
Command="{Binding ToggleContactsCommand}">
|
||||||
|
<Path Classes="icon" Data="{StaticResource IconInfo}" />
|
||||||
|
</Button>
|
||||||
|
<ComboBox Grid.Column="2" MinWidth="150" MaxWidth="220" VerticalAlignment="Center"
|
||||||
AutomationProperties.AutomationId="main-language"
|
AutomationProperties.AutomationId="main-language"
|
||||||
AutomationProperties.Name="{s:Tr Language}"
|
AutomationProperties.Name="{s:Tr Language}"
|
||||||
ItemsSource="{Binding Languages}"
|
ItemsSource="{Binding Languages}"
|
||||||
@@ -22,7 +30,7 @@
|
|||||||
</ComboBox>
|
</ComboBox>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<StackPanel DockPanel.Dock="Bottom" Spacing="6" HorizontalAlignment="Center">
|
<StackPanel DockPanel.Dock="Bottom" Spacing="4" Margin="16,6,16,10" HorizontalAlignment="Center">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Center" IsVisible="{Binding ShowDraftNotice}">
|
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Center" IsVisible="{Binding ShowDraftNotice}">
|
||||||
<TextBlock Classes="small" Text="{s:Tr DraftNotice}" VerticalAlignment="Center" />
|
<TextBlock Classes="small" Text="{s:Tr DraftNotice}" VerticalAlignment="Center" />
|
||||||
<Button Classes="link" Content="{s:Tr SuggestCorrection}"
|
<Button Classes="link" Content="{s:Tr SuggestCorrection}"
|
||||||
@@ -32,19 +40,190 @@
|
|||||||
<TextBlock Classes="small" Text="{Binding VersionFormatted}" HorizontalAlignment="Center" />
|
<TextBlock Classes="small" Text="{Binding VersionFormatted}" HorizontalAlignment="Center" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Spacing="22" VerticalAlignment="Center" HorizontalAlignment="Center" MaxWidth="560">
|
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
|
||||||
<Grid Width="140" Height="140" HorizontalAlignment="Center">
|
<StackPanel Spacing="26" Margin="8,4,8,24">
|
||||||
<Ellipse Classes="pulse" Width="140" Height="140" Fill="{StaticResource BrandRedBrush}" IsVisible="{Binding ShowChecking}" />
|
<Border Classes="banner" IsVisible="{Binding ShowUpdate}">
|
||||||
<Ellipse Width="112" Height="112" Fill="{StaticResource SurfaceRaisedBrush}" Stroke="{StaticResource LineBrush}" StrokeThickness="2" />
|
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||||
<Ellipse Width="64" Height="64" Fill="{StaticResource GoodBrush}" IsVisible="{Binding ShowOnline}" />
|
<TextBlock Classes="body" Text="{Binding UpdateFormatted}" VerticalAlignment="Center" />
|
||||||
<Ellipse Width="64" Height="64" Fill="{StaticResource BadBrush}" IsVisible="{Binding ShowOffline}" />
|
<Button Classes="quiet" Content="{s:Tr UpdateOpen}"
|
||||||
<Ellipse Width="64" Height="64" Fill="{StaticResource TextSoftBrush}" Opacity="0.4" IsVisible="{Binding ShowChecking}" />
|
AutomationProperties.AutomationId="main-update"
|
||||||
|
Command="{Binding OpenDownloadsCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<StackPanel Spacing="16" HorizontalAlignment="Center" MaxWidth="600" Margin="8,6,8,0">
|
||||||
|
<Grid Width="120" Height="120" HorizontalAlignment="Center">
|
||||||
|
<Ellipse Classes="pulse" Width="120" Height="120" IsVisible="{Binding ShowChecking}" />
|
||||||
|
<Ellipse Width="96" Height="96" Fill="{StaticResource SurfaceRaisedBrush}" Stroke="{StaticResource LineBrush}" StrokeThickness="2" />
|
||||||
|
<Ellipse Width="54" Height="54" Fill="{StaticResource GoodBrush}" IsVisible="{Binding ShowOnline}" />
|
||||||
|
<Ellipse Width="54" Height="54" Fill="{StaticResource CautionBrush}" IsVisible="{Binding ShowCaptive}" />
|
||||||
|
<Ellipse Width="54" Height="54" Fill="{StaticResource BadBrush}" IsVisible="{Binding ShowOffline}" />
|
||||||
|
<Ellipse Width="54" Height="54" Fill="{StaticResource TextSoftBrush}" Opacity="0.4" IsVisible="{Binding ShowChecking}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
<TextBlock Classes="status" Text="{Binding StatusFormatted}" />
|
<TextBlock Classes="status" Text="{Binding StatusFormatted}" />
|
||||||
<TextBlock Classes="lead" Text="{s:Tr AppTagline}" />
|
<TextBlock Classes="lead" Text="{s:Tr AppTagline}" />
|
||||||
<Button Classes="primary" Content="{s:Tr Check}"
|
<Button Classes="primary" Content="{Binding CheckFormatted}"
|
||||||
AutomationProperties.AutomationId="main-check"
|
AutomationProperties.AutomationId="main-check"
|
||||||
Command="{Binding RefreshCommand}" />
|
Command="{Binding CheckCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="14" IsVisible="{Binding HasResults}">
|
||||||
|
<TextBlock Classes="section" Text="{s:Tr PathTitle}" HorizontalAlignment="Center" />
|
||||||
|
<ItemsControl ItemsSource="{Binding Path}" IsVisible="{Binding IsWide}" HorizontalAlignment="Center">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal" />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:PathNodeViewModel">
|
||||||
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Top">
|
||||||
|
<Grid Width="56" Height="64" VerticalAlignment="Top" IsVisible="{Binding ShowConnector}">
|
||||||
|
<TextBlock Classes="linkLabel" Text="{Binding LinkLabel}" HorizontalAlignment="Center" VerticalAlignment="Top" Margin="0,6,0,0" />
|
||||||
|
<Line StartPoint="4,32" EndPoint="52,32" Classes="connector" Classes.flowing="{Binding IsRunning}" Classes.lit="{Binding IsLit}" />
|
||||||
|
</Grid>
|
||||||
|
<StackPanel Width="124" Spacing="6" Classes="node" Classes.lit="{Binding IsLit}"
|
||||||
|
AutomationProperties.AutomationId="{Binding AutomationId}">
|
||||||
|
<Grid Width="64" Height="64" HorizontalAlignment="Center">
|
||||||
|
<Ellipse Classes="pulse" Width="64" Height="64" IsVisible="{Binding IsRunning}" />
|
||||||
|
<Ellipse Classes="disc" Width="64" Height="64"
|
||||||
|
Classes.good="{Binding IsGood}" Classes.caution="{Binding IsCaution}"
|
||||||
|
Classes.bad="{Binding IsBad}" Classes.unknown="{Binding IsUnknown}" />
|
||||||
|
<Viewbox Width="28" Height="28">
|
||||||
|
<Path Classes="icon" Data="{Binding Icon}" />
|
||||||
|
</Viewbox>
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Classes="nodeTitle" Text="{Binding Title}" TextAlignment="Center" />
|
||||||
|
<TextBlock Classes="nodeSubtitle" Text="{Binding Subtitle}" TextAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
<ItemsControl ItemsSource="{Binding Path}" IsVisible="{Binding IsNarrow}" HorizontalAlignment="Center" MinWidth="280" Margin="16,0">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:PathNodeViewModel">
|
||||||
|
<StackPanel>
|
||||||
|
<Grid Height="36" ColumnDefinitions="64,*" IsVisible="{Binding ShowConnector}">
|
||||||
|
<Line Grid.Column="0" StartPoint="32,2" EndPoint="32,34" Classes="connector" Classes.flowing="{Binding IsRunning}" Classes.lit="{Binding IsLit}" />
|
||||||
|
<TextBlock Grid.Column="1" Classes="linkLabel" Text="{Binding LinkLabel}" VerticalAlignment="Center" Margin="8,0,0,0" />
|
||||||
|
</Grid>
|
||||||
|
<Grid ColumnDefinitions="64,*" Classes="node" Classes.lit="{Binding IsLit}"
|
||||||
|
AutomationProperties.AutomationId="{Binding AutomationId}">
|
||||||
|
<Grid Grid.Column="0" Width="56" Height="56">
|
||||||
|
<Ellipse Classes="pulse" Width="56" Height="56" IsVisible="{Binding IsRunning}" />
|
||||||
|
<Ellipse Classes="disc" Width="56" Height="56"
|
||||||
|
Classes.good="{Binding IsGood}" Classes.caution="{Binding IsCaution}"
|
||||||
|
Classes.bad="{Binding IsBad}" Classes.unknown="{Binding IsUnknown}" />
|
||||||
|
<Viewbox Width="24" Height="24">
|
||||||
|
<Path Classes="icon" Data="{Binding Icon}" />
|
||||||
|
</Viewbox>
|
||||||
|
</Grid>
|
||||||
|
<StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="12,0,0,0" Spacing="2">
|
||||||
|
<TextBlock Classes="nodeTitle" Text="{Binding Title}" />
|
||||||
|
<TextBlock Classes="nodeSubtitle" Text="{Binding Subtitle}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="8" IsVisible="{Binding HasResults}">
|
||||||
|
<TextBlock Classes="section" Text="{s:Tr ResultsTitle}" HorizontalAlignment="Center" />
|
||||||
|
<ItemsControl ItemsSource="{Binding Cards}" HorizontalAlignment="Center">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<WrapPanel ItemWidth="{Binding CardWidth}" HorizontalAlignment="Center" />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:CardViewModel">
|
||||||
|
<Border Classes="card"
|
||||||
|
Classes.good="{Binding IsGood}" Classes.caution="{Binding IsCaution}"
|
||||||
|
Classes.bad="{Binding IsBad}" Classes.unknown="{Binding IsUnknown}" Classes.info="{Binding IsInfo}">
|
||||||
|
<StackPanel Spacing="10">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
|
<Path Grid.Column="0" Classes="icon" Data="{Binding Icon}" VerticalAlignment="Center" />
|
||||||
|
<TextBlock Grid.Column="1" Classes="cardTitle" Text="{Binding Title}" />
|
||||||
|
<Border Grid.Column="2" Classes="chip">
|
||||||
|
<TextBlock Text="{Binding OutcomeFormatted}" />
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
<ProgressBar Classes="thin" IsIndeterminate="True" IsVisible="{Binding ShowRunning}" />
|
||||||
|
<TextBlock Classes="summary" Text="{Binding Summary}" IsVisible="{Binding ShowSettled}" />
|
||||||
|
<ItemsControl ItemsSource="{Binding Facts}" IsVisible="{Binding ShowSettled}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:FactViewModel">
|
||||||
|
<Grid ColumnDefinitions="2*,3*" Margin="0,3">
|
||||||
|
<TextBlock Grid.Column="0" Classes="factLabel" Text="{Binding Label}" />
|
||||||
|
<StackPanel Grid.Column="1">
|
||||||
|
<SelectableTextBlock Classes="factValue" Text="{Binding Value}" IsVisible="{Binding ShowValue}" />
|
||||||
|
<Button Classes="link" Content="{Binding Source}" IsVisible="{Binding ShowSource}"
|
||||||
|
AutomationProperties.AutomationId="{Binding AutomationId}"
|
||||||
|
Command="{Binding OpenCommand}" />
|
||||||
|
<Button Classes="link" Content="{Binding Value}" IsVisible="{Binding ShowPlainLink}"
|
||||||
|
AutomationProperties.AutomationId="{Binding AutomationId}"
|
||||||
|
Command="{Binding OpenCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
<Expander Classes="explain" Header="{s:Tr WhatIsThis}" IsExpanded="{Binding IsExpanded}"
|
||||||
|
AutomationProperties.AutomationId="{Binding ExplainAutomationId}">
|
||||||
|
<TextBlock Classes="body" FontSize="14" Text="{Binding Explain}" />
|
||||||
|
</Expander>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
|
<Border Classes="scrim" IsVisible="{Binding ShowContacts}">
|
||||||
|
<Border Classes="sheet">
|
||||||
|
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
|
||||||
|
<StackPanel Spacing="14">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Classes="section" Margin="0" Text="{s:Tr Contacts}" VerticalAlignment="Center" />
|
||||||
|
<Button Grid.Column="1" Classes="quiet" Content="{s:Tr Close}"
|
||||||
|
AutomationProperties.AutomationId="contacts-close"
|
||||||
|
Command="{Binding ToggleContactsCommand}" />
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Classes="body" Text="{s:Tr ContactsIntro}" />
|
||||||
|
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto">
|
||||||
|
<Path Grid.Row="0" Classes="icon" Data="{StaticResource IconServer}" VerticalAlignment="Top" Margin="0,0,12,12" />
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="1" Classes="body" Text="{s:Tr ContactServer}" Margin="0,0,0,12" />
|
||||||
|
<Path Grid.Row="1" Classes="icon" Data="{StaticResource IconRouter}" VerticalAlignment="Top" Margin="0,0,12,12" />
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="1" Classes="body" Text="{s:Tr ContactRouter}" Margin="0,0,0,12" />
|
||||||
|
<Path Grid.Row="2" Classes="icon" Data="{StaticResource IconRoute}" VerticalAlignment="Top" Margin="0,0,12,12" />
|
||||||
|
<TextBlock Grid.Row="2" Grid.Column="1" Classes="body" Text="{s:Tr ContactRoute}" Margin="0,0,0,12" />
|
||||||
|
<Path Grid.Row="3" Classes="icon" Data="{StaticResource IconGlobe}" VerticalAlignment="Top" Margin="0,0,12,12" />
|
||||||
|
<TextBlock Grid.Row="3" Grid.Column="1" Classes="body" Text="{s:Tr ContactDns}" Margin="0,0,0,12" />
|
||||||
|
<Path Grid.Row="4" Classes="icon" Data="{StaticResource IconArrowUpRight}" VerticalAlignment="Top" Margin="0,0,12,0" />
|
||||||
|
<TextBlock Grid.Row="4" Grid.Column="1" Classes="body" Text="{s:Tr ContactLinks}" />
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Classes="body" Text="{s:Tr PrivacyLine}" />
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||||
|
<Button Classes="quiet" Content="{s:Tr ClearHistory}"
|
||||||
|
AutomationProperties.AutomationId="contacts-clear-history"
|
||||||
|
Command="{Binding ClearHistoryCommand}" />
|
||||||
|
<TextBlock Classes="small" Text="{s:Tr HistoryCleared}" VerticalAlignment="Center" IsVisible="{Binding HistoryCleared}" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Classes="small" Text="{s:Tr AboutLine}" />
|
||||||
|
<Button Classes="link" Content="{s:Tr SourceCode}"
|
||||||
|
AutomationProperties.AutomationId="contacts-source"
|
||||||
|
Command="{Binding OpenSourceCommand}" />
|
||||||
|
<TextBlock Classes="small" Text="{Binding MapFormatted}" />
|
||||||
|
<TextBlock Classes="small" Text="{s:Tr IconsCredit}" />
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
|
|
||||||
@@ -12,10 +13,15 @@ public partial class MainView : UserControl
|
|||||||
protected override void OnLoaded(RoutedEventArgs e)
|
protected override void OnLoaded(RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
base.OnLoaded(e);
|
base.OnLoaded(e);
|
||||||
if (DataContext is MainViewModel model)
|
if (DataContext is not MainViewModel model) return;
|
||||||
{
|
|
||||||
model.Launcher = TopLevel.GetTopLevel(this)?.Launcher;
|
model.Launcher = TopLevel.GetTopLevel(this)?.Launcher;
|
||||||
model.RefreshCommand.Execute(null);
|
model.AvailableWidth = Bounds.Width;
|
||||||
}
|
model.StartCommand.Execute(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnSizeChanged(SizeChangedEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnSizeChanged(e);
|
||||||
|
if (DataContext is MainViewModel model) model.AvailableWidth = e.NewSize.Width;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
using MeWeb.Core.History;
|
||||||
|
using MeWeb.Core.Knowledge;
|
||||||
|
using MeWeb.Core.Net;
|
||||||
|
using MeWeb.Core.Router;
|
||||||
|
|
||||||
|
namespace MeWeb.Core.Checks;
|
||||||
|
|
||||||
|
public class RunReport
|
||||||
|
{
|
||||||
|
public DateTimeOffset StartedAt { get; set; }
|
||||||
|
public DeviceKind Device { get; set; }
|
||||||
|
public OnlineReport Online { get; set; }
|
||||||
|
public LocalSnapshot Local { get; set; }
|
||||||
|
public string PublicV4 { get; set; }
|
||||||
|
public string PublicV6 { get; set; }
|
||||||
|
public AsnEntry NetworkV4 { get; set; }
|
||||||
|
public AsnEntry NetworkV6 { get; set; }
|
||||||
|
public ProviderProfile Provider { get; set; }
|
||||||
|
public RouterReport Router { get; set; }
|
||||||
|
public List<Hop> Hops { get; set; } = [];
|
||||||
|
public NatKind Nat { get; set; }
|
||||||
|
public HistorySummary History { get; set; }
|
||||||
|
public List<Finding> Findings { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CheckRunner(IPlatform platform, ServerProbe server, KnowledgeBase knowledge, HistoryStore history)
|
||||||
|
{
|
||||||
|
public async Task<RunReport> Run(Action<Finding, RunReport> report, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var run = new RunReport { StartedAt = DateTimeOffset.UtcNow, Device = platform.Device };
|
||||||
|
foreach (var id in Enum.GetValues<CheckId>()) report(Finding.Running(id), run);
|
||||||
|
|
||||||
|
void Settle(Finding finding)
|
||||||
|
{
|
||||||
|
lock (run.Findings) run.Findings.Add(finding);
|
||||||
|
report(finding, run);
|
||||||
|
}
|
||||||
|
|
||||||
|
Task Step(CheckId id, Func<Task> body) => Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await body();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
Settle(new Finding { Id = id, Outcome = Outcome.Unknown, Summary = "CheckFailed", Args = [ex.GetType().Name] });
|
||||||
|
}
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
var local = Task.Run(platform.ReadNetwork, ct);
|
||||||
|
var online = server.Online(ct);
|
||||||
|
var v4 = server.Echo(false, ct);
|
||||||
|
var v6 = server.Echo(true, ct);
|
||||||
|
var route = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
var target = await ServerProbe.ResolveV4(ct);
|
||||||
|
return target is null ? [] : await Route.Trace(target, ct);
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
var connection = Step(CheckId.Connection, async () =>
|
||||||
|
{
|
||||||
|
run.Local = await local;
|
||||||
|
Settle(Verdicts.Connection(run.Local));
|
||||||
|
});
|
||||||
|
|
||||||
|
var onlineDone = Step(CheckId.Online, async () =>
|
||||||
|
{
|
||||||
|
run.Online = await online;
|
||||||
|
await connection;
|
||||||
|
Settle(Verdicts.Online(run.Online, run.Local));
|
||||||
|
});
|
||||||
|
|
||||||
|
var addresses = Step(CheckId.Addresses, async () =>
|
||||||
|
{
|
||||||
|
run.PublicV4 = await v4;
|
||||||
|
run.PublicV6 = await v6;
|
||||||
|
await connection;
|
||||||
|
Settle(Verdicts.Addresses(run.PublicV4, run.PublicV6, run.Local));
|
||||||
|
});
|
||||||
|
|
||||||
|
var provider = Step(CheckId.Provider, async () =>
|
||||||
|
{
|
||||||
|
await addresses;
|
||||||
|
var table = knowledge.Asn;
|
||||||
|
run.NetworkV4 = table?.Find(Addresses.Parse(run.PublicV4));
|
||||||
|
run.NetworkV6 = table?.Find(Addresses.Parse(run.PublicV6));
|
||||||
|
run.Provider = knowledge.ProviderFor((run.NetworkV4 ?? run.NetworkV6)?.Number ?? 0);
|
||||||
|
Settle(Verdicts.Provider(knowledge, run.NetworkV4, run.NetworkV6, run.PublicV4 is not null || run.PublicV6 is not null));
|
||||||
|
});
|
||||||
|
|
||||||
|
var router = Step(CheckId.Router, async () =>
|
||||||
|
{
|
||||||
|
await connection;
|
||||||
|
run.Router = await Gateway.Inspect(platform, run.Local.GatewayV4, ct);
|
||||||
|
Settle(Verdicts.Router(run.Router, run.Local));
|
||||||
|
});
|
||||||
|
|
||||||
|
var nat = Step(CheckId.Nat, async () =>
|
||||||
|
{
|
||||||
|
await Task.WhenAll(router, addresses);
|
||||||
|
run.Hops = await route;
|
||||||
|
var (finding, kind) = Verdicts.Nat(run.Local, run.Router, run.PublicV4, run.PublicV6, run.Hops);
|
||||||
|
run.Nat = kind;
|
||||||
|
Settle(finding);
|
||||||
|
});
|
||||||
|
|
||||||
|
await Step(CheckId.History, async () =>
|
||||||
|
{
|
||||||
|
await Task.WhenAll(onlineDone, provider, nat);
|
||||||
|
var number = (run.NetworkV4 ?? run.NetworkV6)?.Number ?? 0;
|
||||||
|
var network = $"{number}/{run.Local?.GatewayV4 ?? run.Local?.Active?.Kind.ToString() ?? "none"}";
|
||||||
|
run.History = history.Append(new HistoryEntry
|
||||||
|
{
|
||||||
|
At = run.StartedAt,
|
||||||
|
Network = network,
|
||||||
|
PublicV4 = run.PublicV4,
|
||||||
|
PublicV6 = run.PublicV6,
|
||||||
|
Asn = number,
|
||||||
|
Nat = run.Nat.ToString(),
|
||||||
|
});
|
||||||
|
Settle(Verdicts.History(run.History));
|
||||||
|
});
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
namespace MeWeb.Core.Checks;
|
||||||
|
|
||||||
|
public enum CheckId
|
||||||
|
{
|
||||||
|
Online,
|
||||||
|
Connection,
|
||||||
|
Addresses,
|
||||||
|
Provider,
|
||||||
|
Router,
|
||||||
|
Nat,
|
||||||
|
History,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Outcome
|
||||||
|
{
|
||||||
|
Waiting,
|
||||||
|
Running,
|
||||||
|
Good,
|
||||||
|
Caution,
|
||||||
|
Bad,
|
||||||
|
Unknown,
|
||||||
|
Info,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum NatKind
|
||||||
|
{
|
||||||
|
Unknown,
|
||||||
|
Direct,
|
||||||
|
HomeRouter,
|
||||||
|
DoubleNat,
|
||||||
|
Shared,
|
||||||
|
Translated,
|
||||||
|
NoIpv4,
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Fact
|
||||||
|
{
|
||||||
|
public string Label { get; set; }
|
||||||
|
public string Text { get; set; }
|
||||||
|
public string Key { get; set; }
|
||||||
|
public List<string> Args { get; set; } = [];
|
||||||
|
public decimal? Amount { get; set; }
|
||||||
|
public string Currency { get; set; }
|
||||||
|
public string Link { get; set; }
|
||||||
|
public string Basis { get; set; }
|
||||||
|
public string Date { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Finding
|
||||||
|
{
|
||||||
|
public CheckId Id { get; set; }
|
||||||
|
public Outcome Outcome { get; set; }
|
||||||
|
public string Summary { get; set; }
|
||||||
|
public List<string> Args { get; set; } = [];
|
||||||
|
public List<Fact> Facts { get; set; } = [];
|
||||||
|
|
||||||
|
public static Finding Running(CheckId id) => new() { Id = id, Outcome = Outcome.Running };
|
||||||
|
|
||||||
|
public Finding With(string label, string text)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(text)) Facts.Add(new Fact { Label = label, Text = text });
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Finding WithKey(string label, string key, params string[] args)
|
||||||
|
{
|
||||||
|
Facts.Add(new Fact { Label = label, Key = key, Args = [.. args] });
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
|
||||||
|
using MeWeb.Shared;
|
||||||
|
|
||||||
|
namespace MeWeb.Core.Checks;
|
||||||
|
|
||||||
|
public enum OnlineState
|
||||||
|
{
|
||||||
|
Online,
|
||||||
|
Captive,
|
||||||
|
Rewritten,
|
||||||
|
NoNames,
|
||||||
|
Offline,
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OnlineReport
|
||||||
|
{
|
||||||
|
public OnlineState State { get; set; }
|
||||||
|
public ViewInfo Server { get; set; }
|
||||||
|
public string Portal { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ServerProbe(HttpClient https, HttpClient plain)
|
||||||
|
{
|
||||||
|
static readonly TimeSpan Patience = TimeSpan.FromSeconds(6);
|
||||||
|
|
||||||
|
public async Task<OnlineReport> Online(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var names = Resolves(Endpoints.Host, ct);
|
||||||
|
var info = Info(ct);
|
||||||
|
var web = Plain(ct);
|
||||||
|
await Task.WhenAll(names, info, web);
|
||||||
|
var (plainClean, portal) = web.Result;
|
||||||
|
|
||||||
|
if (info.Result is not null)
|
||||||
|
return new OnlineReport { State = plainClean is false ? OnlineState.Rewritten : OnlineState.Online, Server = info.Result, Portal = portal };
|
||||||
|
if (plainClean is false) return new OnlineReport { State = OnlineState.Captive, Portal = portal };
|
||||||
|
return new OnlineReport { State = names.Result ? OnlineState.Offline : OnlineState.NoNames };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> Echo(bool v6, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
timeout.CancelAfter(Patience);
|
||||||
|
var origin = v6 ? Endpoints.BaseV6 : Endpoints.BaseV4;
|
||||||
|
var answer = await https.GetFromJsonAsync(origin + Endpoints.Server.GET_Ip, MeWebJsonContext.Default.ViewIp, timeout.Token);
|
||||||
|
return answer?.Address;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or System.Text.Json.JsonException && !ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<IPAddress> ResolveV4(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var addresses = await Dns.GetHostAddressesAsync(Endpoints.HostV4, AddressFamily.InterNetwork, ct);
|
||||||
|
return addresses.FirstOrDefault();
|
||||||
|
}
|
||||||
|
catch (SocketException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<bool> Resolves(string host, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return (await Dns.GetHostAddressesAsync(host, ct)).Length > 0;
|
||||||
|
}
|
||||||
|
catch (SocketException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<ViewInfo> Info(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
timeout.CancelAfter(Patience);
|
||||||
|
return await https.GetFromJsonAsync(Endpoints.Base + Endpoints.Server.GET_Info, MeWebJsonContext.Default.ViewInfo, timeout.Token);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or System.Text.Json.JsonException && !ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<(bool? Clean, string Portal)> Plain(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
timeout.CancelAfter(Patience);
|
||||||
|
using var response = await plain.GetAsync($"http://{Endpoints.Host}{Endpoints.Server.GET_Info}", HttpCompletionOption.ResponseHeadersRead, timeout.Token);
|
||||||
|
var location = response.Headers.Location;
|
||||||
|
if ((int)response.StatusCode is >= 300 and < 400 && location is not null)
|
||||||
|
{
|
||||||
|
var target = location.IsAbsoluteUri ? location : new Uri(new Uri($"http://{Endpoints.Host}"), location);
|
||||||
|
var clean = target.Scheme == Uri.UriSchemeHttps && string.Equals(target.Host, Endpoints.Host, StringComparison.OrdinalIgnoreCase);
|
||||||
|
return (clean, clean ? null : target.Host);
|
||||||
|
}
|
||||||
|
if (response.IsSuccessStatusCode && response.Content.Headers.ContentType?.MediaType == "application/json") return (true, null);
|
||||||
|
return (false, null);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return (null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
using MeWeb.Core.History;
|
||||||
|
using MeWeb.Core.Knowledge;
|
||||||
|
using MeWeb.Core.Net;
|
||||||
|
using MeWeb.Core.Router;
|
||||||
|
|
||||||
|
namespace MeWeb.Core.Checks;
|
||||||
|
|
||||||
|
public static class Verdicts
|
||||||
|
{
|
||||||
|
public static Finding Online(OnlineReport report, LocalSnapshot local)
|
||||||
|
{
|
||||||
|
var finding = new Finding { Id = CheckId.Online };
|
||||||
|
switch (report.State)
|
||||||
|
{
|
||||||
|
case OnlineState.Online:
|
||||||
|
finding.Outcome = Outcome.Good;
|
||||||
|
finding.Summary = "OnlineGood";
|
||||||
|
break;
|
||||||
|
case OnlineState.Rewritten:
|
||||||
|
finding.Outcome = Outcome.Caution;
|
||||||
|
finding.Summary = "OnlineRewritten";
|
||||||
|
finding.With("FactRedirectedTo", report.Portal);
|
||||||
|
break;
|
||||||
|
case OnlineState.Captive:
|
||||||
|
finding.Outcome = Outcome.Caution;
|
||||||
|
finding.Summary = "OnlineCaptive";
|
||||||
|
finding.With("FactRedirectedTo", report.Portal);
|
||||||
|
break;
|
||||||
|
case OnlineState.NoNames when local.Active is not null:
|
||||||
|
finding.Outcome = Outcome.Bad;
|
||||||
|
finding.Summary = "OnlineNoNames";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
finding.Outcome = Outcome.Bad;
|
||||||
|
finding.Summary = local.Active is null ? "OnlineNoNetwork" : "OnlineOffline";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return finding;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Finding Connection(LocalSnapshot local)
|
||||||
|
{
|
||||||
|
var active = local.Active;
|
||||||
|
if (active is null) return new Finding { Id = CheckId.Connection, Outcome = Outcome.Bad, Summary = "ConnectionNone" };
|
||||||
|
var finding = new Finding
|
||||||
|
{
|
||||||
|
Id = CheckId.Connection,
|
||||||
|
Outcome = Outcome.Info,
|
||||||
|
Summary = active.Kind switch
|
||||||
|
{
|
||||||
|
LinkKind.Wifi => "ConnectionWifi",
|
||||||
|
LinkKind.Cable => "ConnectionCable",
|
||||||
|
LinkKind.Mobile => "ConnectionMobile",
|
||||||
|
_ => "ConnectionOther",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
finding.With("FactAdapter", active.Name);
|
||||||
|
if (active.SpeedMbps > 0) finding.WithKey("FactLinkSpeed", "ValueMbps", active.SpeedMbps.ToString(CultureInfo.InvariantCulture));
|
||||||
|
finding.With("FactLocalAddress", local.LocalV4);
|
||||||
|
var v6 = local.GlobalV6;
|
||||||
|
if (v6.Count > 0) finding.With("FactLocalIpv6", string.Join("\n", v6));
|
||||||
|
finding.With("FactGateway", local.GatewayV4 ?? active.Gateways.FirstOrDefault());
|
||||||
|
if (local.Dns.Count > 0) finding.With("FactDns", string.Join(", ", local.Dns.Take(3)));
|
||||||
|
return finding;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Finding Addresses(string v4, string v6, LocalSnapshot local)
|
||||||
|
{
|
||||||
|
var finding = new Finding { Id = CheckId.Addresses };
|
||||||
|
(finding.Outcome, finding.Summary) = (v4, v6) switch
|
||||||
|
{
|
||||||
|
(not null, not null) => (Outcome.Good, "AddressesBoth"),
|
||||||
|
(not null, null) => (Outcome.Caution, "AddressesV4Only"),
|
||||||
|
(null, not null) => (Outcome.Caution, "AddressesV6Only"),
|
||||||
|
_ => (Outcome.Unknown, "AddressesNone"),
|
||||||
|
};
|
||||||
|
if (v4 is not null)
|
||||||
|
{
|
||||||
|
finding.With("FactPublicV4", v4);
|
||||||
|
var own = local.Active?.Addresses.Any(a => Net.Addresses.SameAddress(a, v4)) == true;
|
||||||
|
finding.WithKey("FactWhoHasIt", own ? "ValueOnThisDevice" : "ValueSharedAtHome");
|
||||||
|
}
|
||||||
|
if (v6 is not null)
|
||||||
|
{
|
||||||
|
finding.With("FactPublicV6", v6);
|
||||||
|
var own = local.Active?.Addresses.Any(a => Net.Addresses.SameAddress(a, v6)) == true;
|
||||||
|
finding.WithKey("FactWhoHasIpv6", own ? "ValueOnThisDevice" : "ValueIpv6NotThisDevice");
|
||||||
|
}
|
||||||
|
return finding;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Finding Provider(KnowledgeBase knowledge, AsnEntry v4, AsnEntry v6, bool anyAddress)
|
||||||
|
{
|
||||||
|
var finding = new Finding { Id = CheckId.Provider };
|
||||||
|
if (!anyAddress)
|
||||||
|
{
|
||||||
|
finding.Outcome = Outcome.Unknown;
|
||||||
|
finding.Summary = "ProviderNoAddress";
|
||||||
|
return finding;
|
||||||
|
}
|
||||||
|
if (!knowledge.HasAsnTable)
|
||||||
|
{
|
||||||
|
finding.Outcome = Outcome.Unknown;
|
||||||
|
finding.Summary = "ProviderNoMap";
|
||||||
|
return finding;
|
||||||
|
}
|
||||||
|
var main = v4 ?? v6;
|
||||||
|
if (main is null)
|
||||||
|
{
|
||||||
|
finding.Outcome = Outcome.Unknown;
|
||||||
|
finding.Summary = "ProviderNotFound";
|
||||||
|
return finding;
|
||||||
|
}
|
||||||
|
var profile = knowledge.ProviderFor(main.Number);
|
||||||
|
finding.Outcome = Outcome.Info;
|
||||||
|
finding.Summary = profile is null ? "ProviderUnlisted" : "ProviderListed";
|
||||||
|
finding.Args = [profile?.Name ?? Display(main)];
|
||||||
|
finding.With("FactNetwork", Describe(main));
|
||||||
|
if (v6 is not null && v4 is not null && v6.Number != v4.Number) finding.With("FactNetworkIpv6", Describe(v6));
|
||||||
|
if (profile is null) return finding;
|
||||||
|
AddFact(finding, "FactProviderIpv4", "ProviderIpv4", profile.Ipv4);
|
||||||
|
AddFact(finding, "FactProviderPublic", "ProviderPublic", profile.PublicAddress);
|
||||||
|
AddFact(finding, "FactProviderIpv6", "ProviderIpv6", profile.Ipv6);
|
||||||
|
AddFact(finding, "FactProviderPort25", "ProviderPort25", profile.Port25);
|
||||||
|
if (profile.Sheet is not null) finding.Facts.Add(new Fact { Label = "FactProviderSheet", Key = "ValueOpenSheet", Link = profile.Sheet });
|
||||||
|
return finding;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Finding Router(RouterReport router, LocalSnapshot local)
|
||||||
|
{
|
||||||
|
var finding = new Finding { Id = CheckId.Router };
|
||||||
|
if (router.Gateway is null)
|
||||||
|
{
|
||||||
|
finding.Outcome = Outcome.Info;
|
||||||
|
finding.Summary = local.Active?.Kind == LinkKind.Mobile ? "RouterMobile" : "RouterNone";
|
||||||
|
return finding;
|
||||||
|
}
|
||||||
|
(finding.Outcome, finding.Summary) = router switch
|
||||||
|
{
|
||||||
|
{ AnyMapping: true } => (Outcome.Good, "RouterOpens"),
|
||||||
|
{ AnyAnswer: true } => (Outcome.Caution, "RouterRefuses"),
|
||||||
|
_ => (Outcome.Caution, "RouterSilent"),
|
||||||
|
};
|
||||||
|
finding.With("FactRouter", Join(router.Make, router.Label));
|
||||||
|
finding.With("FactGateway", router.Gateway);
|
||||||
|
finding.WithKey("FactUpnp", AnswerKey(router.Upnp), router.Upnp == Answer.Refused ? router.UpnpError.ToString(CultureInfo.InvariantCulture) : router.UpnpVersion.ToString(CultureInfo.InvariantCulture));
|
||||||
|
finding.WithKey("FactNatPmp", AnswerKey(router.NatPmp), router.NatPmpResult.ToString(CultureInfo.InvariantCulture));
|
||||||
|
finding.WithKey("FactPcp", AnswerKey(router.Pcp), router.PcpResult.ToString(CultureInfo.InvariantCulture));
|
||||||
|
finding.With("FactRouterWan", router.WanAddress);
|
||||||
|
return finding;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static (Finding Finding, NatKind Kind) Nat(LocalSnapshot local, RouterReport router, string v4, string v6, List<Hop> hops)
|
||||||
|
{
|
||||||
|
var kind = NatOf(local, router, v4);
|
||||||
|
var finding = new Finding { Id = CheckId.Nat };
|
||||||
|
(finding.Outcome, finding.Summary) = kind switch
|
||||||
|
{
|
||||||
|
NatKind.Direct => (Outcome.Good, "NatDirect"),
|
||||||
|
NatKind.HomeRouter => (Outcome.Good, "NatHomeRouter"),
|
||||||
|
NatKind.DoubleNat => (Outcome.Caution, "NatDouble"),
|
||||||
|
NatKind.Shared => (Outcome.Bad, "NatShared"),
|
||||||
|
NatKind.Translated => (Outcome.Bad, "NatTranslated"),
|
||||||
|
NatKind.NoIpv4 => (Outcome.Info, "NatNoIpv4"),
|
||||||
|
_ => (Outcome.Unknown, "NatUnknown"),
|
||||||
|
};
|
||||||
|
finding.With("FactRouterWan", router?.WanAddress);
|
||||||
|
finding.With("FactPublicV4", v4);
|
||||||
|
var before = Route.BeforeInternet(hops);
|
||||||
|
if (hops.Count > 0) finding.With("FactRoute", string.Join(" → ", before.Select(h => h.Address ?? "*").Append(hops.FirstOrDefault(h => h.Kind == AddressKind.Public)?.Address ?? "…")));
|
||||||
|
if (kind == NatKind.Unknown && before.Any(h => h.Kind == AddressKind.Shared)) finding.WithKey("FactHint", "HintSharedHop");
|
||||||
|
|
||||||
|
var v6Direct = v6 is not null && local.Active?.Addresses.Any(a => Net.Addresses.SameAddress(a, v6)) == true;
|
||||||
|
if (v6 is not null) finding.WithKey("FactIpv6", v6Direct ? "ValueIpv6Direct" : "ValueIpv6Translated");
|
||||||
|
if (v6Direct && finding.Outcome is Outcome.Bad or Outcome.Unknown && kind != NatKind.NoIpv4)
|
||||||
|
{
|
||||||
|
finding.Outcome = Outcome.Caution;
|
||||||
|
finding.WithKey("FactWayOut", "ValueIpv6Way");
|
||||||
|
}
|
||||||
|
return (finding, kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static NatKind NatOf(LocalSnapshot local, RouterReport router, string v4)
|
||||||
|
{
|
||||||
|
if (v4 is null) return NatKind.NoIpv4;
|
||||||
|
if (local.Active?.Addresses.Any(a => Net.Addresses.SameAddress(a, v4)) == true) return NatKind.Direct;
|
||||||
|
var localV4 = Net.Addresses.Parse(local.LocalV4);
|
||||||
|
if (router?.Gateway is null && localV4 is not null && Net.Addresses.Classify(localV4) is AddressKind.Shared or AddressKind.Private && local.Active?.Kind == LinkKind.Mobile)
|
||||||
|
return NatKind.Shared;
|
||||||
|
var wan = Net.Addresses.Parse(router?.WanAddress);
|
||||||
|
if (wan is null) return NatKind.Unknown;
|
||||||
|
if (Net.Addresses.SameAddress(router.WanAddress, v4)) return NatKind.HomeRouter;
|
||||||
|
return Net.Addresses.Classify(wan) switch
|
||||||
|
{
|
||||||
|
AddressKind.Shared => NatKind.Shared,
|
||||||
|
AddressKind.Private => NatKind.DoubleNat,
|
||||||
|
AddressKind.Public => NatKind.Translated,
|
||||||
|
_ => NatKind.Unknown,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Finding History(HistorySummary summary)
|
||||||
|
{
|
||||||
|
var finding = new Finding { Id = CheckId.History, Outcome = Outcome.Info };
|
||||||
|
var since = summary.Since.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||||
|
if (summary.Checks <= 1)
|
||||||
|
{
|
||||||
|
finding.Summary = "HistoryFirst";
|
||||||
|
return finding;
|
||||||
|
}
|
||||||
|
finding.Summary = summary.Changes == 0 ? "HistoryStable" : "HistoryChanged";
|
||||||
|
finding.Args = [summary.Checks.ToString(CultureInfo.InvariantCulture), since, summary.Changes.ToString(CultureInfo.InvariantCulture)];
|
||||||
|
finding.WithKey("FactChecks", "ValueCount", summary.Checks.ToString(CultureInfo.InvariantCulture));
|
||||||
|
finding.Facts.Add(new Fact { Label = "FactSince", Date = since });
|
||||||
|
finding.WithKey("FactAddressesSeen", "ValueCount", summary.AddressesSeen.ToString(CultureInfo.InvariantCulture));
|
||||||
|
return finding;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void AddFact(Finding finding, string label, string prefix, ProviderFact fact)
|
||||||
|
{
|
||||||
|
if (fact?.Value is null) return;
|
||||||
|
finding.Facts.Add(new Fact
|
||||||
|
{
|
||||||
|
Label = label,
|
||||||
|
Key = prefix + Pascal(fact.Value),
|
||||||
|
Amount = fact.Price,
|
||||||
|
Currency = fact.Currency,
|
||||||
|
Link = fact.Source,
|
||||||
|
Basis = fact.Basis is null ? null : "Basis" + Pascal(fact.Basis),
|
||||||
|
Date = fact.Date,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static string AnswerKey(Answer answer) => answer switch
|
||||||
|
{
|
||||||
|
Answer.Answered => "ValueAnswered",
|
||||||
|
Answer.Refused => "ValueRefused",
|
||||||
|
_ => "ValueSilent",
|
||||||
|
};
|
||||||
|
|
||||||
|
static string Display(AsnEntry entry) => string.IsNullOrWhiteSpace(entry.Name) ? $"AS{entry.Number}" : entry.Name;
|
||||||
|
|
||||||
|
static string Describe(AsnEntry entry) => $"{Display(entry)} (AS{entry.Number}{(string.IsNullOrEmpty(entry.Country) || entry.Country == "None" ? "" : ", " + entry.Country)})";
|
||||||
|
|
||||||
|
static string Join(string make, string label)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(make)) return label;
|
||||||
|
if (string.IsNullOrWhiteSpace(label) || label.Contains(make.Split(' ')[0], StringComparison.OrdinalIgnoreCase)) return label ?? make;
|
||||||
|
return $"{make} {label}";
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Pascal(string value) => string.Concat(value.Split('-').Select(p => p.Length == 0 ? p : char.ToUpperInvariant(p[0]) + p[1..]));
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace MeWeb.Core.History;
|
||||||
|
|
||||||
|
public class HistoryEntry
|
||||||
|
{
|
||||||
|
public DateTimeOffset At { get; set; }
|
||||||
|
public string Network { get; set; }
|
||||||
|
public string PublicV4 { get; set; }
|
||||||
|
public string PublicV6 { get; set; }
|
||||||
|
public uint Asn { get; set; }
|
||||||
|
public string Nat { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class HistorySummary
|
||||||
|
{
|
||||||
|
public int Checks { get; set; }
|
||||||
|
public DateTimeOffset Since { get; set; }
|
||||||
|
public int AddressesSeen { get; set; }
|
||||||
|
public int Changes { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, WriteIndented = true)]
|
||||||
|
[JsonSerializable(typeof(List<HistoryEntry>))]
|
||||||
|
public partial class HistoryJsonContext : JsonSerializerContext
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public class HistoryStore(string folder)
|
||||||
|
{
|
||||||
|
const int Keep = 500;
|
||||||
|
readonly Lock gate = new();
|
||||||
|
|
||||||
|
string FilePath => Path.Combine(folder, "history.json");
|
||||||
|
|
||||||
|
public List<HistoryEntry> Read()
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(FilePath)) return [];
|
||||||
|
using var stream = File.OpenRead(FilePath);
|
||||||
|
return JsonSerializer.Deserialize(stream, HistoryJsonContext.Default.ListHistoryEntry) ?? [];
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public HistorySummary Append(HistoryEntry entry)
|
||||||
|
{
|
||||||
|
var entries = Read();
|
||||||
|
entries.Add(entry);
|
||||||
|
if (entries.Count > Keep) entries = entries[^Keep..];
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(folder);
|
||||||
|
var temporary = FilePath + ".tmp";
|
||||||
|
using (var stream = File.Create(temporary))
|
||||||
|
JsonSerializer.Serialize(stream, entries, HistoryJsonContext.Default.ListHistoryEntry);
|
||||||
|
File.Move(temporary, FilePath, overwrite: true);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Summarise(entries, entry.Network);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(FilePath);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HistorySummary Summarise(IEnumerable<HistoryEntry> entries, string network)
|
||||||
|
{
|
||||||
|
var same = entries.Where(e => e.Network == network).OrderBy(e => e.At).ToList();
|
||||||
|
var withAddress = same.Where(e => e.PublicV4 is not null).ToList();
|
||||||
|
var changes = withAddress.Zip(withAddress.Skip(1)).Count(p => p.First.PublicV4 != p.Second.PublicV4);
|
||||||
|
return new HistorySummary
|
||||||
|
{
|
||||||
|
Checks = same.Count,
|
||||||
|
Since = same.FirstOrDefault()?.At ?? DateTimeOffset.UtcNow,
|
||||||
|
AddressesSeen = withAddress.Select(e => e.PublicV4).Distinct().Count(),
|
||||||
|
Changes = changes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace MeWeb.Core.Knowledge;
|
||||||
|
|
||||||
|
public class AsnEntry
|
||||||
|
{
|
||||||
|
public uint Number { get; set; }
|
||||||
|
public string Country { get; set; }
|
||||||
|
public string Name { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AsnRange
|
||||||
|
{
|
||||||
|
public IPAddress Start { get; set; }
|
||||||
|
public IPAddress End { get; set; }
|
||||||
|
public uint Number { get; set; }
|
||||||
|
public string Country { get; set; }
|
||||||
|
public string Name { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AsnTable
|
||||||
|
{
|
||||||
|
const uint Magic = 0x314B574D;
|
||||||
|
|
||||||
|
AsnEntry[] systems;
|
||||||
|
uint[] v4Start;
|
||||||
|
int[] v4System;
|
||||||
|
UInt128[] v6Start;
|
||||||
|
int[] v6System;
|
||||||
|
|
||||||
|
public DateTimeOffset SourceDate { get; private set; }
|
||||||
|
|
||||||
|
public int Count => systems.Length;
|
||||||
|
|
||||||
|
public AsnEntry Find(IPAddress address)
|
||||||
|
{
|
||||||
|
if (address is null) return null;
|
||||||
|
if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4();
|
||||||
|
var index = address.AddressFamily == AddressFamily.InterNetwork
|
||||||
|
? Search(v4Start, BinaryPrimitives.ReadUInt32BigEndian(address.GetAddressBytes()), v4System)
|
||||||
|
: Search(v6Start, BinaryPrimitives.ReadUInt128BigEndian(address.GetAddressBytes()), v6System);
|
||||||
|
return index < 0 ? null : systems[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AsnTable Read(Stream compressed)
|
||||||
|
{
|
||||||
|
using var brotli = new BrotliStream(compressed, CompressionMode.Decompress);
|
||||||
|
using var reader = new BinaryReader(brotli, Encoding.UTF8);
|
||||||
|
if (reader.ReadUInt32() != Magic) throw new InvalidDataException("not a me-web? provider table");
|
||||||
|
var table = new AsnTable { SourceDate = DateTimeOffset.FromUnixTimeSeconds(reader.ReadInt64()) };
|
||||||
|
table.systems = new AsnEntry[reader.Read7BitEncodedInt()];
|
||||||
|
for (var i = 0; i < table.systems.Length; i++)
|
||||||
|
table.systems[i] = new AsnEntry { Number = (uint)reader.Read7BitEncodedInt64(), Country = reader.ReadString(), Name = reader.ReadString() };
|
||||||
|
|
||||||
|
var count = reader.Read7BitEncodedInt();
|
||||||
|
table.v4Start = new uint[count];
|
||||||
|
table.v4System = new int[count];
|
||||||
|
var previous4 = 0u;
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
previous4 += (uint)reader.Read7BitEncodedInt64();
|
||||||
|
table.v4Start[i] = previous4;
|
||||||
|
table.v4System[i] = reader.Read7BitEncodedInt() - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
count = reader.Read7BitEncodedInt();
|
||||||
|
table.v6Start = new UInt128[count];
|
||||||
|
table.v6System = new int[count];
|
||||||
|
var previous6 = UInt128.Zero;
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
previous6 += ReadBig(reader);
|
||||||
|
table.v6Start[i] = previous6;
|
||||||
|
table.v6System[i] = reader.Read7BitEncodedInt() - 1;
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Write(Stream output, DateTimeOffset sourceDate, IEnumerable<AsnRange> ranges)
|
||||||
|
{
|
||||||
|
var list = ranges.Where(r => r.Number != 0).ToList();
|
||||||
|
var systems = list.GroupBy(r => r.Number).OrderBy(g => g.Key)
|
||||||
|
.Select(g => new AsnEntry { Number = g.Key, Country = g.First().Country ?? "", Name = g.First().Name ?? "" }).ToList();
|
||||||
|
var index = systems.Select((s, i) => (s.Number, i)).ToDictionary(p => p.Number, p => p.i);
|
||||||
|
|
||||||
|
using var brotli = new BrotliStream(output, CompressionLevel.SmallestSize, leaveOpen: true);
|
||||||
|
using var writer = new BinaryWriter(brotli, Encoding.UTF8);
|
||||||
|
writer.Write(Magic);
|
||||||
|
writer.Write(sourceDate.ToUnixTimeSeconds());
|
||||||
|
writer.Write7BitEncodedInt(systems.Count);
|
||||||
|
foreach (var system in systems)
|
||||||
|
{
|
||||||
|
writer.Write7BitEncodedInt64(system.Number);
|
||||||
|
writer.Write(system.Country);
|
||||||
|
writer.Write(system.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
var v4 = Flatten(list.Where(r => r.Start.AddressFamily == AddressFamily.InterNetwork), a => BinaryPrimitives.ReadUInt32BigEndian(a.GetAddressBytes()), uint.MaxValue, index);
|
||||||
|
writer.Write7BitEncodedInt(v4.Count);
|
||||||
|
var previous4 = 0u;
|
||||||
|
foreach (var (start, system) in v4)
|
||||||
|
{
|
||||||
|
writer.Write7BitEncodedInt64((uint)start - previous4);
|
||||||
|
writer.Write7BitEncodedInt(system + 1);
|
||||||
|
previous4 = (uint)start;
|
||||||
|
}
|
||||||
|
|
||||||
|
var v6 = Flatten(list.Where(r => r.Start.AddressFamily == AddressFamily.InterNetworkV6), a => BinaryPrimitives.ReadUInt128BigEndian(a.GetAddressBytes()), UInt128.MaxValue, index);
|
||||||
|
writer.Write7BitEncodedInt(v6.Count);
|
||||||
|
var previous6 = UInt128.Zero;
|
||||||
|
foreach (var (start, system) in v6)
|
||||||
|
{
|
||||||
|
WriteBig(writer, start - previous6);
|
||||||
|
writer.Write7BitEncodedInt(system + 1);
|
||||||
|
previous6 = start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<(UInt128 Start, int System)> Flatten(IEnumerable<AsnRange> ranges, Func<IPAddress, UInt128> number, UInt128 max, Dictionary<uint, int> index)
|
||||||
|
{
|
||||||
|
var result = new List<(UInt128 Start, int System)>();
|
||||||
|
var nextFree = UInt128.Zero;
|
||||||
|
var lastSystem = -1;
|
||||||
|
var full = false;
|
||||||
|
foreach (var range in ranges.Select(r => (Start: number(r.Start), End: number(r.End), System: index[r.Number])).OrderBy(r => r.Start))
|
||||||
|
{
|
||||||
|
if (full || range.End < nextFree) continue;
|
||||||
|
var start = range.Start < nextFree ? nextFree : range.Start;
|
||||||
|
if (start > nextFree && lastSystem != -1)
|
||||||
|
{
|
||||||
|
result.Add((nextFree, -1));
|
||||||
|
lastSystem = -1;
|
||||||
|
}
|
||||||
|
if (range.System != lastSystem) result.Add((start, range.System));
|
||||||
|
lastSystem = range.System;
|
||||||
|
full = range.End == max;
|
||||||
|
nextFree = full ? max : range.End + 1;
|
||||||
|
}
|
||||||
|
if (lastSystem != -1 && !full) result.Add((nextFree, -1));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int Search<T>(T[] starts, T value, int[] systems) where T : IComparable<T>
|
||||||
|
{
|
||||||
|
var position = Array.BinarySearch(starts, value);
|
||||||
|
if (position < 0) position = ~position - 1;
|
||||||
|
return position < 0 ? -1 : systems[position];
|
||||||
|
}
|
||||||
|
|
||||||
|
static void WriteBig(BinaryWriter writer, UInt128 value)
|
||||||
|
{
|
||||||
|
do
|
||||||
|
{
|
||||||
|
var low = (byte)(value & 0x7F);
|
||||||
|
value >>= 7;
|
||||||
|
writer.Write(value == UInt128.Zero ? low : (byte)(low | 0x80));
|
||||||
|
}
|
||||||
|
while (value != UInt128.Zero);
|
||||||
|
}
|
||||||
|
|
||||||
|
static UInt128 ReadBig(BinaryReader reader)
|
||||||
|
{
|
||||||
|
var value = UInt128.Zero;
|
||||||
|
var shift = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var b = reader.ReadByte();
|
||||||
|
value |= (UInt128)(b & 0x7F) << shift;
|
||||||
|
if ((b & 0x80) == 0) return value;
|
||||||
|
shift += 7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace MeWeb.Core.Knowledge;
|
||||||
|
|
||||||
|
public class ProviderFact
|
||||||
|
{
|
||||||
|
public string Value { get; set; }
|
||||||
|
public decimal? Price { get; set; }
|
||||||
|
public string Currency { get; set; }
|
||||||
|
public string Basis { get; set; }
|
||||||
|
public string Source { get; set; }
|
||||||
|
public string Date { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ProviderProfile
|
||||||
|
{
|
||||||
|
public string Name { get; set; }
|
||||||
|
public string Country { get; set; }
|
||||||
|
public List<uint> Asns { get; set; } = [];
|
||||||
|
public string Sheet { get; set; }
|
||||||
|
public ProviderFact Ipv4 { get; set; }
|
||||||
|
public ProviderFact PublicAddress { get; set; }
|
||||||
|
public ProviderFact Ipv6 { get; set; }
|
||||||
|
public ProviderFact Port25 { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
|
||||||
|
[JsonSerializable(typeof(ProviderProfile))]
|
||||||
|
public partial class KnowledgeJsonContext : JsonSerializerContext
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class KnowledgeBase
|
||||||
|
{
|
||||||
|
readonly Lazy<AsnTable> asn = new(LoadAsn);
|
||||||
|
readonly Lazy<List<ProviderProfile>> providers = new(LoadProviders);
|
||||||
|
|
||||||
|
public bool HasAsnTable => typeof(KnowledgeBase).Assembly.GetManifestResourceInfo("ip2asn.bin") is not null;
|
||||||
|
|
||||||
|
public AsnTable Asn => asn.Value;
|
||||||
|
|
||||||
|
public IReadOnlyList<ProviderProfile> Providers => providers.Value;
|
||||||
|
|
||||||
|
public ProviderProfile ProviderFor(uint number) => number == 0 ? null : Providers.FirstOrDefault(p => p.Asns.Contains(number));
|
||||||
|
|
||||||
|
static AsnTable LoadAsn()
|
||||||
|
{
|
||||||
|
using var stream = typeof(KnowledgeBase).Assembly.GetManifestResourceStream("ip2asn.bin");
|
||||||
|
return stream is null ? null : AsnTable.Read(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<ProviderProfile> LoadProviders()
|
||||||
|
{
|
||||||
|
var assembly = typeof(KnowledgeBase).Assembly;
|
||||||
|
var list = new List<ProviderProfile>();
|
||||||
|
foreach (var name in assembly.GetManifestResourceNames().Where(n => n.StartsWith("isp.", StringComparison.Ordinal)).Order(StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
using var stream = assembly.GetManifestResourceStream(name);
|
||||||
|
var profile = JsonSerializer.Deserialize(stream, KnowledgeJsonContext.Default.ProviderProfile);
|
||||||
|
if (profile is not null) list.Add(profile);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<RootNamespace>MeWeb.Core</RootNamespace>
|
||||||
|
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
|
||||||
|
<KnowledgeFile>$(MSBuildThisFileDirectory)..\..\artifacts\knowledge\ip2asn.bin</KnowledgeFile>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="..\..\data\isps\*.json" LogicalName="isp.%(Filename).json" />
|
||||||
|
<EmbeddedResource Include="$(KnowledgeFile)" LogicalName="ip2asn.bin" Condition="Exists('$(KnowledgeFile)')" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MeWeb.Shared\MeWeb.Shared.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
|
||||||
|
namespace MeWeb.Core.Net;
|
||||||
|
|
||||||
|
public enum AddressKind
|
||||||
|
{
|
||||||
|
Public,
|
||||||
|
Private,
|
||||||
|
Shared,
|
||||||
|
Loopback,
|
||||||
|
LinkLocal,
|
||||||
|
UniqueLocal,
|
||||||
|
Reserved,
|
||||||
|
Unspecified,
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Addresses
|
||||||
|
{
|
||||||
|
public static AddressKind Classify(IPAddress address)
|
||||||
|
{
|
||||||
|
if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4();
|
||||||
|
if (address.AddressFamily == AddressFamily.InterNetwork) return ClassifyV4(address.GetAddressBytes());
|
||||||
|
if (address.Equals(IPAddress.IPv6Loopback)) return AddressKind.Loopback;
|
||||||
|
if (address.Equals(IPAddress.IPv6Any)) return AddressKind.Unspecified;
|
||||||
|
if (address.IsIPv6LinkLocal) return AddressKind.LinkLocal;
|
||||||
|
if (address.IsIPv6UniqueLocal) return AddressKind.UniqueLocal;
|
||||||
|
if (address.IsIPv6Multicast || address.IsIPv6SiteLocal) return AddressKind.Reserved;
|
||||||
|
var bytes = address.GetAddressBytes();
|
||||||
|
if (bytes is [0x20, 0x01, 0x0d, 0xb8, ..]) return AddressKind.Reserved;
|
||||||
|
if (bytes is [0x00, 0x64, 0xff, 0x9b, ..]) return AddressKind.Reserved;
|
||||||
|
return (bytes[0] & 0xe0) == 0x20 ? AddressKind.Public : AddressKind.Reserved;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsPublic(IPAddress address) => address is not null && Classify(address) == AddressKind.Public;
|
||||||
|
|
||||||
|
public static IPAddress Parse(string text) => IPAddress.TryParse(text, out var address) ? address : null;
|
||||||
|
|
||||||
|
public static bool SameAddress(string left, string right)
|
||||||
|
{
|
||||||
|
var a = Parse(left);
|
||||||
|
var b = Parse(right);
|
||||||
|
if (a is null || b is null) return false;
|
||||||
|
if (a.IsIPv4MappedToIPv6) a = a.MapToIPv4();
|
||||||
|
if (b.IsIPv4MappedToIPv6) b = b.MapToIPv4();
|
||||||
|
return a.Equals(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
static AddressKind ClassifyV4(byte[] b) => b switch
|
||||||
|
{
|
||||||
|
[0, ..] => AddressKind.Unspecified,
|
||||||
|
[10, ..] => AddressKind.Private,
|
||||||
|
[127, ..] => AddressKind.Loopback,
|
||||||
|
[169, 254, ..] => AddressKind.LinkLocal,
|
||||||
|
[172, >= 16 and <= 31, ..] => AddressKind.Private,
|
||||||
|
[192, 168, ..] => AddressKind.Private,
|
||||||
|
[100, >= 64 and <= 127, ..] => AddressKind.Shared,
|
||||||
|
[192, 0, 0 or 2, ..] => AddressKind.Reserved,
|
||||||
|
[198, 18 or 19, ..] => AddressKind.Reserved,
|
||||||
|
[198, 51, 100, ..] => AddressKind.Reserved,
|
||||||
|
[203, 0, 113, ..] => AddressKind.Reserved,
|
||||||
|
[>= 224, ..] => AddressKind.Reserved,
|
||||||
|
_ => AddressKind.Public,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.NetworkInformation;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
|
||||||
|
namespace MeWeb.Core.Net;
|
||||||
|
|
||||||
|
public enum LinkKind
|
||||||
|
{
|
||||||
|
Unknown,
|
||||||
|
Wifi,
|
||||||
|
Cable,
|
||||||
|
Mobile,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum DeviceKind
|
||||||
|
{
|
||||||
|
Computer,
|
||||||
|
Phone,
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LocalInterface
|
||||||
|
{
|
||||||
|
public string Name { get; set; }
|
||||||
|
public LinkKind Kind { get; set; }
|
||||||
|
public long SpeedMbps { get; set; }
|
||||||
|
public List<string> Addresses { get; set; } = [];
|
||||||
|
public List<string> Gateways { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LocalSnapshot
|
||||||
|
{
|
||||||
|
public List<LocalInterface> Interfaces { get; set; } = [];
|
||||||
|
public List<string> Dns { get; set; } = [];
|
||||||
|
|
||||||
|
public LocalInterface Active =>
|
||||||
|
Interfaces.FirstOrDefault(i => i.Gateways.Any(IsV4)) ??
|
||||||
|
Interfaces.FirstOrDefault(i => i.Gateways.Count > 0) ??
|
||||||
|
Interfaces.FirstOrDefault(i => i.Kind == LinkKind.Mobile) ??
|
||||||
|
Interfaces.FirstOrDefault();
|
||||||
|
|
||||||
|
public string GatewayV4 => Active?.Gateways.FirstOrDefault(IsV4);
|
||||||
|
|
||||||
|
public string LocalV4 => Active?.Addresses.FirstOrDefault(IsV4);
|
||||||
|
|
||||||
|
public List<string> GlobalV6 => Active?.Addresses.Where(a => !IsV4(a) && Net.Addresses.IsPublic(Net.Addresses.Parse(a))).ToList() ?? [];
|
||||||
|
|
||||||
|
static bool IsV4(string text) => Net.Addresses.Parse(text)?.AddressFamily == AddressFamily.InterNetwork;
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IPlatform
|
||||||
|
{
|
||||||
|
DeviceKind Device { get; }
|
||||||
|
|
||||||
|
LocalSnapshot ReadNetwork();
|
||||||
|
|
||||||
|
IDisposable AllowMulticast();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SystemPlatform : IPlatform
|
||||||
|
{
|
||||||
|
public virtual DeviceKind Device => DeviceKind.Computer;
|
||||||
|
|
||||||
|
public virtual IDisposable AllowMulticast() => new Nothing();
|
||||||
|
|
||||||
|
public virtual LocalSnapshot ReadNetwork()
|
||||||
|
{
|
||||||
|
var snapshot = new LocalSnapshot();
|
||||||
|
NetworkInterface[] adapters;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
adapters = NetworkInterface.GetAllNetworkInterfaces();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is NetworkInformationException or PlatformNotSupportedException)
|
||||||
|
{
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var adapter in adapters)
|
||||||
|
{
|
||||||
|
if (adapter.OperationalStatus != OperationalStatus.Up || adapter.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var properties = adapter.GetIPProperties();
|
||||||
|
var item = new LocalInterface
|
||||||
|
{
|
||||||
|
Name = adapter.Name,
|
||||||
|
Kind = KindOf(adapter),
|
||||||
|
SpeedMbps = SpeedOf(adapter),
|
||||||
|
Addresses = properties.UnicastAddresses.Select(u => Clean(u.Address)).Where(a => a is not null).ToList(),
|
||||||
|
Gateways = GatewaysOf(properties),
|
||||||
|
};
|
||||||
|
if (item.Addresses.Count > 0) snapshot.Interfaces.Add(item);
|
||||||
|
foreach (var dns in DnsOf(properties))
|
||||||
|
if (!snapshot.Dns.Contains(dns)) snapshot.Dns.Add(dns);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is NetworkInformationException or PlatformNotSupportedException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string Clean(IPAddress address)
|
||||||
|
{
|
||||||
|
var kind = Net.Addresses.Classify(address);
|
||||||
|
if (kind is AddressKind.Loopback or AddressKind.LinkLocal or AddressKind.Unspecified) return null;
|
||||||
|
if (address.AddressFamily == AddressFamily.InterNetworkV6) address = new IPAddress(address.GetAddressBytes());
|
||||||
|
return address.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<string> GatewaysOf(IPInterfaceProperties properties)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return properties.GatewayAddresses.Select(g => g.Address)
|
||||||
|
.Where(a => !a.Equals(IPAddress.Any) && !a.Equals(IPAddress.IPv6Any))
|
||||||
|
.OrderBy(a => a.AddressFamily == AddressFamily.InterNetwork ? 0 : 1)
|
||||||
|
.Select(a => a.IsIPv6LinkLocal ? new IPAddress(a.GetAddressBytes(), a.ScopeId).ToString() : a.ToString())
|
||||||
|
.Distinct().ToList();
|
||||||
|
}
|
||||||
|
catch (PlatformNotSupportedException)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static IEnumerable<string> DnsOf(IPInterfaceProperties properties)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return properties.DnsAddresses.Select(a => a.ToString()).ToList();
|
||||||
|
}
|
||||||
|
catch (PlatformNotSupportedException)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static LinkKind KindOf(NetworkInterface adapter)
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsLinux() && (Directory.Exists($"/sys/class/net/{adapter.Name}/wireless") || Directory.Exists($"/sys/class/net/{adapter.Name}/phy80211")))
|
||||||
|
return LinkKind.Wifi;
|
||||||
|
return adapter.NetworkInterfaceType switch
|
||||||
|
{
|
||||||
|
NetworkInterfaceType.Wireless80211 => LinkKind.Wifi,
|
||||||
|
NetworkInterfaceType.Ethernet or NetworkInterfaceType.GigabitEthernet or NetworkInterfaceType.FastEthernetT or NetworkInterfaceType.FastEthernetFx => LinkKind.Cable,
|
||||||
|
NetworkInterfaceType.Wwanpp or NetworkInterfaceType.Wwanpp2 or NetworkInterfaceType.Ppp => LinkKind.Mobile,
|
||||||
|
_ => LinkKind.Unknown,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static long SpeedOf(NetworkInterface adapter)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return adapter.Speed > 0 ? adapter.Speed / 1_000_000 : 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is PlatformNotSupportedException or NetworkInformationException)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class Nothing : IDisposable
|
||||||
|
{
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.NetworkInformation;
|
||||||
|
|
||||||
|
namespace MeWeb.Core.Net;
|
||||||
|
|
||||||
|
public class Hop
|
||||||
|
{
|
||||||
|
public int Distance { get; set; }
|
||||||
|
public string Address { get; set; }
|
||||||
|
public AddressKind Kind { get; set; }
|
||||||
|
public bool IsDestination { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Route
|
||||||
|
{
|
||||||
|
public const int MaxHops = 8;
|
||||||
|
|
||||||
|
public static async Task<List<Hop>> Trace(IPAddress target, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var replies = await Task.WhenAll(Enumerable.Range(1, MaxHops).Select(distance => Probe(target, distance, ct)));
|
||||||
|
if (replies.All(r => r is null)) return [];
|
||||||
|
var hops = new List<Hop>();
|
||||||
|
foreach (var hop in replies.Where(r => r is not null))
|
||||||
|
{
|
||||||
|
hops.Add(hop);
|
||||||
|
if (hop.IsDestination) break;
|
||||||
|
}
|
||||||
|
return hops;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<Hop> BeforeInternet(IEnumerable<Hop> hops)
|
||||||
|
{
|
||||||
|
var list = new List<Hop>();
|
||||||
|
foreach (var hop in hops)
|
||||||
|
{
|
||||||
|
if (hop.Address is not null && hop.Kind == AddressKind.Public) break;
|
||||||
|
list.Add(hop);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<Hop> Probe(IPAddress target, int distance, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var ping = new Ping();
|
||||||
|
var reply = await ping.SendPingAsync(target, TimeSpan.FromSeconds(2), [], new PingOptions(distance, false), ct);
|
||||||
|
var answered = reply.Status is IPStatus.TtlExpired or IPStatus.TimeExceeded or IPStatus.Success;
|
||||||
|
var address = answered && reply.Address is not null && !reply.Address.Equals(IPAddress.Any) ? reply.Address : null;
|
||||||
|
return new Hop
|
||||||
|
{
|
||||||
|
Distance = distance,
|
||||||
|
Address = address?.ToString(),
|
||||||
|
Kind = address is null ? AddressKind.Unspecified : Addresses.Classify(address),
|
||||||
|
IsDestination = reply.Status == IPStatus.Success,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is PingException or PlatformNotSupportedException or InvalidOperationException or NotSupportedException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Xml;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
|
||||||
|
using MeWeb.Core.Net;
|
||||||
|
|
||||||
|
namespace MeWeb.Core.Router;
|
||||||
|
|
||||||
|
public static class Gateway
|
||||||
|
{
|
||||||
|
const int PortMappingPort = 5351;
|
||||||
|
static readonly IPEndPoint Ssdp = new(IPAddress.Parse("239.255.255.250"), 1900);
|
||||||
|
static readonly string[] SearchTargets = ["urn:schemas-upnp-org:device:InternetGatewayDevice:2", "urn:schemas-upnp-org:device:InternetGatewayDevice:1"];
|
||||||
|
static readonly HttpClient Lan = new(new SocketsHttpHandler { UseProxy = false, AllowAutoRedirect = false, ConnectTimeout = TimeSpan.FromSeconds(2) })
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(4),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static async Task<RouterReport> Inspect(IPlatform platform, string gateway, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var report = new RouterReport { Gateway = gateway };
|
||||||
|
var address = Addresses.Parse(gateway);
|
||||||
|
if (address is null || address.AddressFamily != AddressFamily.InterNetwork) return report;
|
||||||
|
|
||||||
|
var upnp = Upnp(platform, address, report, ct);
|
||||||
|
var natPmp = NatPmp(address, report, ct);
|
||||||
|
var pcp = Pcp(address, report, ct);
|
||||||
|
await Task.WhenAll(upnp, natPmp, pcp);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task Upnp(IPlatform platform, IPAddress gateway, RouterReport report, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var location = await Discover(platform, gateway, ct);
|
||||||
|
if (location is null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var description = XDocument.Parse(await Lan.GetStringAsync(location, ct));
|
||||||
|
var root = description.Root?.Elements().FirstOrDefault(e => e.Name.LocalName == "device");
|
||||||
|
if (root is null) return;
|
||||||
|
report.Upnp = Answer.Answered;
|
||||||
|
report.UpnpVersion = Text(root, "deviceType")?.EndsWith(":2", StringComparison.Ordinal) == true ? 2 : 1;
|
||||||
|
report.Name = Text(root, "friendlyName");
|
||||||
|
report.Make = Text(root, "manufacturer");
|
||||||
|
report.Model = Text(root, "modelName");
|
||||||
|
var service = root.Descendants().Where(e => e.Name.LocalName == "service")
|
||||||
|
.Select(e => (Type: Text(e, "serviceType"), Control: Text(e, "controlURL")))
|
||||||
|
.FirstOrDefault(s => s.Type is not null && (s.Type.Contains(":WANIPConnection:") || s.Type.Contains(":WANPPPConnection:")));
|
||||||
|
if (service.Type is null || service.Control is null) return;
|
||||||
|
var baseText = description.Root.Elements().FirstOrDefault(e => e.Name.LocalName == "URLBase")?.Value;
|
||||||
|
var baseUri = Uri.TryCreate(baseText, UriKind.Absolute, out var parsed) && IsLocal(parsed) ? parsed : location;
|
||||||
|
if (!Uri.TryCreate(baseUri, service.Control, out var control) || !IsLocal(control)) return;
|
||||||
|
await ExternalAddress(control, service.Type, report, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is HttpRequestException or XmlException or TaskCanceledException && !ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task ExternalAddress(Uri control, string serviceType, RouterReport report, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var body = $"""<?xml version="1.0"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:GetExternalIPAddress xmlns:u="{serviceType}"/></s:Body></s:Envelope>""";
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, control) { Content = new StringContent(body, Encoding.UTF8) };
|
||||||
|
request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("text/xml; charset=\"utf-8\"");
|
||||||
|
request.Headers.TryAddWithoutValidation("SOAPAction", $"\"{serviceType}#GetExternalIPAddress\"");
|
||||||
|
using var response = await Lan.SendAsync(request, ct);
|
||||||
|
var answer = XDocument.Parse(await response.Content.ReadAsStringAsync(ct));
|
||||||
|
var external = answer.Descendants().FirstOrDefault(e => e.Name.LocalName == "NewExternalIPAddress")?.Value?.Trim();
|
||||||
|
if (Addresses.Parse(external) is not null)
|
||||||
|
{
|
||||||
|
report.WanAddress = external;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var code = answer.Descendants().FirstOrDefault(e => e.Name.LocalName == "errorCode")?.Value;
|
||||||
|
report.UpnpError = int.TryParse(code, out var number) ? number : (int)response.StatusCode;
|
||||||
|
report.Upnp = Answer.Refused;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<Uri> Discover(IPlatform platform, IPAddress gateway, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var hold = platform.AllowMulticast();
|
||||||
|
using var udp = new UdpClient(AddressFamily.InterNetwork);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
udp.Client.Bind(new IPEndPoint(IPAddress.Any, 0));
|
||||||
|
foreach (var target in SearchTargets)
|
||||||
|
{
|
||||||
|
var search = Encoding.ASCII.GetBytes($"M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\nMAN: \"ssdp:discover\"\r\nMX: 2\r\nST: {target}\r\n\r\n");
|
||||||
|
await udp.SendAsync(search, Ssdp, ct);
|
||||||
|
await udp.SendAsync(search, Ssdp, ct);
|
||||||
|
}
|
||||||
|
var answers = new List<(IPAddress From, Uri Location)>();
|
||||||
|
using var window = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
window.CancelAfter(TimeSpan.FromSeconds(2.5));
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
UdpReceiveResult received;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
received = await udp.ReceiveAsync(window.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
var headers = ParseHeaders(Encoding.ASCII.GetString(received.Buffer));
|
||||||
|
if (!headers.TryGetValue("LOCATION", out var text) || !Uri.TryCreate(text, UriKind.Absolute, out var location) || !IsLocal(location)) continue;
|
||||||
|
answers.Add((received.RemoteEndPoint.Address, location));
|
||||||
|
}
|
||||||
|
if (answers.Count == 0) return null;
|
||||||
|
return answers.OrderByDescending(a => a.From.Equals(gateway)).First().Location;
|
||||||
|
}
|
||||||
|
catch (SocketException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task NatPmp(IPAddress gateway, RouterReport report, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var answer = await Exchange(gateway, [0, 0], ct);
|
||||||
|
if (answer is null) return;
|
||||||
|
if (answer.Length < 12 || answer[0] != 0 || answer[1] != 128) return;
|
||||||
|
report.NatPmpResult = (answer[2] << 8) | answer[3];
|
||||||
|
if (report.NatPmpResult != 0)
|
||||||
|
{
|
||||||
|
report.NatPmp = Answer.Refused;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
report.NatPmp = Answer.Answered;
|
||||||
|
var external = new IPAddress(answer.AsSpan(8, 4));
|
||||||
|
if (report.WanAddress is null && !external.Equals(IPAddress.Any)) report.WanAddress = external.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task Pcp(IPAddress gateway, RouterReport report, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var udp = new UdpClient(AddressFamily.InterNetwork);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
udp.Connect(gateway, PortMappingPort);
|
||||||
|
}
|
||||||
|
catch (SocketException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var local = ((IPEndPoint)udp.Client.LocalEndPoint)?.Address ?? IPAddress.Any;
|
||||||
|
var request = new byte[24];
|
||||||
|
request[0] = 2;
|
||||||
|
local.MapToIPv6().GetAddressBytes().CopyTo(request, 8);
|
||||||
|
var answer = await Exchange(udp, request, ct);
|
||||||
|
if (answer is null || answer.Length < 24 || answer[0] != 2 || answer[1] != 0x80) return;
|
||||||
|
report.PcpResult = answer[3];
|
||||||
|
report.Pcp = answer[3] == 0 ? Answer.Answered : Answer.Refused;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<byte[]> Exchange(IPAddress gateway, byte[] request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var udp = new UdpClient(AddressFamily.InterNetwork);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
udp.Connect(gateway, PortMappingPort);
|
||||||
|
}
|
||||||
|
catch (SocketException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return await Exchange(udp, request, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<byte[]> Exchange(UdpClient udp, byte[] request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
foreach (var wait in new[] { 1000, 2000 })
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await udp.SendAsync(request, ct);
|
||||||
|
using var window = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
window.CancelAfter(wait);
|
||||||
|
var received = await udp.ReceiveAsync(window.Token);
|
||||||
|
return received.Buffer;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (SocketException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Dictionary<string, string> ParseHeaders(string message)
|
||||||
|
{
|
||||||
|
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var line in message.Split("\r\n"))
|
||||||
|
{
|
||||||
|
var colon = line.IndexOf(':');
|
||||||
|
if (colon > 0) headers[line[..colon].Trim()] = line[(colon + 1)..].Trim();
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool IsLocal(Uri uri) =>
|
||||||
|
uri.Scheme == Uri.UriSchemeHttp && IPAddress.TryParse(uri.Host, out var host) &&
|
||||||
|
Addresses.Classify(host) is AddressKind.Private or AddressKind.Shared or AddressKind.LinkLocal or AddressKind.UniqueLocal;
|
||||||
|
|
||||||
|
static string Text(XElement parent, string name)
|
||||||
|
{
|
||||||
|
var value = parent.Elements().FirstOrDefault(e => e.Name.LocalName == name)?.Value?.Trim();
|
||||||
|
return string.IsNullOrEmpty(value) ? null : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
namespace MeWeb.Core.Router;
|
||||||
|
|
||||||
|
public enum Answer
|
||||||
|
{
|
||||||
|
Silent,
|
||||||
|
Answered,
|
||||||
|
Refused,
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RouterReport
|
||||||
|
{
|
||||||
|
public string Gateway { get; set; }
|
||||||
|
public string Name { get; set; }
|
||||||
|
public string Make { get; set; }
|
||||||
|
public string Model { get; set; }
|
||||||
|
public Answer Upnp { get; set; }
|
||||||
|
public int UpnpVersion { get; set; }
|
||||||
|
public int UpnpError { get; set; }
|
||||||
|
public Answer NatPmp { get; set; }
|
||||||
|
public int NatPmpResult { get; set; } = -1;
|
||||||
|
public Answer Pcp { get; set; }
|
||||||
|
public int PcpResult { get; set; } = -1;
|
||||||
|
public string WanAddress { get; set; }
|
||||||
|
|
||||||
|
public bool AnyAnswer => Upnp != Answer.Silent || NatPmp != Answer.Silent || Pcp != Answer.Silent;
|
||||||
|
|
||||||
|
public bool AnyMapping => Upnp == Answer.Answered || NatPmp == Answer.Answered || Pcp == Answer.Answered;
|
||||||
|
|
||||||
|
public string Label => !string.IsNullOrWhiteSpace(Model) ? Model : !string.IsNullOrWhiteSpace(Name) ? Name : Make;
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
using MeWeb.Shared;
|
||||||
|
|
||||||
|
namespace MeWeb.Server;
|
||||||
|
|
||||||
|
public static class DownloadPage
|
||||||
|
{
|
||||||
|
const string Fingerprint = "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";
|
||||||
|
|
||||||
|
static readonly Dictionary<string, Dictionary<string, string>> Words = new()
|
||||||
|
{
|
||||||
|
["en"] = new()
|
||||||
|
{
|
||||||
|
["Title"] = "me-web? · can you run your own server from here?",
|
||||||
|
["Lead"] = "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.",
|
||||||
|
["Latest"] = "Version {0}, published {1}.",
|
||||||
|
["Early"] = "This is an early version: it reads your connection, your router and your provider. The tests from outside come in later versions.",
|
||||||
|
["None"] = "There is no release to download yet.",
|
||||||
|
["Android"] = "Android",
|
||||||
|
["AndroidNote"] = "Your phone will ask whether the browser may install apps. The app is signed; its certificate's SHA-256 is",
|
||||||
|
["Windows"] = "Windows (64-bit)",
|
||||||
|
["WindowsNote"] = "The file isn't signed with a paid certificate, so Windows may say it protected your PC: choose “More info”, then “Run anyway”.",
|
||||||
|
["Linux"] = "Linux",
|
||||||
|
["LinuxNote"] = "Install with your package manager, for example",
|
||||||
|
["Mac"] = "Apple computers",
|
||||||
|
["MacNote"] = "Builds for Mac are attached to the release, but they haven't been tried on a Mac yet.",
|
||||||
|
["Sums"] = "Checksums (SHA-256)",
|
||||||
|
["All"] = "All releases",
|
||||||
|
["Source"] = "Source code (AGPL-3.0)",
|
||||||
|
["Privacy"] = "This page sets no cookies and loads nothing from other sites.",
|
||||||
|
["Publisher"] = "Published by Arasaka di Hvozdikov Yevheniy, Busto Arsizio, Italy · VAT IT03929200123",
|
||||||
|
["Other"] = "Italiano",
|
||||||
|
},
|
||||||
|
["it"] = new()
|
||||||
|
{
|
||||||
|
["Title"] = "me-web? · può ospitare un server da qui?",
|
||||||
|
["Lead"] = "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.",
|
||||||
|
["Latest"] = "Versione {0}, pubblicata il {1}.",
|
||||||
|
["Early"] = "È una versione iniziale: legge la connessione, il router e l'operatore. Le prove da fuori arrivano nelle prossime versioni.",
|
||||||
|
["None"] = "Non c'è ancora una versione da scaricare.",
|
||||||
|
["Android"] = "Android",
|
||||||
|
["AndroidNote"] = "Il telefono chiederà se il browser può installare app. L'app è firmata; lo SHA-256 del suo certificato è",
|
||||||
|
["Windows"] = "Windows (64 bit)",
|
||||||
|
["WindowsNote"] = "Il file non è firmato con un certificato a pagamento, quindi Windows può dire di aver protetto il PC: scelga “Ulteriori informazioni”, poi “Esegui comunque”.",
|
||||||
|
["Linux"] = "Linux",
|
||||||
|
["LinuxNote"] = "Si installa con il gestore dei pacchetti, per esempio",
|
||||||
|
["Mac"] = "Computer Apple",
|
||||||
|
["MacNote"] = "Le versioni per Mac sono allegate alla release, ma non sono ancora state provate su un Mac.",
|
||||||
|
["Sums"] = "Somme di controllo (SHA-256)",
|
||||||
|
["All"] = "Tutte le versioni",
|
||||||
|
["Source"] = "Codice sorgente (AGPL-3.0)",
|
||||||
|
["Privacy"] = "Questa pagina non usa cookie e non carica nulla da altri siti.",
|
||||||
|
["Publisher"] = "Pubblicato da Arasaka di Hvozdikov Yevheniy, Busto Arsizio · P.IVA 03929200123",
|
||||||
|
["Other"] = "English",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string Render(GiteaRelease release, string language)
|
||||||
|
{
|
||||||
|
var words = Words[language];
|
||||||
|
var culture = CultureInfo.GetCultureInfo(language);
|
||||||
|
var other = language == "it" ? "en" : "it";
|
||||||
|
var html = new StringBuilder();
|
||||||
|
html.Append($$"""
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="{{language}}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{E(words["Title"])}}</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
body { margin: 0; background: #1B141C; color: #F0EAEF; font: 17px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
||||||
|
main { max-width: 760px; margin: 0 auto; padding: 32px 16px 48px; }
|
||||||
|
h1 { font-size: 2.1rem; margin: 0 0 4px; }
|
||||||
|
h2 { font-size: 1.15rem; margin: 0 0 6px; }
|
||||||
|
p { margin: 0 0 14px; }
|
||||||
|
.soft { color: #B3A6B2; font-size: .95rem; }
|
||||||
|
.card { background: #2C222D; border-radius: 14px; padding: 16px 18px; margin: 14px 0; }
|
||||||
|
a { color: #EC7A82; }
|
||||||
|
a.button { display: inline-block; background: #E04A55; color: #F0EAEF; text-decoration: none; font-weight: 600; padding: 10px 20px; border-radius: 22px; margin: 4px 8px 8px 0; }
|
||||||
|
code { font-family: ui-monospace, "Fira Code", monospace; font-size: .85rem; overflow-wrap: anywhere; color: #F0EAEF; }
|
||||||
|
nav { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
|
||||||
|
footer { margin-top: 28px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<nav><h1>me-web?</h1><a href="/?lang={{other}}">{{E(words["Other"])}}</a></nav>
|
||||||
|
<p>{{E(words["Lead"])}}</p>
|
||||||
|
""");
|
||||||
|
|
||||||
|
if (release is null)
|
||||||
|
{
|
||||||
|
html.Append($"<div class=\"card\"><p>{E(words["None"])}</p></div>");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var version = release.Tag.TrimStart('v');
|
||||||
|
html.Append($"<p class=\"soft\">{E(string.Format(culture, words["Latest"], version, release.PublishedAt.ToString("d MMMM yyyy", culture)))} {E(words["Early"])}</p>");
|
||||||
|
Block(html, release, words["Android"], [("android", "APK")], $"{E(words["AndroidNote"])} <code>{Fingerprint}</code>.");
|
||||||
|
Block(html, release, words["Windows"], [("windows", ".exe")], E(words["WindowsNote"]));
|
||||||
|
var deb = ReleaseFeed.Asset(release, "linux-deb")?.Name;
|
||||||
|
var rpm = ReleaseFeed.Asset(release, "linux-rpm")?.Name;
|
||||||
|
Block(html, release, words["Linux"], [("linux-deb", ".deb · Debian, Ubuntu"), ("linux-rpm", ".rpm · Fedora, openSUSE")],
|
||||||
|
$"{E(words["LinuxNote"])} <code>sudo apt install ./{E(deb)}</code> · <code>sudo dnf install ./{E(rpm)}</code>");
|
||||||
|
Block(html, release, words["Mac"], [("macos-arm64", "Apple Silicon"), ("macos-x64", "Intel")], E(words["MacNote"]));
|
||||||
|
html.Append($"<p><a href=\"{Endpoints.Downloads.GET_M_Platform("checksums")}\">{E(words["Sums"])}</a> · <a href=\"{E(release.Page ?? Endpoints.Gitea.Releases)}\">{E(words["All"])}</a></p>");
|
||||||
|
}
|
||||||
|
|
||||||
|
html.Append($$"""
|
||||||
|
<p><a href="{{Endpoints.Gitea.Source}}">{{E(words["Source"])}}</a></p>
|
||||||
|
<footer><p class="soft">{{E(words["Privacy"])}}</p><p class="soft">{{E(words["Publisher"])}}</p></footer>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""");
|
||||||
|
return html.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Block(StringBuilder html, GiteaRelease release, string title, (string Platform, string Label)[] files, string note)
|
||||||
|
{
|
||||||
|
html.Append($"<div class=\"card\"><h2>{E(title)}</h2>");
|
||||||
|
foreach (var (platform, label) in files)
|
||||||
|
{
|
||||||
|
var asset = ReleaseFeed.Asset(release, platform);
|
||||||
|
if (asset is null) continue;
|
||||||
|
html.Append($"<a class=\"button\" href=\"{Endpoints.Downloads.GET_M_Platform(platform)}\">{E(label)} · {asset.Size / 1048576.0:0} MB</a>");
|
||||||
|
}
|
||||||
|
html.Append($"<p class=\"soft\">{note}</p></div>");
|
||||||
|
}
|
||||||
|
|
||||||
|
static string E(string text) => WebUtility.HtmlEncode(text ?? "");
|
||||||
|
}
|
||||||
@@ -20,6 +20,12 @@ public static class Hosting
|
|||||||
options.KnownProxies.Add(IPAddress.IPv6Loopback);
|
options.KnownProxies.Add(IPAddress.IPv6Loopback);
|
||||||
});
|
});
|
||||||
services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Insert(0, MeWebJsonContext.Default));
|
services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Insert(0, MeWebJsonContext.Default));
|
||||||
|
services.AddHttpClient(ReleaseFeed.Client, http =>
|
||||||
|
{
|
||||||
|
http.Timeout = TimeSpan.FromSeconds(8);
|
||||||
|
http.DefaultRequestHeaders.UserAgent.ParseAdd($"meweb-server/{BuildInfo.Version}");
|
||||||
|
});
|
||||||
|
services.AddSingleton<ReleaseFeed>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
using MeWeb.Shared;
|
||||||
|
|
||||||
|
namespace MeWeb.Server;
|
||||||
|
|
||||||
|
public class GiteaAsset
|
||||||
|
{
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("size")]
|
||||||
|
public long Size { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("browser_download_url")]
|
||||||
|
public string Url { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GiteaRelease
|
||||||
|
{
|
||||||
|
[JsonPropertyName("tag_name")]
|
||||||
|
public string Tag { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("draft")]
|
||||||
|
public bool Draft { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("prerelease")]
|
||||||
|
public bool Prerelease { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("published_at")]
|
||||||
|
public DateTimeOffset PublishedAt { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("html_url")]
|
||||||
|
public string Page { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("assets")]
|
||||||
|
public List<GiteaAsset> Assets { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonSerializable(typeof(List<GiteaRelease>))]
|
||||||
|
public partial class GiteaJsonContext : JsonSerializerContext
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ReleaseFeed(IHttpClientFactory factory, ILogger<ReleaseFeed> log)
|
||||||
|
{
|
||||||
|
public const string Client = "gitea";
|
||||||
|
static readonly TimeSpan Fresh = TimeSpan.FromMinutes(10);
|
||||||
|
|
||||||
|
public static readonly IReadOnlyList<(string Platform, string Suffix)> Platforms =
|
||||||
|
[
|
||||||
|
("android", "-android.apk"),
|
||||||
|
("windows", "-windows-x64.exe"),
|
||||||
|
("linux-deb", "-linux-x64.deb"),
|
||||||
|
("linux-rpm", "-linux-x64.rpm"),
|
||||||
|
("macos-arm64", "-macos-arm64.zip"),
|
||||||
|
("macos-x64", "-macos-x64.zip"),
|
||||||
|
("checksums", "SHA256SUMS.txt"),
|
||||||
|
];
|
||||||
|
|
||||||
|
readonly SemaphoreSlim gate = new(1, 1);
|
||||||
|
(DateTimeOffset At, GiteaRelease Release) cached;
|
||||||
|
|
||||||
|
public async Task<GiteaRelease> Latest(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (cached.Release is not null && DateTimeOffset.UtcNow - cached.At < Fresh) return cached.Release;
|
||||||
|
await gate.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (cached.Release is not null && DateTimeOffset.UtcNow - cached.At < Fresh) return cached.Release;
|
||||||
|
var http = factory.CreateClient(Client);
|
||||||
|
var releases = await http.GetFromJsonAsync(Endpoints.Gitea.GET_Releases, GiteaJsonContext.Default.ListGiteaRelease, ct) ?? [];
|
||||||
|
var latest = releases.FirstOrDefault(r => !r.Draft && r.Assets.Count > 0);
|
||||||
|
cached = (DateTimeOffset.UtcNow, latest);
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or System.Text.Json.JsonException && !ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
log.LogWarning("{Service} could not read the releases: {Error}", nameof(ReleaseFeed), ex.Message);
|
||||||
|
return cached.Release;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
gate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GiteaAsset Asset(GiteaRelease release, string platform)
|
||||||
|
{
|
||||||
|
var suffix = Platforms.FirstOrDefault(p => p.Platform == platform).Suffix;
|
||||||
|
return suffix is null || release is null ? null : release.Assets.FirstOrDefault(a => a.Name.EndsWith(suffix, StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ public static class ServerApi
|
|||||||
{
|
{
|
||||||
app.MapGet(Endpoints.Server.GET_Info, Info);
|
app.MapGet(Endpoints.Server.GET_Info, Info);
|
||||||
app.MapGet(Endpoints.Server.GET_Ip, Ip);
|
app.MapGet(Endpoints.Server.GET_Ip, Ip);
|
||||||
|
app.MapGet(Endpoints.Downloads.GET_Page, Page);
|
||||||
|
app.MapGet(Endpoints.Downloads.GET_Platform, Download);
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,4 +25,22 @@ public static class ServerApi
|
|||||||
var family = address.AddressFamily == AddressFamily.InterNetworkV6 ? "ipv6" : "ipv4";
|
var family = address.AddressFamily == AddressFamily.InterNetworkV6 ? "ipv6" : "ipv4";
|
||||||
return Results.Ok(new ViewIp { Address = address.ToString(), Family = family });
|
return Results.Ok(new ViewIp { Address = address.ToString(), Family = family });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async Task<IResult> Page(HttpContext context, ReleaseFeed feed, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var asked = context.Request.Query["lang"].ToString();
|
||||||
|
var language = asked is "it" or "en" ? asked : context.Request.Headers.AcceptLanguage.ToString().StartsWith("it", StringComparison.OrdinalIgnoreCase) ? "it" : "en";
|
||||||
|
var release = await feed.Latest(ct);
|
||||||
|
context.Response.Headers.CacheControl = "no-cache";
|
||||||
|
context.Response.Headers.ContentLanguage = language;
|
||||||
|
context.Response.Headers.Vary = "Accept-Language";
|
||||||
|
return Results.Content(DownloadPage.Render(release, language), "text/html; charset=utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<IResult> Download(string platform, ReleaseFeed feed, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!ReleaseFeed.Platforms.Any(p => p.Platform == platform)) return Results.NotFound();
|
||||||
|
var asset = ReleaseFeed.Asset(await feed.Latest(ct), platform);
|
||||||
|
return asset?.Url is null ? Results.Redirect(Endpoints.Gitea.Releases) : Results.Redirect(asset.Url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -20,8 +20,18 @@ public static class Endpoints
|
|||||||
|
|
||||||
public static class Downloads
|
public static class Downloads
|
||||||
{
|
{
|
||||||
|
public const string GET_Page = "/";
|
||||||
public const string GET_Platform = "/download/{platform}";
|
public const string GET_Platform = "/download/{platform}";
|
||||||
|
|
||||||
public static string GET_M_Platform(string platform) => $"/download/{platform}";
|
public static string GET_M_Platform(string platform) => $"/download/{platform}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class Gitea
|
||||||
|
{
|
||||||
|
public const string Origin = "https://git.thepra.dev";
|
||||||
|
public const string Repository = "Arasaka/MeWeb";
|
||||||
|
public const string GET_Releases = $"{Origin}/api/v1/repos/{Repository}/releases?limit=10";
|
||||||
|
public const string Releases = $"{Origin}/{Repository}/releases";
|
||||||
|
public const string Source = $"{Origin}/{Repository}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ using MeWeb.App;
|
|||||||
using MeWeb.App.Services;
|
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 Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
@@ -16,19 +17,34 @@ var codes = args.Length > 1 ? args[1].Split(',', StringSplitOptions.RemoveEmptyE
|
|||||||
Directory.CreateDirectory(output);
|
Directory.CreateDirectory(output);
|
||||||
|
|
||||||
var session = HeadlessUnitTestSession.StartNew(typeof(ScreenshotEntry));
|
var session = HeadlessUnitTestSession.StartNew(typeof(ScreenshotEntry));
|
||||||
await session.Dispatch(() =>
|
await session.Dispatch(async () =>
|
||||||
{
|
{
|
||||||
foreach (var (width, height, label) in new[] { (1100, 760, "desktop"), (400, 820, "phone") })
|
var run = await App.Services.GetRequiredService<CheckRunner>().Run((_, _) => { }, CancellationToken.None);
|
||||||
|
Console.WriteLine($"run: {string.Join(", ", run.Findings.Select(f => $"{f.Id}={f.Outcome}"))}");
|
||||||
|
foreach (var (width, height, label) in new[] { (1100, 1500, "desktop"), (400, 2600, "phone") })
|
||||||
{
|
{
|
||||||
foreach (var code in codes)
|
foreach (var code in codes)
|
||||||
{
|
{
|
||||||
Localizer.Instance.SetLanguage(Languages.Find(code) ?? Languages.Fallback);
|
Localizer.Instance.SetLanguage(Languages.Find(code) ?? Languages.Fallback);
|
||||||
|
Shoot(width, height, $"main-{label}-{code}", model => model.Replay(run));
|
||||||
|
if (label == "desktop") Shoot(width, 900, $"contacts-{code}", model => model.ShowContacts = true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}, CancellationToken.None);
|
||||||
|
Environment.Exit(0);
|
||||||
|
|
||||||
|
void Shoot(int width, int height, string name, Action<MainViewModel> prepare)
|
||||||
|
{
|
||||||
var model = App.Services.GetRequiredService<MainViewModel>();
|
var model = App.Services.GetRequiredService<MainViewModel>();
|
||||||
var window = new Window { Width = width, Height = height, Content = new MainView { DataContext = model } };
|
var view = new MainView { DataContext = model };
|
||||||
|
var window = new Window { Width = width, Height = height, Content = view };
|
||||||
window.Show();
|
window.Show();
|
||||||
Dispatcher.UIThread.RunJobs();
|
model.AvailableWidth = width;
|
||||||
|
prepare(model);
|
||||||
|
for (var i = 0; i < 6; i++) Dispatcher.UIThread.RunJobs();
|
||||||
var frame = window.CaptureRenderedFrame();
|
var frame = window.CaptureRenderedFrame();
|
||||||
var path = Path.Combine(output, $"main-{label}-{code}.png");
|
var path = Path.Combine(output, $"{name}.png");
|
||||||
if (frame is not null)
|
if (frame is not null)
|
||||||
{
|
{
|
||||||
using var file = File.Create(path);
|
using var file = File.Create(path);
|
||||||
@@ -37,9 +53,6 @@ await session.Dispatch(() =>
|
|||||||
Console.WriteLine($"{path} {(frame is null ? "no frame" : "ok")}");
|
Console.WriteLine($"{path} {(frame is null ? "no frame" : "ok")}");
|
||||||
window.Close();
|
window.Close();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}, CancellationToken.None);
|
|
||||||
Environment.Exit(0);
|
|
||||||
|
|
||||||
public static class ScreenshotEntry
|
public static class ScreenshotEntry
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#:project ../src/MeWeb.Core/MeWeb.Core.csproj
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Net;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
using MeWeb.Core.Knowledge;
|
||||||
|
|
||||||
|
const string Source = "https://iptoasn.com/data/ip2asn-combined.tsv.gz";
|
||||||
|
var root = FindRoot();
|
||||||
|
var cache = Path.Combine(root, "tools", "cache");
|
||||||
|
var output = Path.Combine(root, "artifacts", "knowledge", "ip2asn.bin");
|
||||||
|
Directory.CreateDirectory(cache);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(output));
|
||||||
|
var download = Path.Combine(cache, "ip2asn-combined.tsv.gz");
|
||||||
|
|
||||||
|
using var http = new HttpClient();
|
||||||
|
http.DefaultRequestHeaders.UserAgent.ParseAdd("me-web-knowledge/1 (+https://git.thepra.dev/Arasaka/MeWeb)");
|
||||||
|
var fresh = !File.Exists(download) || File.GetLastWriteTimeUtc(download) < DateTime.UtcNow.AddHours(-12) || args.Contains("--refresh");
|
||||||
|
var sourceDate = File.Exists(download) ? new DateTimeOffset(File.GetLastWriteTimeUtc(download), TimeSpan.Zero) : DateTimeOffset.UtcNow;
|
||||||
|
if (fresh)
|
||||||
|
{
|
||||||
|
using var response = await http.GetAsync(Source, HttpCompletionOption.ResponseHeadersRead);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
sourceDate = response.Content.Headers.LastModified ?? DateTimeOffset.UtcNow;
|
||||||
|
await using (var file = File.Create(download + ".tmp"))
|
||||||
|
await response.Content.CopyToAsync(file);
|
||||||
|
File.Move(download + ".tmp", download, overwrite: true);
|
||||||
|
File.SetLastWriteTimeUtc(download, sourceDate.UtcDateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
var hash = Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(download)));
|
||||||
|
var ranges = new List<AsnRange>();
|
||||||
|
using (var reader = new StreamReader(new GZipStream(File.OpenRead(download), CompressionMode.Decompress)))
|
||||||
|
{
|
||||||
|
string line;
|
||||||
|
while ((line = reader.ReadLine()) is not null)
|
||||||
|
{
|
||||||
|
var parts = line.Split('\t');
|
||||||
|
if (parts.Length < 5 || !uint.TryParse(parts[2], out var number) || number == 0) continue;
|
||||||
|
if (!IPAddress.TryParse(parts[0], out var start) || !IPAddress.TryParse(parts[1], out var end)) continue;
|
||||||
|
ranges.Add(new AsnRange { Start = start, End = end, Number = number, Country = parts[3] == "None" ? "" : parts[3], Name = parts[4] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var file = File.Create(output + ".tmp"))
|
||||||
|
AsnTable.Write(file, sourceDate, ranges);
|
||||||
|
File.Move(output + ".tmp", output, overwrite: true);
|
||||||
|
|
||||||
|
using var check = File.OpenRead(output);
|
||||||
|
var table = AsnTable.Read(check);
|
||||||
|
var probe = table.Find(IPAddress.Parse("1.1.1.1"));
|
||||||
|
Console.WriteLine($"knowledge: {ranges.Count} routed ranges, {table.Count} networks, source {sourceDate:yyyy-MM-dd HH:mm} UTC, sha256 {hash}");
|
||||||
|
Console.WriteLine($"knowledge: {output} {new FileInfo(output).Length / 1024} KiB; 1.1.1.1 -> AS{probe?.Number} {probe?.Name}");
|
||||||
|
File.WriteAllText(Path.Combine(root, "artifacts", "knowledge", "SOURCE.txt"), $"{Source}\nlast-modified {sourceDate:O}\nsha256 {hash}\n");
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
static string FindRoot()
|
||||||
|
{
|
||||||
|
var dir = new DirectoryInfo(Directory.GetCurrentDirectory());
|
||||||
|
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "MeWeb.slnx"))) dir = dir.Parent;
|
||||||
|
return dir?.FullName ?? throw new InvalidOperationException("run from inside the repository (MeWeb.slnx not found)");
|
||||||
|
}
|
||||||
@@ -39,6 +39,11 @@ Directory.CreateDirectory(output);
|
|||||||
Console.WriteLine($"me-web? {version} ({commit}): {string.Join(", ", targets)} -> {output}");
|
Console.WriteLine($"me-web? {version} ({commit}): {string.Join(", ", targets)} -> {output}");
|
||||||
|
|
||||||
var failed = false;
|
var failed = false;
|
||||||
|
if (targets.Any(t => t != "server"))
|
||||||
|
{
|
||||||
|
Console.WriteLine("== knowledge");
|
||||||
|
RunTool("knowledge.cs");
|
||||||
|
}
|
||||||
foreach (var target in targets)
|
foreach (var target in targets)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -61,8 +66,25 @@ foreach (var target in targets)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
WriteSums();
|
WriteSums();
|
||||||
|
WriteNotes();
|
||||||
return failed ? 1 : 0;
|
return failed ? 1 : 0;
|
||||||
|
|
||||||
|
void WriteNotes()
|
||||||
|
{
|
||||||
|
var source = Path.Combine(root, "artifacts", "knowledge", "SOURCE.txt");
|
||||||
|
var lines = new List<string>
|
||||||
|
{
|
||||||
|
$"me-web? {version}, commit {commit}, built {builtAt}.",
|
||||||
|
"",
|
||||||
|
"Built with the provider map from iptoasn.com (PDDL 1.0):",
|
||||||
|
"",
|
||||||
|
};
|
||||||
|
lines.AddRange(File.Exists(source) ? File.ReadAllLines(source).Where(l => l.Length > 0).Select(l => $" {l}") : [" (no provider map in this build)"]);
|
||||||
|
lines.AddRange(["", "SHA256SUMS.txt lists every file. The APK's signing certificate SHA-256 is 5f52cff4f52f4e0b6da6cd044bfceaf78d1d42db24f2470602db7f19cbfdf846."]);
|
||||||
|
Directory.CreateDirectory(Path.Combine(root, "artifacts"));
|
||||||
|
File.WriteAllLines(Path.Combine(root, "artifacts", "release-notes.md"), lines);
|
||||||
|
}
|
||||||
|
|
||||||
string[] Stamp() => [$"-p:Version={version}", $"-p:BuildCommit={commit}", $"-p:BuildTimeUtc={builtAt}"];
|
string[] Stamp() => [$"-p:Version={version}", $"-p:BuildCommit={commit}", $"-p:BuildTimeUtc={builtAt}"];
|
||||||
|
|
||||||
string Linux()
|
string Linux()
|
||||||
|
|||||||
+58
-1
@@ -10,7 +10,11 @@ var placeholder = new Regex(@"\{\d+\}");
|
|||||||
|
|
||||||
if (args.Length >= 1 && args[0] == "check") return Check();
|
if (args.Length >= 1 && args[0] == "check") return Check();
|
||||||
if (args.Length == 3 && args[0] == "set") return Set(args[1], args[2]);
|
if (args.Length == 3 && args[0] == "set") return Set(args[1], args[2]);
|
||||||
Console.Error.WriteLine("usage: dotnet run tools/strings.cs -- check | set <Key> <values.json> (values.json maps a language code to its text)");
|
if (args.Length == 2 && args[0] == "merge") return Merge(args[1]);
|
||||||
|
if (args.Length == 3 && args[0] == "import") return Import(args[1], args[2]);
|
||||||
|
if (args.Length == 2 && args[0] == "drop") return Drop(args[1]);
|
||||||
|
Console.Error.WriteLine("usage: dotnet run tools/strings.cs -- check | set <Key> <values.json> | merge <keys.json> | import <code> <strings.json> | drop <Key>");
|
||||||
|
Console.Error.WriteLine(" values.json maps a language code to its text; keys.json maps keys to such maps; strings.json maps keys to one language's text");
|
||||||
return 2;
|
return 2;
|
||||||
|
|
||||||
int Check()
|
int Check()
|
||||||
@@ -48,6 +52,59 @@ int Set(string key, string valuesPath)
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int Merge(string path)
|
||||||
|
{
|
||||||
|
var keys = JsonNode.Parse(File.ReadAllText(path)).AsObject();
|
||||||
|
var byLanguage = new Dictionary<string, SortedDictionary<string, string>>();
|
||||||
|
foreach (var (key, values) in keys)
|
||||||
|
{
|
||||||
|
if (values?["en"] is null) throw new InvalidOperationException($"{key} carries no \"en\"");
|
||||||
|
foreach (var (code, value) in values.AsObject())
|
||||||
|
{
|
||||||
|
if (!byLanguage.TryGetValue(code, out var strings)) byLanguage[code] = strings = Read(code);
|
||||||
|
strings[key] = (string)value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (var (code, strings) in byLanguage) Write(code, strings);
|
||||||
|
Console.WriteLine($"merge: {keys.Count} key(s) into {byLanguage.Count} language(s)");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int Import(string code, string path)
|
||||||
|
{
|
||||||
|
var english = Read("en");
|
||||||
|
var incoming = JsonNode.Parse(File.ReadAllText(path)).AsObject();
|
||||||
|
var strings = Read(code);
|
||||||
|
var unknown = new List<string>();
|
||||||
|
foreach (var (key, value) in incoming)
|
||||||
|
{
|
||||||
|
if (!english.ContainsKey(key))
|
||||||
|
{
|
||||||
|
unknown.Add(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
strings[key] = (string)value;
|
||||||
|
}
|
||||||
|
Write(code, strings);
|
||||||
|
Console.WriteLine($"import {code}: {incoming.Count - unknown.Count} key(s){(unknown.Count > 0 ? $", ignored unknown [{string.Join(", ", unknown)}]" : "")}");
|
||||||
|
return unknown.Count == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int Drop(string key)
|
||||||
|
{
|
||||||
|
var count = 0;
|
||||||
|
foreach (var file in Directory.GetFiles(folder, "*.json"))
|
||||||
|
{
|
||||||
|
var code = Path.GetFileNameWithoutExtension(file);
|
||||||
|
var strings = Read(code);
|
||||||
|
if (!strings.Remove(key)) continue;
|
||||||
|
Write(code, strings);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
Console.WriteLine($"{key}: dropped from {count} language(s)");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
SortedDictionary<string, string> Read(string code)
|
SortedDictionary<string, string> Read(string code)
|
||||||
{
|
{
|
||||||
var path = Path.Combine(folder, $"{code}.json");
|
var path = Path.Combine(folder, $"{code}.json");
|
||||||
|
|||||||
Reference in new issue
Block a user