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>
This commit is contained in:
SowinskiBraedenandClaude Opus 5 committed 2026-09-24 20:29:00 -07:00
commit 377f3a31b3
91 files changed
+24994

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
# Build a native Arch Linux package (.pkg.tar.zst) from this checkout.
#
# scripts/build-arch.sh [--no-check]
#
# The build runs inside an `archlinux:base-devel` container, so the binary is
# linked against Arch's own glibc and WebKit rather than this machine's. That
# matters: a Tauri app built on Debian links libwebkit2gtk-4.1 with Debian's
# soname and ABI, and handing that to an Arch user is how you get a blank
# window or a missing-symbol crash. Building on Arch removes the question.
#
# Output: target/arch/reforgermods-launcher-<version>-1-x86_64.pkg.tar.zst,
# installed on the target machine with `sudo pacman -U <file>`. pacman resolves
# webkit2gtk-4.1 and the rest, so there is nothing else to install by hand.
#
# Docker is only a clean Arch userspace here — nothing is deployed and no
# daemon is left running. --no-check skips the rfm-core test suite.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE="${RFM_ARCH_IMAGE:-rfm-arch-build}"
PKGBUILD="$REPO/packaging/arch/PKGBUILD"
STAGE="$REPO/target/arch"
MAKEPKG_ARGS=(--force --noconfirm --clean)
if [[ "${1:-}" == "--no-check" ]]; then
MAKEPKG_ARGS+=(--nocheck)
fi
command -v docker >/dev/null || { echo "docker is required" >&2; exit 1; }
docker info >/dev/null 2>&1 || { echo "the docker daemon is not reachable" >&2; exit 1; }
# 1. One version, agreed in both places.
#
# tauri.conf.json is what the app reports about itself, so it is the source of
# truth; the PKGBUILD carries its own copy because makepkg needs it literally.
# A silent disagreement here ships a package whose pacman version does not
# match the version in the window, so it is checked rather than assumed.
CONF_VERSION="$(sed -n 's/^ "version": "\(.*\)",$/\1/p' "$REPO/src-tauri/tauri.conf.json")"
PKG_VERSION="$(sed -n 's/^pkgver=\(.*\)$/\1/p' "$PKGBUILD")"
if [[ -z "$CONF_VERSION" || -z "$PKG_VERSION" ]]; then
echo "ERROR: could not read the version from tauri.conf.json or the PKGBUILD." >&2
exit 1
fi
if [[ "$CONF_VERSION" != "$PKG_VERSION" ]]; then
echo "ERROR: version mismatch — tauri.conf.json says $CONF_VERSION, PKGBUILD says $PKG_VERSION." >&2
echo "Update pkgver in packaging/arch/PKGBUILD (and reset pkgrel to 1)." >&2
exit 1
fi
VERSION="$CONF_VERSION"
# 2. The build image: Arch plus the toolchains, and a user whose uid matches
# this one so everything written to the bind mount stays owned by the caller.
if [[ -n "${RFM_ARCH_REBUILD_IMAGE:-}" ]] || ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
echo "==> building the $IMAGE container image"
docker build -t "$IMAGE" \
--build-arg "UID=$(id -u)" --build-arg "GID=$(id -g)" - <<'DOCKERFILE'
FROM archlinux:base-devel
ARG UID=1000
ARG GID=1000
RUN pacman -Syu --noconfirm --needed rust nodejs npm webkit2gtk-4.1 cmake \
&& pacman -Scc --noconfirm
RUN groupadd -g "$GID" builder 2>/dev/null || true \
&& useradd -u "$UID" -g "$GID" -m -d /home/builder builder 2>/dev/null || true \
&& mkdir -p /cache/cargo /cache/npm /build \
&& chown -R "$UID:$GID" /cache /build /home/builder
USER $UID:$GID
ENV CARGO_HOME=/cache/cargo npm_config_cache=/cache/npm
WORKDIR /build
DOCKERFILE
fi
# 3. The source tarball makepkg consumes: the working tree, minus everything
# that is generated or host-specific. target/ especially — those are Debian
# build artifacts and cargo would happily reuse the stale ones.
echo "==> staging sources for $VERSION"
rm -rf "$STAGE"
mkdir -p "$STAGE"
tar --create --gzip --file "$STAGE/reforgermods-launcher-$VERSION.tar.gz" \
--directory "$REPO" \
--transform "s,^\.,reforgermods-launcher-$VERSION," \
--exclude=./.git \
--exclude=./node_modules \
--exclude=./target \
--exclude=./dist \
--exclude=./src-tauri/target \
--exclude=./src-tauri/gen/schemas \
.
cp "$PKGBUILD" "$STAGE/PKGBUILD"
# 4. Named volumes keep the cargo registry and npm cache across runs, so only
# the first build pays for downloading the dependency tree.
docker volume create rfm-arch-cargo >/dev/null
docker volume create rfm-arch-npm >/dev/null
docker run --rm --user root \
-v rfm-arch-cargo:/cache/cargo -v rfm-arch-npm:/cache/npm \
"$IMAGE" chown "$(id -u):$(id -g)" /cache/cargo /cache/npm
echo "==> running makepkg in $IMAGE"
docker run --rm \
-v "$STAGE:/build" \
-v rfm-arch-cargo:/cache/cargo \
-v rfm-arch-npm:/cache/npm \
"$IMAGE" makepkg "${MAKEPKG_ARGS[@]}"
PKG="$(ls -t "$STAGE"/*.pkg.tar.zst 2>/dev/null | head -1)"
if [[ -z "$PKG" ]]; then
echo "ERROR: makepkg produced no package." >&2
exit 1
fi
# 5. Checksum, for handing the file over.
#
# The package is already known to be a production build rather than a dev one:
# build() asserts that before packaging, which is the only point where the
# evidence still exists.
sha256sum "$PKG" | sed "s,$STAGE/,," > "$STAGE/SHA256SUMS"
echo
echo "built: $PKG"
ls -lh "$PKG"
echo
echo "install on Arch with: sudo pacman -U $(basename "$PKG")"
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# Cross-compile a Windows executable from Linux — no Visual Studio, no root.
#
# scripts/build-windows.sh [--debug]
#
# Produces target/x86_64-pc-windows-msvc/<profile>/reforgermods-launcher.exe,
# a standalone binary (the frontend is embedded at compile time). It needs the
# WebView2 runtime on the target machine, which ships with current Windows.
#
# Release is the default, and for a distributable binary it is the only correct
# choice: tauri-build sets `cfg(dev)` for debug profiles, and a `dev` build
# loads the UI from the Vite dev server on localhost:1420 rather than from the
# embedded assets. Run a debug build on a machine without `npm run dev` going
# and you get a blank window. `--debug` exists for attaching a debugger, and
# the script checks the result and says so.
#
# How it works: cargo-xwin downloads the MSVC CRT and Windows SDK from
# Microsoft into ~/.cache/cargo-xwin (~1.5 GB, once), and LLVM's clang-cl and
# lld-link stand in for cl.exe and link.exe. The LLVM pieces are fetched as
# Debian packages and unpacked into a local prefix, so nothing is installed
# system-wide and no sudo is required.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TOOLS="${RFM_WIN_TOOLS:-$HOME/.cache/rfm-win-tools}"
LLVM_VERSION="${RFM_LLVM_VERSION:-19}"
PROFILE_ARGS=(--release)
PROFILE_DIR="release"
if [[ "${1:-}" == "--debug" ]]; then
PROFILE_ARGS=()
PROFILE_DIR="debug"
fi
PREFIX="$TOOLS/root/usr/lib/llvm-$LLVM_VERSION/bin"
# 1. LLVM cross-linking tools, unpacked locally.
if [[ ! -x "$PREFIX/lld-link" || ! -e "$PREFIX/clang-cl" ]]; then
echo "==> fetching LLVM $LLVM_VERSION tools into $TOOLS"
mkdir -p "$TOOLS"
(
cd "$TOOLS"
# clang provides clang-cl; lld provides lld-link; libclang-cpp is clang's runtime.
apt-get download "clang-$LLVM_VERSION" "lld-$LLVM_VERSION" "libclang-cpp$LLVM_VERSION"
for deb in *.deb; do dpkg-deb -x "$deb" ./root; done
)
# clang-cl is a driver mode of clang, not always shipped as its own file.
[[ -e "$PREFIX/clang-cl" ]] || ln -s clang "$PREFIX/clang-cl"
fi
export PATH="$PREFIX:/usr/lib/llvm-$LLVM_VERSION/bin:$PATH"
export LD_LIBRARY_PATH="$TOOLS/root/usr/lib/llvm-$LLVM_VERSION/lib:${LD_LIBRARY_PATH:-}"
for tool in clang-cl lld-link llvm-rc llvm-lib; do
command -v "$tool" >/dev/null || { echo "missing $tool" >&2; exit 1; }
done
# 2. cargo-xwin, which supplies the MSVC CRT and Windows SDK.
command -v cargo-xwin >/dev/null || cargo install cargo-xwin --locked
# cargo-xwin keeps its own clang-cl symlink and refuses to start if a stale one
# points somewhere else (for example a previous checkout's tools prefix).
XWIN_LINK="${XWIN_CACHE_DIR:-$HOME/.cache/cargo-xwin}/clang-cl"
if [[ -L "$XWIN_LINK" && "$(readlink -f "$XWIN_LINK")" != "$(readlink -f "$PREFIX/clang-cl")" ]]; then
rm -f "$XWIN_LINK"
fi
# 3. The frontend, embedded into the binary by tauri-build.
echo "==> building frontend"
cd "$REPO"
npm run build
echo "==> cross-compiling for x86_64-pc-windows-msvc"
# --features custom-protocol is what makes this a production build; see the
# comment on that feature in src-tauri/Cargo.toml.
cargo xwin build --target x86_64-pc-windows-msvc -p reforgermods-launcher \
--features custom-protocol "${PROFILE_ARGS[@]}"
EXE="$REPO/target/x86_64-pc-windows-msvc/$PROFILE_DIR/reforgermods-launcher.exe"
echo
echo "built: $EXE"
ls -lh "$EXE"
# Assert the binary is not a dev build.
#
# You cannot check this by grepping the exe: Tauri brotli-compresses the
# embedded assets, and it embeds the whole config (devUrl included) either way.
# The signal is `cargo:rustc-cfg=dev` from tauri-build, which cargo records in
# the build script's output file. A dev build looks completely fine here and
# fails with ERR_CONNECTION_REFUSED on the target machine, so it is worth
# asserting rather than assuming.
BUILD_OUT=$(ls -t "$REPO/target/x86_64-pc-windows-msvc/$PROFILE_DIR"/build/reforgermods-launcher-*/output 2>/dev/null | head -1)
if [[ -z "$BUILD_OUT" ]]; then
echo "WARNING: could not find the build script output to verify the build mode." >&2
elif grep -q "rustc-cfg=dev" "$BUILD_OUT"; then
echo
echo "ERROR: this is a DEV build. It loads its UI from the Vite dev server on" >&2
echo "localhost:1420 and will show ERR_CONNECTION_REFUSED anywhere else." >&2
echo "The --features custom-protocol flag did not take effect." >&2
exit 1
else
echo "build mode: production (frontend embedded, no dev-server dependency)"
fi
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""Derive the launcher's icons from the reforgermods.net brand assets.
No image libraries, so this is reproducible from a clean checkout with nothing
but a Python interpreter. Run from the repo root:
python3 scripts/gen_icons.py
Sources (from the reforgermods.net site assets, copied into brand/):
reforger-mods-app-icon.png -> application / taskbar icon, all sizes
reforger-mods-icon-dark.png -> transparent mark used in the app header
Downsampling is a box filter on premultiplied alpha. Premultiplying matters:
averaging straight RGBA makes fully transparent pixels drag their colour into
the edge, which shows up as a dark halo around the mark at 16px.
"""
import struct
import zlib
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
BRAND = ROOT / "brand"
ICONS = ROOT / "src-tauri" / "icons"
UI_ASSETS = ROOT / "src" / "assets"
# ----------------------------------------------------------------- decoding
def read_png(path):
"""Decodes an 8-bit RGBA PNG into (width, height, rows of bytes)."""
data = path.read_bytes()
if data[:8] != b"\x89PNG\r\n\x1a\n":
raise ValueError(f"{path} is not a PNG")
width = height = None
idat = bytearray()
pos = 8
while pos < len(data):
length, tag = struct.unpack_from(">I4s", data, pos)
body = data[pos + 8 : pos + 8 + length]
if tag == b"IHDR":
width, height, depth, colour = struct.unpack(">IIBB", body[:10])
if depth != 8 or colour != 6:
raise ValueError(f"{path}: expected 8-bit RGBA, got depth={depth} colour={colour}")
elif tag == b"IDAT":
idat += body
elif tag == b"IEND":
break
pos += 12 + length
raw = zlib.decompress(bytes(idat))
stride = width * 4
rows = []
previous = bytearray(stride)
pos = 0
for _ in range(height):
filter_type = raw[pos]
pos += 1
line = bytearray(raw[pos : pos + stride])
pos += stride
unfilter(filter_type, line, previous, 4)
rows.append(bytes(line))
previous = line
return width, height, rows
def unfilter(filter_type, line, previous, bpp):
"""Reverses one PNG scanline filter in place (RFC 2083 section 6)."""
if filter_type == 0:
return
for i in range(len(line)):
a = line[i - bpp] if i >= bpp else 0
b = previous[i]
if filter_type == 1:
line[i] = (line[i] + a) & 0xFF
elif filter_type == 2:
line[i] = (line[i] + b) & 0xFF
elif filter_type == 3:
line[i] = (line[i] + ((a + b) >> 1)) & 0xFF
elif filter_type == 4:
c = previous[i - bpp] if i >= bpp else 0
p = a + b - c
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
pred = a if (pa <= pb and pa <= pc) else (b if pb <= pc else c)
line[i] = (line[i] + pred) & 0xFF
else:
raise ValueError(f"unknown filter {filter_type}")
# --------------------------------------------------------------- resampling
def resize(width, height, rows, size):
"""Box-filter downsample to size x size, averaging premultiplied alpha."""
out = []
for oy in range(size):
y0, y1 = oy * height // size, max(oy * height // size + 1, (oy + 1) * height // size)
row = bytearray()
for ox in range(size):
x0, x1 = ox * width // size, max(ox * width // size + 1, (ox + 1) * width // size)
r = g = b = a = 0
count = 0
for y in range(y0, y1):
line = rows[y]
for x in range(x0, x1):
i = x * 4
alpha = line[i + 3]
# Premultiply so transparent pixels contribute no colour.
r += line[i] * alpha
g += line[i + 1] * alpha
b += line[i + 2] * alpha
a += alpha
count += 1
if a == 0:
row += bytes(4)
else:
row += bytes((r // a, g // a, b // a, a // count))
out.append(bytes(row))
return out
# ----------------------------------------------------------------- encoding
def write_png(rows, size):
def chunk(tag, body):
payload = tag + body
return struct.pack(">I", len(body)) + payload + struct.pack(">I", zlib.crc32(payload))
raw = b"".join(b"\x00" + r for r in rows)
return (
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw, 9))
+ chunk(b"IEND", b"")
)
def write_ico(entries):
"""Packs PNG-encoded images into an .ico container."""
header = struct.pack("<HHH", 0, 1, len(entries))
offset = 6 + 16 * len(entries)
directory, blobs = b"", b""
for size, blob in entries:
directory += struct.pack(
"<BBBBHHII", size % 256, size % 256, 0, 0, 1, 32, len(blob), offset
)
blobs += blob
offset += len(blob)
return header + directory + blobs
# --------------------------------------------------------------------- main
def main():
ICONS.mkdir(parents=True, exist_ok=True)
UI_ASSETS.mkdir(parents=True, exist_ok=True)
app_w, app_h, app_rows = read_png(BRAND / "reforger-mods-app-icon.png")
cache = {}
def app_at(size):
if size not in cache:
cache[size] = write_png(resize(app_w, app_h, app_rows, size), size)
return cache[size]
for name, size in [
("32x32.png", 32),
("128x128.png", 128),
("128x128@2x.png", 256),
("icon.png", 512),
]:
(ICONS / name).write_bytes(app_at(size))
print(f"wrote src-tauri/icons/{name} ({size}x{size})")
(ICONS / "icon.ico").write_bytes(write_ico([(s, app_at(s)) for s in (16, 32, 48, 256)]))
print("wrote src-tauri/icons/icon.ico (16, 32, 48, 256)")
# Transparent mark for the in-app header. 64px covers an 18px slot at 3x.
mark_w, mark_h, mark_rows = read_png(BRAND / "reforger-mods-icon-dark.png")
blob = write_png(resize(mark_w, mark_h, mark_rows, 64), 64)
(UI_ASSETS / "brand-mark.png").write_bytes(blob)
print(f"wrote src/assets/brand-mark.png (64x64, {len(blob)} bytes)")
if __name__ == "__main__":
main()
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""Build the two release manifests from a directory of artifacts.
scripts/release-manifest.py --version 0.1.0 --artifacts dist/release \
--base-url https://dl.reforgermods.net --out dist/release
Writes two files, because two different consumers need two different shapes and
conflating them is how a download page and an updater drift apart:
releases.json what the website's /launcher/ page reads. Every download it
should offer, with size and SHA-256 so a visitor can verify.
Consumed by public/static/launcher.js in reforgermods-web.
latest.json what tauri-plugin-updater polls. Only the platforms that have
an updater artifact and a signature, in Tauri's own schema.
Both are generated from the same scan of the same directory, so the version, the
URLs and the filenames cannot disagree between them.
Artifacts are recognised by suffix rather than listed, so a build that fails to
produce one is a manifest that is missing it — not a manifest with a dead link.
"""
import argparse
import hashlib
import json
import pathlib
import sys
from datetime import datetime, timezone
# Suffix -> (platform id understood by launcher.js, kind, human label).
# Order matters: the longest, most specific suffix has to be tested first, or
# "-setup.exe" is matched by the portable ".exe" rule.
ARTIFACT_RULES = [
(".nsis.zip", "windows", "updater", "Updater package"),
("-setup.exe", "windows", "installer", "Installer, Windows 10 or 11 64-bit"),
# An NSIS bundle is signed in place: the updater downloads the same
# -setup.exe a visitor does, so that file is both a download and the updater
# artifact. Any artifact with a sibling .sig is treated as one, below.
(".pkg.tar.zst", "arch", "pacman", "Arch Linux package"),
(".deb", "debian", "deb", "Debian / Ubuntu package"),
(".AppImage", "appimage", "appimage", "AppImage, no install"),
(".rpm", "fedora", "rpm", "Fedora package"),
(".exe", "windows", "portable", "Portable, no installer"),
]
# The updater target triple Tauri looks for in latest.json.
UPDATER_TARGETS = {"windows": "windows-x86_64"}
def classify(name):
for suffix, platform, kind, label in ARTIFACT_RULES:
if name.endswith(suffix):
return platform, kind, label
return None
def sha256(path):
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--version", required=True)
parser.add_argument("--artifacts", required=True, type=pathlib.Path)
parser.add_argument("--base-url", required=True, help="e.g. https://dl.reforgermods.net")
parser.add_argument("--out", required=True, type=pathlib.Path)
parser.add_argument("--notes-url", default="https://reforgermods.net/launcher/changelog/")
parser.add_argument(
"--notes",
default="",
help="Release notes shown by the updater. Kept short: it is a dialog, not a changelog.",
)
parser.add_argument(
"--published-at",
default=None,
help="RFC3339 timestamp. Defaults to now, in UTC.",
)
args = parser.parse_args()
version = args.version.lstrip("v")
base = args.base_url.rstrip("/")
prefix = f"{base}/{version}"
published = args.published_at or datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
if not args.artifacts.is_dir():
sys.exit(f"not a directory: {args.artifacts}")
downloads, updater_platforms, skipped = [], {}, []
# Signatures sit beside their artifact as <artifact>.sig; they are read, never
# listed as downloads.
for path in sorted(p for p in args.artifacts.iterdir() if p.is_file()):
if path.suffix == ".sig" or path.name in ("releases.json", "latest.json", "SHA256SUMS"):
continue
classified = classify(path.name)
if classified is None:
skipped.append(path.name)
continue
platform, kind, label = classified
url = f"{prefix}/{path.name}"
# A sibling .sig makes this the platform's updater artifact, whether or
# not it is also a download. An unsigned one is never offered: the client
# rejects it, so publishing it means every launch checks, fails, retries.
signature = path.with_name(path.name + ".sig")
target = UPDATER_TARGETS.get(platform)
if signature.is_file():
if target is None:
skipped.append(f"{path.name} (signed, but no updater target for {platform})")
elif target in updater_platforms:
skipped.append(f"{path.name} (a {target} updater artifact was already chosen)")
else:
updater_platforms[target] = {
"signature": signature.read_text(encoding="utf-8").strip(),
"url": url,
}
elif kind == "updater":
skipped.append(f"{path.name} (no {signature.name}; not offered to the updater)")
if kind == "updater":
# A dedicated updater package is not a human download.
continue
downloads.append(
{
"platform": platform,
"kind": kind,
"label": label,
"url": url,
"size": path.stat().st_size,
"sha256": sha256(path),
}
)
# launcher.js offers the first entry whose platform matches as the primary
# download, so an installer has to come before a portable build of the same
# platform. Within a platform, installer first; otherwise by platform id for
# a stable file.
kind_order = {"installer": 0, "pacman": 0, "deb": 0, "rpm": 0, "appimage": 1, "portable": 2}
# Windows leads because that is where the players are; the rest follow in the
# order the download table should read.
platform_order = {"windows": 0, "debian": 1, "arch": 2, "fedora": 3, "appimage": 4}
downloads.sort(key=lambda d: (platform_order.get(d["platform"], 9), kind_order.get(d["kind"], 9)))
args.out.mkdir(parents=True, exist_ok=True)
releases = {
"version": version,
"publishedAt": published,
"notesUrl": args.notes_url,
"downloads": downloads,
}
(args.out / "releases.json").write_text(json.dumps(releases, indent=2) + "\n", encoding="utf-8")
latest = {
"version": version,
"notes": args.notes or f"reforgermods.net launcher {version}",
"pub_date": published,
"platforms": updater_platforms,
}
(args.out / "latest.json").write_text(json.dumps(latest, indent=2) + "\n", encoding="utf-8")
print(f"version {version}, published {published}")
for download in downloads:
print(f" download {download['platform']:9} {download['kind']:9} {download['size']:>10,} B {download['url']}")
for target, entry in updater_platforms.items():
print(f" updater {target} {entry['url']}")
for name in skipped:
print(f" skipped {name}")
if not downloads:
sys.exit("no downloadable artifacts were found: refusing to write an empty manifest")
if not updater_platforms:
# Not fatal: a Linux-only or unsigned build is a legitimate state. It is
# called out because it means the updater will find nothing.
print(" WARNING: latest.json has no platforms; the updater will never fire")
if __name__ == "__main__":
main()
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# The launcher's version, in one place.
#
# 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 has to be repeated in four places — Cargo's workspace manifest, package.json,
# tauri.conf.json and the PKGBUILD — because no two of those tools can read each
# other's format. tauri.conf.json could inherit from Cargo.toml by omitting the
# field, but the src-tauri crate declares `version.workspace = true` and Tauri
# parses that manifest itself rather than resolving workspace inheritance, so the
# value has to be written out. `check` is what makes the repetition safe.
#
# This matters more than tidiness: an updater whose manifest version disagrees
# with the installed version either loops forever or never fires
# (docs/distribution.md section 6).
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO"
canonical() {
# The first `version = "x.y.z"` under [workspace.package].
awk '/^\[workspace\.package\]/{f=1;next} /^\[/{f=0} f && /^version *=/{gsub(/[^0-9a-zA-Z.\-+]/,"",$3);print $3;exit}' Cargo.toml
}
read_package_json() { python3 -c 'import json;print(json.load(open("package.json"))["version"])'; }
read_tauri_conf() { python3 -c 'import json;print(json.load(open("src-tauri/tauri.conf.json"))["version"])'; }
read_pkgbuild() { sed -n 's/^pkgver=\(.*\)$/\1/p' packaging/arch/PKGBUILD; }
SEMVER='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'
cmd_show() { canonical; }
cmd_check() {
local want tag="${1:-}" status=0
want="$(canonical)"
if [[ ! "$want" =~ $SEMVER ]]; then
echo "Cargo.toml [workspace.package] version is not semver: '$want'" >&2
return 1
fi
local name value
for name in package.json src-tauri/tauri.conf.json packaging/arch/PKGBUILD; do
case "$name" in
package.json) value="$(read_package_json)" ;;
src-tauri/tauri.conf.json) value="$(read_tauri_conf)" ;;
packaging/arch/PKGBUILD) value="$(read_pkgbuild)" ;;
esac
if [[ "$value" != "$want" ]]; then
echo "$name: $value, want $want (run: scripts/version.sh set $want)" >&2
status=1
fi
done
# A tag is checked only when one is given, so the same command serves CI on a
# tag push and a developer on a branch. Both `1.2.3` and `v1.2.3` are accepted.
if [[ -n "$tag" && "${tag#v}" != "$want" ]]; then
echo "tag $tag does not match version $want" >&2
status=1
fi
if [[ $status -eq 0 ]]; then
echo "version $want is consistent${tag:+ with tag $tag}"
fi
return $status
}
cmd_set() {
local want="${1:?usage: scripts/version.sh set <version>}"
want="${want#v}"
if [[ ! "$want" =~ $SEMVER ]]; then
echo "not a semver version: '$want'" >&2
return 1
fi
python3 - "$want" <<'PY'
import io, json, re, sys
want = sys.argv[1]
# Cargo.toml: only the [workspace.package] version, never a dependency's.
path = "Cargo.toml"
text = io.open(path, encoding="utf-8").read()
def bump_workspace_version(match):
return match.group(1) + '"' + want + '"'
new, count = re.subn(r'(\[workspace\.package\]\n(?:[^\[]*?\n)?version *= *)"[^"]*"', bump_workspace_version, text)
assert count == 1, f"{path}: matched {count} workspace versions, expected 1"
io.open(path, "w", encoding="utf-8").write(new)
# package.json / tauri.conf.json: rewrite the single field, preserving formatting
# (a json.dump round trip would reformat files a human maintains).
for path, key in (("package.json", "version"), ("src-tauri/tauri.conf.json", "version")):
text = io.open(path, encoding="utf-8").read()
new, count = re.subn(r'("%s"\s*:\s*)"[^"]*"' % key, lambda m: m.group(1) + '"' + want + '"', text, count=1)
assert count == 1, f"{path}: no {key} field"
json.loads(new)
io.open(path, "w", encoding="utf-8").write(new)
path = "packaging/arch/PKGBUILD"
text = io.open(path, encoding="utf-8").read()
new, count = re.subn(r'(?m)^pkgver=.*$', "pkgver=" + want, text, count=1)
assert count == 1, f"{path}: no pkgver"
io.open(path, "w", encoding="utf-8").write(new)
print("set version", want)
PY
cmd_check
}
case "${1:-show}" in
show) cmd_show ;;
check) shift; cmd_check "$@" ;;
set) shift; cmd_set "$@" ;;
*) echo "usage: scripts/version.sh [show|check [tag]|set <version>]" >&2; exit 2 ;;
esac