A desktop server browser for Arma Reforger, built on the reforgermods.net v2 API. Live server list with search, sort and filters; a detail inspector with population history and mod readiness; measured ICMP latency; favorites, recents and settings; and direct joining through Steam. Tauri 2 and Rust, with a Preact frontend that builds to 80 KB of JavaScript in one chunk. Logic lives in rfm-core, which has no Tauri dependency and is tested directly; src-tauri is the window, the command surface and the wiring. Packaged for the public beta: a per-user NSIS installer, a portable .exe and an Arch package, with signature-verified background updates on Windows and a release pipeline that builds from a tag. The artifacts are not yet Authenticode-signed — that is wired and waiting on an Azure Trusted Signing identity, and docs/distribution.md says so plainly rather than letting anyone discover it at the SmartScreen dialog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
343 lines
16 KiB
Markdown
343 lines
16 KiB
Markdown
# Distributing the launcher from reforgermods.net
|
||
|
||
**Implemented, with one thing outstanding.** The decisions below are made, the
|
||
pipeline exists (`.github/workflows/release.yml`) and 0.1.0 is built. The gap is
|
||
Authenticode: the release job is wired for Azure Trusted Signing but the
|
||
credentials do not exist yet, so the first beta artifacts are **unsigned** and
|
||
will show the SmartScreen warning. Everything else — NSIS per-user installer,
|
||
signed auto-updates, R2 hosting, the download page — is in place.
|
||
|
||
See [Release procedure](#release-procedure) for how to cut one, and
|
||
[What is still outstanding](#what-is-still-outstanding) for the gap.
|
||
|
||
## The shape, in one line
|
||
|
||
An **NSIS per-user installer** as the primary download, built on **Windows in
|
||
CI**, signed with **Azure Trusted Signing**, hosted on **Cloudflare R2** at
|
||
`dl.reforgermods.net`, with the **Tauri updater** wired so nobody ends up running
|
||
a stale API client.
|
||
|
||
## 1. Installer format
|
||
|
||
Tauri can produce three Windows artifacts. They are not equivalent:
|
||
|
||
| Format | Admin needed | Notes |
|
||
| --- | --- | --- |
|
||
| **NSIS** (`.exe`) | No, with `installMode: currentUser` | Start Menu entry, uninstaller, updater support, smallest |
|
||
| **MSI** (WiX) | Yes | Enterprise deployment, Group Policy. Irrelevant for a game tool |
|
||
| Portable `.exe` | No | No Start Menu, no uninstaller, SmartScreen prompt every time |
|
||
|
||
**NSIS, per-user.** It is already configured. Per-user matters: a UAC prompt on
|
||
a game utility loses people, and there is no reason to write outside the user's
|
||
profile — the launcher already stores everything under the per-user config
|
||
directory.
|
||
|
||
**Done:** `targets` is `["nsis"]`. MSI was removed — it doubled release size and
|
||
build time for an audience that will not use it.
|
||
|
||
The **portable `.exe` is published as a secondary download** for people who will
|
||
not run installers. It costs nothing: it is the same binary the installer wraps,
|
||
and the release job copies it straight out of the build.
|
||
|
||
### WebView2
|
||
|
||
The installer's `webviewInstallMode` decides what happens when the runtime is
|
||
missing. `downloadBootstrapper` (the default) adds ~2 MB and fetches the runtime
|
||
only when absent. Windows 11 and current Windows 10 already ship it, so most
|
||
users never notice. `embedBootstrapper` or `offlineInstaller` only make sense if
|
||
offline installs matter.
|
||
|
||
## 2. Code signing — the decision that actually matters
|
||
|
||
Unsigned, every download shows **"Windows protected your PC"** and requires
|
||
More info → Run anyway. For a launcher whose point is promotion, that is the
|
||
single biggest conversion loss in the whole pipeline.
|
||
|
||
**What changed:** EV certificates used to grant immediate SmartScreen
|
||
reputation. **Microsoft removed that in 2024.** An EV certificate now builds
|
||
reputation the same way an OV one does, so paying EV prices purely to skip
|
||
SmartScreen is no longer justified.
|
||
|
||
| Option | Cost | Verdict |
|
||
| --- | --- | --- |
|
||
| Nothing | £0 | SmartScreen warning on every download. Not viable for a promoted tool |
|
||
| OV certificate | ~£200–400/yr | Signs fine; reputation still has to build |
|
||
| EV certificate | ~£300–600/yr + token/HSM | No longer buys instant reputation. Hard to justify |
|
||
| **Azure Trusted Signing** | **$9.99/mo** (5,000 signatures) | Cheapest credible route; no hardware token; fits CI cleanly |
|
||
|
||
**Recommended: Azure Trusted Signing** (renamed Azure Artifact Signing).
|
||
Eligibility is verified US / Canada / EU / UK businesses and self-employed
|
||
individuals — `cedarline.digital` should qualify, and the organisation identity
|
||
is what appears in the UAC and SmartScreen dialogs.
|
||
|
||
Be realistic about reputation: signing removes the *unknown publisher* framing,
|
||
but SmartScreen reputation still accrues with download volume. Expect some
|
||
friction on the first releases regardless of route. The mitigation is to keep
|
||
one stable signing identity rather than rotating certificates, so reputation
|
||
accumulates against one publisher.
|
||
|
||
**State:** chosen, wired, not yet active. The release job signs through
|
||
`bundle.windows.signCommand`, supplied by `src-tauri/tauri.signing.conf.json` and
|
||
run by `packaging/windows/sign.cmd`, which shells out to `trusted-signing-cli`.
|
||
|
||
Signing has to happen *during* bundling rather than after it, which is why it is
|
||
a `signCommand` and not a later CI step: the updater artifact is the installer
|
||
plus a detached signature over it. Sign the installer afterwards and the
|
||
signature describes a file nobody will download, and every client rejects the
|
||
update.
|
||
|
||
The job checks for `AZURE_CLIENT_ID` and `RFM_SIGN_ENDPOINT` and, when they are
|
||
absent, builds unsigned and prints a warning to the run summary rather than
|
||
failing. That is deliberate: it keeps the beta shippable while the Azure identity
|
||
is sorted out, and it makes the unsigned state loud instead of silent. See
|
||
[What is still outstanding](#what-is-still-outstanding).
|
||
|
||
## 3. Build and release pipeline
|
||
|
||
Cross-compiling from Linux works for the bare `.exe` (`scripts/build-windows.sh`)
|
||
and is fine for development, but the release build should run on Windows:
|
||
|
||
- NSIS bundling and signing both want Windows tooling.
|
||
- The signed artifact should be produced by CI, not a laptop, so releases are
|
||
reproducible and the signing credential lives in one place.
|
||
|
||
**Done:** `.github/workflows/release.yml`. A `windows-latest` job on a `v*` tag
|
||
runs the full check suite, builds, signs when it can, and uploads the artifacts;
|
||
a second job generates the manifests and publishes to R2. It is stored under
|
||
`.github/workflows` so both GitHub Actions and Gitea Actions pick it up, matching
|
||
the other two repositories.
|
||
|
||
The job refuses to build unless the tag and every version in the tree already
|
||
agree (`scripts/version.sh check "$tag"`), so a mistagged release fails in the
|
||
first thirty seconds rather than after producing artifacts.
|
||
|
||
**Hosting: Cloudflare R2**, served at `dl.reforgermods.net`. It sits on the
|
||
account that already runs the site, keeps the project free of a GitHub
|
||
dependency, and downloads never touch the API's budget. The trade is that the
|
||
bandwidth is ours; for an installer of about 2 MB that is not a real cost at beta
|
||
volumes. See [R2 setup](#r2-setup) for the one-time configuration.
|
||
|
||
Versioned paths are immutable and only two small files move — `/releases.json`
|
||
and `/updates/latest.json` — which is what makes a rollback a matter of
|
||
re-publishing one file rather than moving binaries around.
|
||
|
||
## 4. Auto-update
|
||
|
||
`tauri-plugin-updater` checks a JSON manifest, verifies a signature against a
|
||
public key baked into the build, and applies the update.
|
||
|
||
This matters more than usual here: the launcher is an API client. When
|
||
`/v2/servers` changes shape, users on an old build get a broken browser and
|
||
blame the site. An updater turns that from a support problem into a non-event.
|
||
|
||
Needs a keypair (`tauri signer generate`) — the private key is a CI secret, the
|
||
public key ships in `tauri.conf.json`. Distinct from code signing; both are
|
||
needed and they solve different problems.
|
||
|
||
**Done**, in `src-tauri/src/update.rs`. The keypair exists and its public half is
|
||
in `tauri.conf.json`; the private key is **not** in this repository and has to be
|
||
added to CI as `TAURI_SIGNING_PRIVATE_KEY` (see
|
||
[Release procedure](#release-procedure)).
|
||
|
||
The behaviour is exactly as proposed, and it is three steps rather than
|
||
`download_and_install` for a reason:
|
||
|
||
1. check and download in the background, so nothing blocks startup;
|
||
2. hold the downloaded installer until the session ends;
|
||
3. run it as the app exits, with `restart_after_install(false)`.
|
||
|
||
Nobody is interrupted mid-session and nobody is asked to make a decision about a
|
||
patch release; the update is simply there next time. `Update::install` calls
|
||
`std::process::exit(0)` on Windows after launching the installer, which is
|
||
harmless at exit and would be a hard kill at any other moment — which is the
|
||
other reason the install is pinned to that exact point.
|
||
|
||
Every failure path is silent: no endpoint, no network, a malformed manifest or a
|
||
bad signature all leave the app as it was. `RFM_DEBUG=1` is how it becomes
|
||
visible.
|
||
|
||
**Windows only.** The dependency is scoped to Windows in `Cargo.toml`, so the
|
||
module does not exist in a Linux build. The Arch package belongs to pacman, and
|
||
an app that rewrites its own files under `/usr/bin` fights the package manager.
|
||
|
||
## 5. Download page
|
||
|
||
**Done**, in the `reforgermods-web` repository: `/launcher/` with
|
||
`public/static/launcher.js` and `public/static/launcher/releases.json`.
|
||
|
||
- Detects the visitor's platform and offers the matching build as the primary
|
||
action; an unrecognised platform gets the full table rather than a guess.
|
||
- Lists every download with its size and SHA-256.
|
||
- States the WebView2 requirement as a footnote — nearly nobody needs it.
|
||
- The page ships a working pre-release state in its HTML and the script only ever
|
||
upgrades it, so a failed fetch leaves an honest panel rather than a dead button.
|
||
|
||
The page reads **its own copy** of `releases.json`, served from the site origin
|
||
rather than from R2. That avoids a cross-origin dependency on the download host
|
||
for a page that must render, but it means a release is not visible until that
|
||
file is updated and the site is deployed. The release job says so in its summary.
|
||
|
||
## 6. Versioning
|
||
|
||
**Done:** `scripts/version.sh`.
|
||
|
||
scripts/version.sh show print the canonical version
|
||
scripts/version.sh check [tag] fail if any copy disagrees
|
||
scripts/version.sh set 0.2.0 rewrite every copy
|
||
|
||
The canonical value is `[workspace.package] version` in the root `Cargo.toml`,
|
||
because that is the one the running binary reports: `rfm-core` builds its
|
||
User-Agent from `CARGO_PKG_VERSION`, so the API's per-version rollups are keyed
|
||
off it whatever the other files say.
|
||
|
||
It still has to be repeated in four places — the workspace manifest,
|
||
`package.json`, `tauri.conf.json` and the PKGBUILD — because no two of those
|
||
tools read each other's format. `check` is what makes the repetition safe, and
|
||
the release job runs it against the tag before building anything. An updater
|
||
manifest that disagrees with the installed version either loops forever or never
|
||
fires, and neither failure is obvious from the outside.
|
||
|
||
## 7. Linux
|
||
|
||
Not a distribution channel yet — but the app does build and run there, and
|
||
`scripts/build-arch.sh` produces a native Arch package
|
||
(`target/arch/*.pkg.tar.zst`) for handing to a tester. It builds inside an
|
||
`archlinux:base-devel` container so the binary links Arch's WebKit and glibc
|
||
rather than the build host's; `packaging/arch/PKGBUILD` holds the recipe and
|
||
README covers the two makepkg workarounds it needs.
|
||
|
||
Verified end to end: installed into a clean `archlinux:base` container with
|
||
`pacman -U`, every dependency resolved from the declared `depends`, and the app
|
||
started under Xvfb, loaded the live server list and resolved pings over
|
||
unprivileged ICMP.
|
||
|
||
**Changed for the beta:** the Arch package is now published alongside the Windows
|
||
builds rather than handed over per tester. It appears on the download page for
|
||
visitors the page detects as Arch, and in the table for everyone else.
|
||
|
||
That is one distribution, not a Linux strategy. `.deb`, AppImage and an AUR
|
||
package are all still unbuilt, and `public/static/launcher.js` already knows
|
||
those platform ids so adding them is a packaging job, not a web one. The updater
|
||
deliberately does not cover any of them: a distro package is updated by the
|
||
distro's tooling, not by the app rewriting its own files in `/usr/bin`.
|
||
|
||
## Release procedure
|
||
|
||
One-time setup, then four steps per release.
|
||
|
||
### One-time
|
||
|
||
1. **Updater key.** Generated already; the public half is in `tauri.conf.json`.
|
||
The private key is at `~/.config/reforgermods-launcher/updater.key` on the
|
||
machine it was generated on and **is not in this repository**. Add it to CI:
|
||
|
||
- `TAURI_SIGNING_PRIVATE_KEY` — the file's contents
|
||
- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` — empty; the key was generated without
|
||
one, on the grounds that the key is already a secret in the same store and a
|
||
password beside it adds a second secret rather than a second factor.
|
||
Regenerate with a password if you disagree; both have to change together.
|
||
|
||
Back it up. Losing it means every installed launcher stops accepting updates,
|
||
and the only fix is a new key in a new release that everyone installs by hand.
|
||
|
||
2. **R2.** See [R2 setup](#r2-setup).
|
||
|
||
3. **Signing.** See [What is still outstanding](#what-is-still-outstanding).
|
||
|
||
### Per release
|
||
|
||
```sh
|
||
scripts/version.sh set 0.1.1 # rewrites all four version copies
|
||
# update the changelog in reforgermods-web: src/app/launcher/changelog/page.tsx
|
||
git commit -am "release 0.1.1" && git tag v0.1.1 && git push --follow-tags
|
||
```
|
||
|
||
The tag starts the pipeline. When it finishes, take `releases.json` from the run
|
||
artifacts, drop it into `public/static/launcher/releases.json` in
|
||
`reforgermods-web`, and deploy — until then the page still offers the previous
|
||
release.
|
||
|
||
### Building artifacts by hand
|
||
|
||
The release job is the supported path; these are for testing what it will
|
||
produce. Neither can Authenticode-sign, because that needs a Windows host.
|
||
|
||
```sh
|
||
scripts/build-windows.sh --release # the bare .exe
|
||
scripts/build-arch.sh # target/arch/*.pkg.tar.zst
|
||
```
|
||
|
||
A full Windows bundle can be cross-built on Linux, but Tauri calls this
|
||
experimental and it needs NSIS present:
|
||
|
||
```sh
|
||
export TAURI_SIGNING_PRIVATE_KEY="$(cat ~/.config/reforgermods-launcher/updater.key)"
|
||
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD=""
|
||
npm run tauri -- build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis
|
||
```
|
||
|
||
Debian's `makensis` has `/usr/share/nsis` compiled in as its data directory and
|
||
the bundler does not pass `NSISDIR` through, so a rootless install needs a
|
||
wrapper on `PATH` that exports it before exec'ing the real binary. 0.1.0 was
|
||
built this way and its updater signature verifies against the shipped public key.
|
||
|
||
Then assemble and describe the result:
|
||
|
||
```sh
|
||
python3 scripts/release-manifest.py --version "$(scripts/version.sh show)" \
|
||
--artifacts dist/release --base-url https://dl.reforgermods.net --out dist/release
|
||
```
|
||
|
||
It writes both manifests from one scan of one directory, so the version, the URLs
|
||
and the filenames cannot disagree between the download page and the updater. An
|
||
artifact with no sibling `.sig` is never offered to the updater: the client would
|
||
reject it, and every launch would check, fail and retry.
|
||
|
||
## R2 setup
|
||
|
||
One bucket, public over a custom domain:
|
||
|
||
```sh
|
||
npx wrangler r2 bucket create reforgermods-launcher-downloads
|
||
```
|
||
|
||
Then, in the Cloudflare dashboard, attach `dl.reforgermods.net` to the bucket
|
||
(R2, the bucket, Settings, Public access, Custom domain). The release job needs
|
||
`CLOUDFLARE_API_TOKEN` (Workers R2 Storage: Edit) and `CLOUDFLARE_ACCOUNT_ID` as
|
||
secrets on the `release` environment.
|
||
|
||
Layout:
|
||
|
||
```text
|
||
dl.reforgermods.net/
|
||
0.1.0/…-setup.exe installer, and the updater artifact
|
||
0.1.0/…-setup.exe.sig detached updater signature
|
||
0.1.0/…-0.1.0.exe portable
|
||
0.1.0/…-x86_64.pkg.tar.zst Arch package
|
||
0.1.0/SHA256SUMS
|
||
updates/latest.json what the updater polls
|
||
releases.json published for reference; the site reads its own copy
|
||
```
|
||
|
||
Everything under a version prefix is immutable. Only the two manifests move.
|
||
|
||
## What is still outstanding
|
||
|
||
**Authenticode signing.** The pipeline is wired and inert. To turn it on:
|
||
|
||
1. Get a **verified Trusted Signing identity** for `cedarline.digital` and an
|
||
Azure subscription that owns it. This is the blocking item — it is an identity
|
||
verification, not a purchase, and it takes as long as it takes.
|
||
2. Create a Trusted Signing account and certificate profile.
|
||
3. Add to the `release` environment — secrets `AZURE_CLIENT_ID`,
|
||
`AZURE_CLIENT_SECRET`, `AZURE_TENANT_ID`; variables `RFM_SIGN_ENDPOINT`,
|
||
`RFM_SIGN_ACCOUNT`, `RFM_SIGN_PROFILE`.
|
||
|
||
The job picks it up on the next tag with no code change. `packaging/windows/sign.cmd`
|
||
has never run on a Windows host, so budget one throwaway tag to shake it out
|
||
rather than discovering it on a release you meant to publish.
|
||
|
||
Until then the beta is unsigned and every download shows **"Windows protected
|
||
your PC"**. The download page says so plainly rather than letting people discover
|
||
it at the SmartScreen dialog — an honest note costs less trust than a surprise.
|