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:
91 files changed
+24994
No files matched your search
Executable
+123
@@ -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")"
|
||||
Executable
+102
@@ -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
|
||||
@@ -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()
|
||||
Executable
+183
@@ -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()
|
||||
Executable
+116
@@ -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
|
||||
Reference in new issue
Block a user