|
|
|
@@ -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:]))
|