Desktop release builds: Linux AppImage, Windows exe and macOS app via tools/build_desktop.py
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2475a8ca3c
commit
98b78eb518
4 files changed
+266
No files matched your search
@@ -4,3 +4,4 @@ Cargo.lock.bak
|
|||||||
www/dungeoncraft.wasm
|
www/dungeoncraft.wasm
|
||||||
www/mq_js_bundle.js
|
www/mq_js_bundle.js
|
||||||
play/
|
play/
|
||||||
|
dist/
|
||||||
+11
@@ -53,6 +53,17 @@ data folder: `~/.local/share/dungeoncraft/` (Linux, or `$XDG_DATA_HOME/dungeoncr
|
|||||||
*Save Game* and saved campaigns land in its `saves/` folder; *Load Game* opens the system file dialog there (on
|
*Save Game* and saved campaigns land in its `saves/` folder; *Load Game* opens the system file dialog there (on
|
||||||
Linux through the desktop portal), and dropping a file onto the window works as well.
|
Linux through the desktop portal), and dropping a file onto the window works as well.
|
||||||
|
|
||||||
|
Release packages are built by `python3 tools/build_desktop.py [linux] [windows] [macos]` into `dist/`:
|
||||||
|
|
||||||
|
- **Linux**: `DungeonCraft-<version>-x86_64.AppImage`, built against glibc 2.28 (distributions from about 2019 on).
|
||||||
|
- **Windows**: `DungeonCraft-<version>-windows-x64.exe`, a single file for Windows 10 and newer, cross-compiled on Linux.
|
||||||
|
- **macOS**: `DungeonCraft-<version>-macos.zip` with a universal `DungeonCraft.app` (Apple silicon and Intel); only on a
|
||||||
|
Mac. It is signed ad hoc, not notarised: on first start open it with right click -> *Open*.
|
||||||
|
|
||||||
|
Linux and Windows need [zig](https://ziglang.org/) (`mise install zig@0.15` works) and
|
||||||
|
`cargo install --locked cargo-zigbuild`, plus `rustup target add x86_64-pc-windows-gnu`; the AppImage tool is downloaded
|
||||||
|
once into `~/.cache/dungeoncraft/`.
|
||||||
|
|
||||||
### Testing the game logic
|
### Testing the game logic
|
||||||
|
|
||||||
The tests run natively: a headless simulation of whole games, a savegame round trip, rendering of all music modules and the audio engine:
|
The tests run natively: a headless simulation of whole games, a savegame round trip, rendering of all music modules and the audio engine:
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// DungeonCraft – dungeon building & defence in the browser (Rust + macroquad, WASM).
|
// DungeonCraft – dungeon building & defence in the browser (Rust + macroquad, WASM).
|
||||||
|
// Windows release builds are a window program: no console opens beside the game
|
||||||
|
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
|
||||||
|
|
||||||
mod achievements;
|
mod achievements;
|
||||||
mod ai;
|
mod ai;
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build the desktop versions of DungeonCraft into dist/.
|
||||||
|
|
||||||
|
python3 tools/build_desktop.py # everything this machine can build
|
||||||
|
python3 tools/build_desktop.py linux windows # only these
|
||||||
|
|
||||||
|
- linux: dist/DungeonCraft-<version>-x86_64.AppImage
|
||||||
|
built with cargo-zigbuild against glibc 2.28, so it runs on distributions from about 2019 on;
|
||||||
|
needs on the build machine: zig, cargo-zigbuild, the ALSA headers (alsa-lib / libasound2-dev);
|
||||||
|
appimagetool is downloaded once into ~/.cache/dungeoncraft/
|
||||||
|
- windows: dist/DungeonCraft-<version>-windows-x64.exe (one file, Windows 10 and newer)
|
||||||
|
cross-compiled with cargo-zigbuild for x86_64-pc-windows-gnu
|
||||||
|
- macos: dist/DungeonCraft-<version>-macos.zip (DungeonCraft.app, Apple silicon + Intel)
|
||||||
|
only on a Mac (Apple's SDK is needed); the app is signed ad hoc, not notarised
|
||||||
|
|
||||||
|
Everything the game needs (graphics, music, languages) is inside the program; nothing else is packed.
|
||||||
|
The tools: `cargo install --locked cargo-zigbuild`, zig from the package manager or `mise install zig@0.15`
|
||||||
|
(found on PATH, in $CARGO_ZIGBUILD_ZIG_PATH or in mise's install folder), and the Rust targets
|
||||||
|
(`rustup target add ...`, the script tells which).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import platform
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||||
|
DIST = ROOT / "dist"
|
||||||
|
CACHE = pathlib.Path(os.environ.get("XDG_CACHE_HOME") or pathlib.Path.home() / ".cache") / "dungeoncraft"
|
||||||
|
ICON = ROOT / "www" / "icons" / "icon-512.png"
|
||||||
|
GLIBC = "2.28"
|
||||||
|
APPIMAGETOOL_URL = "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage"
|
||||||
|
|
||||||
|
DESKTOP_ENTRY = """[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=DungeonCraft
|
||||||
|
Comment=Dig. Build. Defend. Conquer.
|
||||||
|
Exec=dungeoncraft
|
||||||
|
Icon=dungeoncraft
|
||||||
|
Categories=Game;StrategyGame;
|
||||||
|
Terminal=false
|
||||||
|
"""
|
||||||
|
|
||||||
|
INFO_PLIST = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleName</key><string>DungeonCraft</string>
|
||||||
|
<key>CFBundleDisplayName</key><string>DungeonCraft</string>
|
||||||
|
<key>CFBundleIdentifier</key><string>org.familie-zink.dungeoncraft</string>
|
||||||
|
<key>CFBundleExecutable</key><string>DungeonCraft</string>
|
||||||
|
<key>CFBundleIconFile</key><string>DungeonCraft</string>
|
||||||
|
<key>CFBundlePackageType</key><string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key><string>{short}</string>
|
||||||
|
<key>CFBundleVersion</key><string>{version}</string>
|
||||||
|
<key>LSMinimumSystemVersion</key><string>11.0</string>
|
||||||
|
<key>LSApplicationCategoryType</key><string>public.app-category.strategy-games</string>
|
||||||
|
<key>NSHighResolutionCapable</key><true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class BuildError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
print(f"==> {msg}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd, env=None, cwd=ROOT):
|
||||||
|
print(" $", " ".join(str(c) for c in cmd), flush=True)
|
||||||
|
r = subprocess.run([str(c) for c in cmd], cwd=cwd, env=env)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise BuildError(f"{pathlib.Path(str(cmd[0])).name} failed (exit {r.returncode})")
|
||||||
|
|
||||||
|
|
||||||
|
def version():
|
||||||
|
return (ROOT / "VERSION").read_text(encoding="utf-8").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def cargo():
|
||||||
|
c = shutil.which("cargo") or str(pathlib.Path.home() / ".cargo" / "bin" / "cargo")
|
||||||
|
if not pathlib.Path(c).exists():
|
||||||
|
raise BuildError("cargo not found: install Rust with rustup")
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def tool_env():
|
||||||
|
env = dict(os.environ)
|
||||||
|
env["PATH"] = str(pathlib.Path(cargo()).parent) + os.pathsep + env.get("PATH", "")
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def need_target(triple):
|
||||||
|
rustup = shutil.which("rustup", path=tool_env()["PATH"])
|
||||||
|
if not rustup:
|
||||||
|
return
|
||||||
|
have = subprocess.run([rustup, "target", "list", "--installed"], capture_output=True, text=True).stdout.split()
|
||||||
|
if triple not in have:
|
||||||
|
raise BuildError(f"Rust target missing: rustup target add {triple}")
|
||||||
|
|
||||||
|
|
||||||
|
def zig_env():
|
||||||
|
env = tool_env()
|
||||||
|
if not shutil.which("cargo-zigbuild", path=env["PATH"]):
|
||||||
|
raise BuildError("cargo-zigbuild missing: cargo install --locked cargo-zigbuild")
|
||||||
|
installs = pathlib.Path.home() / ".local" / "share" / "mise" / "installs" / "zig"
|
||||||
|
from_mise = sorted(installs.glob("*/zig"), key=lambda p: [int(x) if x.isdigit() else 0 for x in p.parent.name.split(".")])
|
||||||
|
candidates = [env.get("CARGO_ZIGBUILD_ZIG_PATH"), shutil.which("zig", path=env["PATH"])] + [str(p) for p in reversed(from_mise)]
|
||||||
|
|
||||||
|
def works(z): # a mise shim without an active version is on PATH but does not run
|
||||||
|
try:
|
||||||
|
return subprocess.run([z, "version"], capture_output=True, timeout=30).returncode == 0
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
zig = next((z for z in candidates if z and works(z)), None)
|
||||||
|
if not zig:
|
||||||
|
raise BuildError("zig not found: install it (package manager, or `mise install zig@0.15`)")
|
||||||
|
env["CARGO_ZIGBUILD_ZIG_PATH"] = zig
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ Linux: AppImage
|
||||||
|
|
||||||
|
def appimagetool():
|
||||||
|
tool = CACHE / "appimagetool-x86_64.AppImage"
|
||||||
|
if not tool.exists():
|
||||||
|
log(f"downloading appimagetool to {tool}")
|
||||||
|
CACHE.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = tool.with_suffix(".part")
|
||||||
|
with urllib.request.urlopen(APPIMAGETOOL_URL, timeout=120) as r, open(tmp, "wb") as f:
|
||||||
|
shutil.copyfileobj(r, f)
|
||||||
|
tmp.rename(tool)
|
||||||
|
tool.chmod(tool.stat().st_mode | stat.S_IXUSR)
|
||||||
|
return tool
|
||||||
|
|
||||||
|
|
||||||
|
def build_linux(ver):
|
||||||
|
if platform.system() != "Linux" or platform.machine() not in ("x86_64", "AMD64"):
|
||||||
|
raise BuildError("the AppImage is built on x86_64 Linux")
|
||||||
|
need_target("x86_64-unknown-linux-gnu")
|
||||||
|
env = zig_env()
|
||||||
|
# libasound is only linked by name: its own glibc references are the user's system's business
|
||||||
|
env["RUSTFLAGS"] = (env.get("RUSTFLAGS", "") + " -C link-arg=-Wl,--allow-shlib-undefined").strip()
|
||||||
|
log(f"Linux build (glibc {GLIBC})")
|
||||||
|
run([cargo(), "zigbuild", "--release", "--target", f"x86_64-unknown-linux-gnu.{GLIBC}"], env=env)
|
||||||
|
binary = ROOT / "target" / "x86_64-unknown-linux-gnu" / "release" / "dungeoncraft"
|
||||||
|
|
||||||
|
appdir = ROOT / "target" / "AppDir"
|
||||||
|
shutil.rmtree(appdir, ignore_errors=True)
|
||||||
|
(appdir / "usr" / "bin").mkdir(parents=True)
|
||||||
|
shutil.copy2(binary, appdir / "usr" / "bin" / "dungeoncraft")
|
||||||
|
(appdir / "dungeoncraft.desktop").write_text(DESKTOP_ENTRY, encoding="utf-8")
|
||||||
|
shutil.copy2(ICON, appdir / "dungeoncraft.png")
|
||||||
|
os.symlink("dungeoncraft.png", appdir / ".DirIcon")
|
||||||
|
os.symlink("usr/bin/dungeoncraft", appdir / "AppRun")
|
||||||
|
|
||||||
|
out = DIST / f"DungeonCraft-{ver}-x86_64.AppImage"
|
||||||
|
out.unlink(missing_ok=True)
|
||||||
|
tenv = dict(os.environ, ARCH="x86_64", APPIMAGE_EXTRACT_AND_RUN="1") # no FUSE needed for the tool
|
||||||
|
log("packing the AppImage")
|
||||||
|
run([appimagetool(), "--no-appstream", appdir, out], env=tenv)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ Windows: one exe
|
||||||
|
|
||||||
|
def build_windows(ver):
|
||||||
|
need_target("x86_64-pc-windows-gnu")
|
||||||
|
env = zig_env()
|
||||||
|
log("Windows build")
|
||||||
|
run([cargo(), "zigbuild", "--release", "--target", "x86_64-pc-windows-gnu"], env=env)
|
||||||
|
out = DIST / f"DungeonCraft-{ver}-windows-x64.exe"
|
||||||
|
shutil.copy2(ROOT / "target" / "x86_64-pc-windows-gnu" / "release" / "dungeoncraft.exe", out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ macOS: universal .app in a zip
|
||||||
|
|
||||||
|
def build_macos(ver):
|
||||||
|
if platform.system() != "Darwin":
|
||||||
|
raise BuildError("the macOS app can only be built on a Mac (Apple's SDK is needed)")
|
||||||
|
env = tool_env()
|
||||||
|
arches = ["aarch64-apple-darwin", "x86_64-apple-darwin"]
|
||||||
|
for t in arches:
|
||||||
|
need_target(t)
|
||||||
|
env["MACOSX_DEPLOYMENT_TARGET"] = "11.0"
|
||||||
|
for t in arches:
|
||||||
|
log(f"macOS build {t}")
|
||||||
|
run([cargo(), "build", "--release", "--target", t], env=env)
|
||||||
|
|
||||||
|
app = ROOT / "target" / "DungeonCraft.app"
|
||||||
|
shutil.rmtree(app, ignore_errors=True)
|
||||||
|
(app / "Contents" / "MacOS").mkdir(parents=True)
|
||||||
|
(app / "Contents" / "Resources").mkdir(parents=True)
|
||||||
|
run(["lipo", "-create", "-output", app / "Contents" / "MacOS" / "DungeonCraft"] + [ROOT / "target" / t / "release" / "dungeoncraft" for t in arches])
|
||||||
|
short = ver.lstrip("v").replace("-", ".")
|
||||||
|
(app / "Contents" / "Info.plist").write_text(INFO_PLIST.format(short=short, version=ver), encoding="utf-8")
|
||||||
|
|
||||||
|
iconset = ROOT / "target" / "DungeonCraft.iconset"
|
||||||
|
shutil.rmtree(iconset, ignore_errors=True)
|
||||||
|
iconset.mkdir()
|
||||||
|
for size in (16, 32, 128, 256, 512):
|
||||||
|
run(["sips", "-z", size, size, ICON, "--out", iconset / f"icon_{size}x{size}.png"])
|
||||||
|
if size <= 256:
|
||||||
|
run(["sips", "-z", size * 2, size * 2, ICON, "--out", iconset / f"icon_{size}x{size}@2x.png"])
|
||||||
|
run(["iconutil", "-c", "icns", iconset, "-o", app / "Contents" / "Resources" / "DungeonCraft.icns"])
|
||||||
|
|
||||||
|
run(["codesign", "--force", "--deep", "--sign", "-", app])
|
||||||
|
out = DIST / f"DungeonCraft-{ver}-macos.zip"
|
||||||
|
out.unlink(missing_ok=True)
|
||||||
|
run(["ditto", "-c", "-k", "--keepParent", app, out])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
BUILDERS = {"linux": build_linux, "windows": build_windows, "macos": build_macos}
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
if any(a in ("-h", "--help") for a in argv):
|
||||||
|
print(__doc__)
|
||||||
|
return 0
|
||||||
|
wanted = argv or (["macos"] if platform.system() == "Darwin" else ["linux", "windows"])
|
||||||
|
unknown = [w for w in wanted if w not in BUILDERS]
|
||||||
|
if unknown:
|
||||||
|
print(f"unknown target(s): {', '.join(unknown)} (linux, windows, macos)", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
DIST.mkdir(exist_ok=True)
|
||||||
|
ver = version()
|
||||||
|
done, failed = [], []
|
||||||
|
for w in wanted:
|
||||||
|
try:
|
||||||
|
done.append(BUILDERS[w](ver))
|
||||||
|
except BuildError as e:
|
||||||
|
failed.append(f"{w}: {e}")
|
||||||
|
print(f"!! {w}: {e}", file=sys.stderr)
|
||||||
|
print()
|
||||||
|
for p in done:
|
||||||
|
print(f"built {p.relative_to(ROOT)} ({p.stat().st_size / 1e6:.1f} MB)")
|
||||||
|
for f in failed:
|
||||||
|
print(f"failed {f}")
|
||||||
|
return 1 if failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv[1:]))
|
||||||
Reference in new issue
Block a user