- The first screen now starts from what a home connection could do for you: a drawing of your home's internet box with the things it could host circling it (family photos, a website, reaching home from outside, a game server, a blog, mail), the question in plain words, why it matters, and one button, "Check this connection". - Below it, the six things the check answers, and a promise: nothing on your network changes without asking you first, and the results stay on your device unless you share them. - The codes for installing the app on another device are folded away at the bottom; open them when you need them. - While the check runs, the offer to share your findings already waits at the end of the page, getting them ready, and becomes a button as soon as they are complete. - Long button labels and the translation notice no longer run off narrow screens. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
186 lines
19 KiB
Markdown
186 lines
19 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/strings.cs -- quote # put every “me-web?” between its language's quotation marks
|
|
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`, and `quotes`, the language's own quotation marks from CLDR (fixed by hand where CLDR has none or is wrong) |
|
|
| `data/glossary/<code>.json` | per language, the plain words chosen for about forty recurring ideas (provider, router, port, name server, …), so a card never calls one thing by two names; written with the translations and read by whoever reviews them |
|
|
| `data/isps/*.json` | provider profiles: AS numbers, and per fact a value, who said it (`basis`), the source URL and the month |
|
|
| `data/observed.json` | notes counted from shared findings and accepted by a person: per AS number and line (`home`, `mobile`), IPv4 shared or public, IPv6 offered or not, port 25, each with how many networks agree; embedded in every release |
|
|
| `src/MeWeb.Server/ObservedNotes.cs` | every six hours (with the share sweep) counts the shared findings into `/var/lib/meweb/observed.json` on the box: one vote per /24 or /48, the latest check of each network, the publisher's own network left out; nothing is served |
|
|
| `tools/observed.cs` | `show` prints that count, `accept <file> AS<n>/<line> ...` copies the ready notes (at least 5 networks, 4 in 5 agreeing) into `data/observed.json` |
|
|
| `src/MeWeb.Server/KnowledgeFeeder.cs` | refreshes the open network data under `/var/lib/meweb/knowledge` every six hours (iptoasn daily, DB-IP monthly, eight RouteViews RIBs weekly through `bzcat` (one or two collectors per continent, `Collectors`; each counts once per network in `UpstreamTally`, and a path's first AS, the collector's own neighbour, is never taken for an upstream), the AOSP carrier list weekly), 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 publisher's own network measures the house, not the line.** When the public IPv4 equals the server's, or
|
|
the IPv6 shares its /48 (`Addresses.SameNetwork`, `RunReport.OwnNetwork`), everything aimed at the server loops
|
|
back through the FRITZ!Box: the NAT type, the port burst, hairpin, lifetime, speed and round trip. Those cards then
|
|
say `ReachOwnNetwork` and the burst is not sent; before 0.5.3 a home report read "hard NAT" next to "ports kept".
|
|
- **One verdict decides who holds the IPv4.** `Verdicts.Holder(NatOf(...))` feeds both the address card and the
|
|
provider card's "On this line" row, so a home router behind CGNAT can no longer read "your router" on one card and
|
|
"shared with other customers" on the next. Provider notes that the check contradicts (sharing, port 25) get that row
|
|
right under them, added when the provider card is settled again at the end of the run.
|
|
- **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).
|
|
- **A note from shared findings never ships without a person.** The consent screen and the privacy page promise
|
|
"only totals without addresses, after a person has reviewed them, from at least five networks agreeing four in
|
|
five"; `ObservedFact.Ready` is that rule, `tools/observed.cs accept` the review. Hand-written profile facts win over
|
|
observed ones; observed facts fill the gaps, and on mobile data they replace the home-line profile entirely.
|
|
- **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, quoted or not) 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 name is always quoted, in each language's own marks.** “me-web?” in English, «me-web?» in Italian, „me-web?“
|
|
in German, « me-web? » (no-break spaces) in French, 「me-web?」 in Japanese: unquoted, the question mark reads as the
|
|
end of a sentence ("What me-web? found"). `tools/strings.cs check` fails on a bare name; `AppName` is the title. In
|
|
right-to-left languages the `Localizer` wraps the name in U+2066/U+2069 (left-to-right isolate), the English fallback
|
|
included: without it Avalonia lays out ”me-web?“ alone on a line as `me-web?”“`, and inside a sentence the `?`
|
|
jumps to the other end of the name. A left-to-right mark after the name was tried and does not fix the lone title.
|
|
- **Avalonia 12 refreshes an indexer binding only on `PropertyChanged("Item")`.** `{s:Tr Key}` binds `Localizer.Instance[Key]`;
|
|
the WPF name `"Item[]"` and `""` refresh nothing, so until 0.7.1 changing the language left every `{s:Tr}` text in
|
|
the old one while the view models' `*Formatted` strings followed. The screenshot tool draws each `start-<code>`
|
|
shot by switching from English on a live view, which is the check that would have caught it.
|
|
- **The first screen sells the outcome, not the test.** `Views/HomeOrbit` draws the home's router with the six uses
|
|
circling it on a tilted ring (depth from scale, opacity and z-order, one turn in 40 s, frames from
|
|
`RequestAnimationFrame` only while it is visible); the question, one sentence of why, the button and the six uses
|
|
as chips follow, and the QR codes wait folded at the bottom. While a check runs, the share card sits at the end in
|
|
a muted "getting them ready" state (`ShowSharePreparing`) and turns into the offer when the findings are complete.
|
|
- **Values worth copying are read-only text boxes with a copy button inside** (`Views/CopyField`): addresses, the
|
|
router, the network, the reverse name, the route, the STUN answers and a submission's code. The box is
|
|
left-to-right in every language and copies the raw value, never the bidi marks `Texts.Isolate` adds to display
|
|
text. Which facts get one is the `Copyable` list in `FactViewModel`.
|
|
- **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.
|