Files
reforgermods-launcher/docs/distribution.md
T
SowinskiBraedenandClaude Opus 5 377f3a31b3 initial commit: reforgermods.net launcher 0.1.0
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>
2026-09-24 20:29:00 -07:00

16 KiB
Raw Blame History

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 for how to cut one, and 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.

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 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).

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.

  3. Signing. See What is still outstanding.

Per release

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.

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:

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:

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:

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:

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.