Files
MeWeb/CLAUDE.md
T
thepraandClaude Opus 5.5 c72a6be433
Build / Desktop, server and tools (push) Successful in 1m25s
Release / Every platform, the server, the release (push) Successful in 8m20s
me-web? 0.5.3: the cards no longer contradict each other
- From the publisher's own network, the tests aimed at its server stay inside the house, so
  the type of address translation, the ports, the hairpin, how long a connection stays open,
  the speed and the round trip described the home network: a report said "hard NAT" next to
  "ports kept". These cards now say the test can't run there, and those messages are not sent.
- Who holds your public IPv4 address is now decided by the same evidence as the address
  sharing card, so a home router behind a provider's shared address no longer reads "your
  router" on one card and "shared with other customers" on the next.
- When your connection differs from what the provider notes say is usual (a shared address,
  port 25), the provider card adds "On this line" with what was found, right under the note.

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

15 KiB

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

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 Beats 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 PathNodeViewModels, 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 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).

  • 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 TextBlocks 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.