#!/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--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) and libudev (libudev-dev, for gamepads); appimagetool is downloaded once into ~/.cache/dungeoncraft/ - windows: dist/DungeonCraft--windows-x64.exe (one file, Windows 10 and newer) cross-compiled with cargo-zigbuild for x86_64-pc-windows-gnu - macos: dist/DungeonCraft--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 = """ CFBundleNameDungeonCraft CFBundleDisplayNameDungeonCraft CFBundleIdentifierorg.familie-zink.dungeoncraft CFBundleExecutableDungeonCraft CFBundleIconFileDungeonCraft CFBundlePackageTypeAPPL CFBundleShortVersionString{short} CFBundleVersion{version} LSMinimumSystemVersion11.0 LSApplicationCategoryTypepublic.app-category.strategy-games NSHighResolutionCapable """ 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", "--profile", "desktop", "--target", f"x86_64-unknown-linux-gnu.{GLIBC}"], env=env) binary = ROOT / "target" / "x86_64-unknown-linux-gnu" / "desktop" / "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", "--profile", "desktop", "--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" / "desktop" / "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", "--profile", "desktop", "--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 / "desktop" / "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:]))