Files
MeWeb/CLAUDE.md
T
thepraandClaude Opus 5.5 eb8be87284
Build / Desktop, server and tools (push) Successful in 59s
Release / Every platform, the server, the release (push) Successful in 8m42s
me-web? 0.5: the shared exit measured, the route from the server, the network's upstreams
- Behind your provider's shared address, me-web? now shows how its exit hands out ports
  (kept, one after another, from one block, or at random), the range it gave this device,
  and how many customers may share the address at most; the estimate sharpens with each
  check on the same address.
- It tests whether two devices behind the same address can reach each other (hairpin), and
  how long an unused connection stays open: the card says "measuring" for about two minutes
  after the check, then the answer.
- With the test from outside, the publisher's server also traces the route back to your
  address: your provider's routers and the networks in between appear on the path, with
  their names.
- The provider card says which networks your provider reaches the internet through.
- On mobile data, the path shows the operator and the type of network (5G, 4G) read from the
  phone; on Android 13 and later the phone asks once for permission to read the network type.
- The speed card adds the round trip to the publisher's server.
- The network data now comes from the publisher's server, refreshed there from open sources:
  iptoasn.com, DB-IP Lite (CC BY 4.0), the University of Oregon Route Views Project (CC BY 4.0)
  and the Android Open Source Project's list of mobile operators (Apache 2.0); the release
  notes list each source's date and checksum.
- Source links in the cards wrap instead of being cut off.
- New strings, drafted in every language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-15 11:11:24 +02:00

150 lines
14 KiB
Markdown

# CLAUDE.md — MeWeb quick reference
Read `AGENT.md` first and cite `PATTERNS.md` for code rules. This file holds commands, the map and the pitfalls.
## Commands
```bash
dotnet build src/MeWeb.Desktop/MeWeb.Desktop.csproj
dotnet run --project src/MeWeb.Desktop
dotnet run --project src/MeWeb.Server # ASPNETCORE_URLS=http://127.0.0.1:5481
dotnet run tools/setup.cs -- --group jdk # pinned JDK 21 into tools/jdk
dotnet run tools/setup.cs -- --group packaging # pinned nfpm into tools/packaging
dotnet build src/MeWeb.Android/MeWeb.Android.csproj \
-p:JavaSdkDirectory=$PWD/tools/jdk/linux-x64/jdk-21.0.12+8 -p:AndroidSdkDirectory=$HOME/Android/Sdk
dotnet run --project tools/Screenshots -- dist/screenshots [en,it,...]
tools/release.sh notes # artifacts/release-notes.md alone, nothing built
dotnet run tools/strings.cs -- check # every language against English
dotnet run tools/strings.cs -- set Key values.json # one key, every language at once
dotnet run tools/strings.cs -- merge keys.json # many keys: {"Key": {"en": "…", "it": "…"}}
dotnet run tools/strings.cs -- import fr fr.json # one language's drafts: {"Key": "…"}
dotnet run tools/knowledge.cs # provider map -> artifacts/knowledge/ip2asn.bin
dotnet run tools/languages.cs # regenerate data/languages.json from CLDR
dotnet publish src/MeWeb.Desktop -c Release -r win-x64 --self-contained -p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true
dotnet publish src/MeWeb.Desktop -c Release -r osx-arm64 --self-contained
```
The Android workload installs into the user SDK (`dotnet workload install android`); the system SDK in `/opt/dotnet`
is root-owned, so CI uses the runner user's own `~/.dotnet` (passolo's `tools/release.sh` pattern).
## Repository, CI and the variables they read
| Name | Where | What |
|---|---|---|
| `GITEA_URL` | shell, optional | Gitea base URL for `tools/gitea/*.sh`; default `https://git.thepra.dev` |
| `GITEA_ORG` | shell, optional | organisation; default `Arasaka` |
| `GITEA_REPO` | shell, optional | repository for `set_secret.sh`; default `MeWeb` |
| (git credential store) | `~/.git-credentials` | the only Gitea authentication the scripts use: `git credential fill` for `git.thepra.dev` |
| `MEWEB_KEYSTORE_B64` | Actions secret | the Android keystore, base64 (`tools/gitea/set_secret.sh`) |
| `MEWEB_KEYSTORE_PASS` | Actions secret | its password (store and key share it), alias `meweb` |
| `GITEA_TOKEN` | Actions, automatic | the job token; `release.sh` uses it as `RELEASE_TOKEN` to create the release and attach files |
| `VERSION`, `COMMIT` | job env | written by `.gitea/scripts/prepare.sh` from `Directory.Build.props` and the commit |
| `MEWEB_KEYSTORE` | release step | a temporary file in `$RUNNER_TEMP` decoded by `.gitea/scripts/android.sh`, removed on exit |
`build.yml` runs on every push to `master`; `release.yml` on a `v*` tag (publishes the Gitea release) or by hand
(`publish: false` builds every platform and deploys the server only). Both run on the Arasaka org runner `nuvola`,
user `gitea-runner`, home `/var/lib/gitea-runner`: `tools/sdk.sh` installs the SDK from `global.json` into its
`~/.dotnet` (the system SDK in `/opt/dotnet` is older and root-owned), and the JDK, nfpm and the Android SDK are
cached in `~/.cache/meweb`. There is deliberately no `pull_request` trigger: the runner is the production box.
The server's one-time root setup is `deploy/max/setup.sh` (user `meweb`, `/var/www/meweb.arasaka.software`,
`/etc/meweb/meweb.env`, the unit, the runner's sudoers line, the certificate, nginx), run on the box through
`arasaka.software`'s `tools/max/run.sh`.
The Android release key: alias `meweb`, certificate SHA-256
`5f52cff4f52f4e0b6da6cd044bfceaf78d1d42db24f2470602db7f19cbfdf846`. Never regenerate it.
## Map
| Path | What |
|---|---|
| `src/MeWeb.Shared` | `Endpoints.cs` (routes and hosts), wire contracts, `MeWebJsonContext`, generated `BuildInfo` |
| `src/MeWeb.Core` | the checks, no UI: `Net/` (address kinds, local network, TTL route, STUN, the probe's `Answerer`, DNS, speed, the device), `Router/` (UPnP IGD, NAT-PMP, PCP; `PortMapper` opens and removes mappings, `Leftovers` remembers them across a crash), `Mail/` (port 25, reverse name, blocklists through the user's resolver), `Knowledge/` (provider map and profiles), `History/` (checks, shared submissions), `Checks/` (`CheckRunner`, `Verdicts`, `Reach`, `Uses`, `ServerProbe`, `SharePayload`) |
| `src/MeWeb.App` | Avalonia views and view models, `Services/Localizer.cs` (+ `{s:Tr Key}`), `Strings/<code>.json`, `Resources/Colors.axaml`; `Resources/Icons.axaml` (Lucide, 24 units, for cards and lists) and `Resources/Art.axaml` (the path's drawings, 48 units: `Art<Name>Base`, up to three `Beat`s that pulse in turn while the step is checked and take its verdict colour, and a `Spin` for the globe). The path is a vertical zig-zag of `PathNodeViewModel`s, and the same nodes carry the result cards (`Cards`, regrouped by `MainViewModel.Home`), so a step in the path and its group of cards are one object |
| `src/MeWeb.Desktop` | desktop head (`meweb` executable), Windows manifest |
| `src/MeWeb.Android` | Android head (`software.arasaka.meweb`), resources, manifest |
| `src/MeWeb.Server` | `meweb.arasaka.software`: `/api/info`, `/api/ip`, the download page `/` and `/privacy` (en, it), `/download/{platform}` from the latest Gitea release, the probe (`/api/probe/ticket`, `/api/probe/{id}`) and shared findings (`/api/share`, files under `/var/lib/meweb/shares`, swept after `KeepDays`) |
| `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 |
| `src/MeWeb.Server/KnowledgeFeeder.cs` | refreshes the open network data under `/var/lib/meweb/knowledge` every six hours (iptoasn daily, DB-IP monthly, a RouteViews RIB weekly through `bzcat`, the AOSP carrier list weekly), builds `ip2asn.bin`, `networks.bin`, `carriers.json` and `SOURCES.txt` with `MeWeb.Core/Knowledge/Sources.cs`, and serves them at `/api/knowledge` |
| `tools/knowledge.cs` | takes the server's knowledge files (`MEWEB_KNOWLEDGE_URL`, default the public server; the release sets `http://127.0.0.1:5480` after deploying the server first) into `artifacts/knowledge/`, checking each SHA-256, and falls back to building `ip2asn.bin` alone from iptoasn (`--local` forces that); `MeWeb.Core` embeds whatever is there; `tools/release.cs` runs it before every app build |
| `tools/setup.cs` + `setup.json` | pinned build tools |
| `tools/Screenshots` | headless renders of every view in every language |
| `branding/` | the icon (SVG source, PNG, ICO) |
## Pitfalls
- **A TTL ping needs an empty payload off-root.** .NET on Linux falls back to the `ping` utility without
`CAP_NET_RAW` and refuses any custom buffer ("Unable to send custom ping payload"); `SendPingAsync(target, timeout, [],
new PingOptions(ttl, false))` works and reports `TimeExceeded` with the hop's address.
- **A 100.64.x.x hop is not proof of CGNAT.** The owner's Dimensione line has a public IPv4 on the FRITZ!Box and still
shows `100.64.255.255` as hop 2. Only the router's own WAN address compared with the echo decides; hops are a hint.
- **Routers answer the SSDP search they don't implement.** The FRITZ!Box 5530 answers an IGD:2 search while describing
itself as IGD:1; the version comes from the description's `deviceType`. Its PCP answers `NOT_AUTHORIZED` (2).
- **`HeadlessUnitTestSession.Dispatch` builds a fresh `Application` each call**, and `AttachDeveloperTools` throws the
second time: the screenshot tool does the real check and every shot inside one `Dispatch`.
- **Fluent's `Expander` paints its header from resources** (`ExpanderHeaderBackground`, …), not from `Background`; the
`Expander.explain` style overrides them in `Style.Resources`.
- **The probe never trusts the client for a target.** A ticket is bound to the address the server saw, single use,
60 s; the run refuses any other caller address, private and CGNAT space, and `Probe:Refuse` (the box's own
addresses, so the owner cannot probe himself from home). `Probe__AllowPrivate=true` exists for a local server only.
- **Ask the outside STUN server first.** When `stun.meweb` is the caller's own public address (the owner's line), the
first binding hairpins inside the FRITZ!Box with the port preserved and the next destination reuses that mapping,
so the order `stun.nextcloud.com` then `stun.meweb` is what reads a symmetric NAT as hard.
- **Shared findings are files, not Mongo.** The box's Mongo has auth on and me-web? has no user there; a file per
submission under the unit's `StateDirectory`, with only the delete token's SHA-256, needs no credentials.
- **An address inside right-to-left text reorders around `→`.** `Texts.Isolate` wraps every literal value in U+2068/U+2069.
- **On mobile data there is no router of the user's.** Android reports a gateway for the cellular link, so
`CheckRunner` skips the router protocols on a mobile link, and a private or 100.64 address on the device with a
different public echo is the operator's NAT (`NatKind.Shared`); before 0.4 a phone on Iliad read "router not
answering" and "can't tell" everywhere downstream. Provider profiles describe home lines, so on mobile the card says so.
- **The route is drawn, never trusted.** `Route.Trace` (8 TTLs) feeds the path's wires: hops after the first, up to
the first public one, sit before the shared exit or the provider; public ones carry their AS number into the
internet; unanswered runs collapse to "…". The NAT verdict never reads them (see the 100.64 pitfall).
- **The knowledge build must fit the unit's `MemoryMax=512M`.** It peaked at 551 MB with Server GC on a 32-core
machine; workstation GC (the server's csproj), streaming hashes and streaming the iptoasn ranges into compact
tuples brought it to about 335 MB. Re-measure (`VmHWM`) after touching `Sources.cs` or `AsnTable.Write`.
- **The server cannot ping**: `NoNewPrivileges` drops `ping`'s file capability and `ping_group_range` is closed, so
the trace back to a caller uses `tracepath` (UDP, unprivileged) and keeps only public hops outside `Probe:Refuse`,
so the box's own network never reaches an app.
- **Port facts are a lower bound.** Sixteen bindings see a few ports of a provider's block; the block estimate
widens with each check on the same public address (`HistoryStore.PortRange`), so "up to about N customers" is an
upper bound that only falls over time. The mapping lifetime test (30 s and 120 s) runs after the check and updates
the card; a NAT that reuses the same port after expiry reads as "kept".
- **5G non-standalone reports LTE** as the data network type; the display-info callback (Android 12+) gives the
override the status bar shows. The radio type needs `READ_BASIC_PHONE_STATE` (Android 13+), asked during the
first check on mobile data.
- **Download speed is timed after the first quarter of the sample**, once TCP has ramped up: on mobile the sample is
2 MB and the whole-transfer figure was mostly the ramp.
- **A Lucide icon joined from several SVG elements must start each one with an absolute `M`.** A leading `m` is
absolute only as a path's first command; joined after another element it becomes relative and draws outside the box.
The shield and the radio tower did that until 0.3; check new geometry by walking its points against 24 (or 48).
- **On Android `GC.GetGCMemoryInfo().TotalAvailableMemoryBytes` is the runtime's limit, not the RAM** (0.1 GB on a
phone with several): `AndroidPlatform` reads `ActivityManager.MemoryInfo.TotalMem`, Linux `/proc/meminfo`.
- **`DriveInfo` on Unix takes any path** (it calls `statvfs`): pass the app's folder, not `/`, or Android reads the
read-only root and reports no free disk at all.
- **An IPv6 address only wraps at a colon if it is allowed to**: the path's detail lines put U+200B after each colon;
they are plain `TextBlock`s for that reason, so a copy never carries the invisible character (the cards' values,
which are selectable, stay untouched).
- **Avalonia 12 Android head:** `[Application] MainApplication : AvaloniaAndroidApplication<App>` plus a non-generic
`AvaloniaMainActivity`; the main view comes through `IActivityApplicationLifetime.MainViewFactory`, checked before
`ISingleViewApplicationLifetime`.
- **`ApplicationVersion` must be an integer**: computed from `Version` through separate `VersionMajor/Minor/Patch`
properties; one nested property function left it unevaluated (`XA0003`).
- **A headless Avalonia session does not end on its own** while the pulse animation runs: the screenshot tool calls
`Environment.Exit(0)` after dispatching.
- **Launchers and the Android 12 splash crop the icon to their own shape.** The launcher icon is the adaptive
`mipmap-anydpi-v26/icon.xml` (background colour, `drawable/icon_foreground.xml`, `drawable/icon_monochrome.xml` for
themed icons), and the splash uses the same foreground. Both vectors hold `branding/meweb.svg`'s drawing scaled into
the central 66 of 108 units around its smallest enclosing circle; a change to the drawing goes into all three files.
The plain `mipmap-xxxhdpi/icon.png` is only for Android 7.x.
- **Release notes are the commits since the previous tag.** `tools/release.cs` writes "What's new" from each
commit's subject (without the `me-web? x.y:` prefix) and body, dropping trailers and any line that mentions Claude;
the release step needs `fetch-depth: 0` for the tags. So a commit body is written for the people who download the
release: plain words, one bullet per change they would notice.
- **The macOS apphost is ad-hoc signed by the .NET 10 SDK even on Linux** (`LC_CODE_SIGNATURE` present); Apple
Silicon refuses unsigned arm64 code, so never strip it.
- **The Windows single-file publish drops native `.pdb` files beside the exe**; only `meweb.exe` ships.