2026-10-15 01:14:44 +02:00
# 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,...]
2026-10-15 09:22:16 +02:00
tools/release.sh notes # artifacts/release-notes.md alone, nothing built
2026-10-15 01:18:13 +02:00
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
2026-10-15 04:39:03 +02:00
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
2026-10-15 01:18:13 +02:00
dotnet run tools/languages.cs # regenerate data/languages.json from CLDR
2026-10-15 01:14:44 +02:00
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).
2026-10-15 01:24:11 +02:00
## 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.
2026-10-15 01:14:44 +02:00
## Map
| Path | What |
|---|---|
| `src/MeWeb.Shared` | `Endpoints.cs` (routes and hosts), wire contracts, `MeWebJsonContext` , generated `BuildInfo` |
2026-10-15 05:41:44 +02:00
| `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` ) |
2026-10-15 08:08:37 +02:00
| `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 |
2026-10-15 01:14:44 +02:00
| `src/MeWeb.Desktop` | desktop head (`meweb` executable), Windows manifest |
| `src/MeWeb.Android` | Android head (`software.arasaka.meweb` ), resources, manifest |
2026-10-15 05:41:44 +02:00
| `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` ) |
2026-10-15 01:18:13 +02:00
| `data/languages.json` | 113 languages: code, autonym, English name, `rtl` , `reviewed` or `draft` |
2026-10-15 04:39:03 +02:00
| `data/isps/*.json` | provider profiles: AS numbers, and per fact a value, who said it (`basis` ), the source URL and the month |
2026-10-15 11:11:24 +02:00
| `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 |
2026-10-15 01:14:44 +02:00
| `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
2026-10-15 04:39:03 +02:00
- **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` .
2026-10-15 05:41:44 +02:00
- **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.
2026-10-15 04:39:03 +02:00
- **An address inside right-to-left text reorders around `→` .** `Texts.Isolate` wraps every literal value in U+2068/U+2069.
2026-10-15 09:10:37 +02:00
- **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).
2026-10-15 11:11:24 +02:00
- **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.
2026-10-15 09:10:37 +02:00
- **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.
2026-10-15 08:08:37 +02:00
- **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).
2026-10-15 04:39:03 +02:00
2026-10-15 01:14:44 +02:00
- **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.
2026-10-15 08:31:10 +02:00
- **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.
2026-10-15 09:22:16 +02:00
- **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.
2026-10-15 01:14:44 +02:00
- **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.