# 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,...] 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/.json`, `Resources/Colors.axaml`; `Resources/Icons.axaml` (Lucide, 24 units, for cards and lists) and `Resources/Art.axaml` (the path's drawings, 48 units: `ArtBase`, 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 | | `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/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. - **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` 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. - **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.