Building wolf

This commit is contained in:
DazedAnon 2026-07-05 11:06:18 -05:00
parent 6fe5ab1963
commit b407aef557
6 changed files with 486 additions and 23 deletions

1
.gitignore vendored
View file

@ -29,6 +29,7 @@ __pycache__
util/forge/.forge_version.json
util/ace/*.exe
util/ace/.tools_version.json
util/wolfdawn/.wolf_version.json
!util/ace/offline/*.exe
# WolfDawn: keep the committed prebuilt binaries (offline) under version control.

View file

@ -63,8 +63,8 @@ def check_tool_update() -> str | None:
def check_all_updates() -> dict:
"""Check tool, Ace, and Forge for available updates."""
results = {"tool": None, "ace": False, "forge": False}
"""Check tool, Ace, Forge, and WolfDawn for available updates."""
results = {"tool": None, "ace": False, "forge": False, "wolf": False}
results["tool"] = check_tool_update()
try:
from util.ace.update_tools import check_ace_tools_update
@ -76,15 +76,25 @@ def check_all_updates() -> dict:
results["forge"] = check_forge_plugins_update()
except Exception:
pass
try:
from util.wolfdawn.update_tools import check_wolfdawn_update
results["wolf"] = check_wolfdawn_update()
except Exception:
pass
return results
def any_updates_available(results: dict) -> bool:
return bool(results.get("tool")) or results.get("ace") or results.get("forge")
return (
bool(results.get("tool"))
or results.get("ace")
or results.get("forge")
or results.get("wolf")
)
class BackgroundUpdateCheckThread(QThread):
"""Checks for tool, Ace, and Forge updates without blocking the UI."""
"""Checks for tool, Ace, Forge, and WolfDawn updates without blocking the UI."""
finished = pyqtSignal(dict)
@ -93,15 +103,22 @@ class BackgroundUpdateCheckThread(QThread):
class AuxToolsUpdateThread(QThread):
"""Download Ace and/or Forge updates without updating the main tool."""
"""Download Ace, Forge, and/or WolfDawn updates without updating the main tool."""
progress = pyqtSignal(str)
finished = pyqtSignal(bool, str)
def __init__(self, update_ace: bool = False, update_forge: bool = False, parent=None):
def __init__(
self,
update_ace: bool = False,
update_forge: bool = False,
update_wolf: bool = False,
parent=None,
):
super().__init__(parent)
self.update_ace = update_ace
self.update_forge = update_forge
self.update_wolf = update_wolf
def run(self):
try:
@ -117,6 +134,12 @@ class AuxToolsUpdateThread(QThread):
if not ensure_forge_plugins(force=True, log_fn=lambda m: self.progress.emit(m)):
self.finished.emit(False, "Forge plugin update failed.")
return
if self.update_wolf:
from util.wolfdawn.update_tools import ensure_wolfdawn_binary
self.progress.emit("Updating WolfDawn…")
if not ensure_wolfdawn_binary(force=True, log_fn=lambda m: self.progress.emit(m)):
self.finished.emit(False, "WolfDawn update failed.")
return
self.finished.emit(True, "components_updated")
except Exception as exc:
self.finished.emit(False, str(exc))
@ -235,6 +258,12 @@ class UpdateThread(QThread):
ensure_forge_plugins(log_fn=lambda m: self.progress.emit(m))
except Exception as exc:
self.progress.emit(f"Forge update skipped: {exc}")
try:
from util.wolfdawn.update_tools import ensure_wolfdawn_binary
self.progress.emit("Updating WolfDawn…")
ensure_wolfdawn_binary(log_fn=lambda m: self.progress.emit(m))
except Exception as exc:
self.progress.emit(f"WolfDawn update skipped: {exc}")
self.finished.emit(True, f"updated:{latest_sha[:8]}")
@ -282,6 +311,8 @@ class UpdateDialog(QDialog):
labels.append("RPG Maker Ace tools")
if self._pending.get("forge"):
labels.append("Forge plugins")
if self._pending.get("wolf"):
labels.append("WolfDawn")
return labels
def _show_pending_updates(self):
@ -324,7 +355,7 @@ class UpdateDialog(QDialog):
if message == "already_up_to_date":
self.status_label.setText("✅ Already up to date.")
elif message == "components_updated":
self._pending = {"tool": None, "ace": False, "forge": False}
self._pending = {"tool": None, "ace": False, "forge": False, "wolf": False}
self.status_label.setText("✅ Component updates installed.")
if isinstance(self.parent(), DazedMTLGUI):
self.parent().clear_update_indicator()
@ -339,7 +370,7 @@ class UpdateDialog(QDialog):
self.layout().insertWidget(self.layout().count() - 1, update_btn)
elif message.startswith("updated:"):
sha = message.split(":", 1)[1]
self._pending = {"tool": None, "ace": False, "forge": False}
self._pending = {"tool": None, "ace": False, "forge": False, "wolf": False}
self.status_label.setText(
f"✅ Updated to {sha}.\n\nPlease restart the tool for changes to take effect."
)
@ -371,11 +402,13 @@ class UpdateDialog(QDialog):
update_ace = bool(self._pending.get("ace"))
update_forge = bool(self._pending.get("forge"))
if update_ace or update_forge:
update_wolf = bool(self._pending.get("wolf"))
if update_ace or update_forge or update_wolf:
self.status_label.setText("Downloading component updates…")
self._thread = AuxToolsUpdateThread(
update_ace=update_ace,
update_forge=update_forge,
update_wolf=update_wolf,
parent=self,
)
self._thread.progress.connect(self.status_label.setText)

View file

@ -124,6 +124,26 @@ def wolf_binary_path() -> Path:
return bundled_binary_path()
def _latest_release_asset(platform: str) -> Optional[tuple[str, str]]:
"""Return ``(tag_name, download_url)`` for the newest release with a platform zip."""
match = _RELEASE_ASSET_MATCH.get(platform)
if not match:
return None
req = urllib.request.Request(_RELEASES_API, headers={"User-Agent": _DOWNLOAD_UA})
with urllib.request.urlopen(req, timeout=30) as resp:
releases = json.loads(resp.read().decode("utf-8"))
link_re = re.compile(r"\[([^\]]+)\]\((/uploads/[0-9a-f]+/[^)]+)\)")
for release in releases:
tag = release.get("tag_name") or ""
description = release.get("description") or ""
for filename, upload_path in link_re.findall(description):
name = filename.lower()
if match in name and name.endswith(".zip"):
url = f"{_GITGUD_HOST}/-/project/{_WOLFDAWN_PROJECT_ID}{upload_path}"
return tag, url
return None
def _find_release_upload_url(platform: str) -> Optional[str]:
"""Look up the newest release and return the download URL of ``platform``'s zip.
@ -133,20 +153,8 @@ def _find_release_upload_url(platform: str) -> Optional[str]:
numeric-project uploads URL (which bypasses the Cloudflare challenge) or
``None`` when the platform isn't published.
"""
match = _RELEASE_ASSET_MATCH.get(platform)
if not match:
return None
req = urllib.request.Request(_RELEASES_API, headers={"User-Agent": _DOWNLOAD_UA})
with urllib.request.urlopen(req, timeout=30) as resp:
releases = json.loads(resp.read().decode("utf-8"))
link_re = re.compile(r"\[([^\]]+)\]\((/uploads/[0-9a-f]+/[^)]+)\)")
for release in releases:
description = release.get("description") or ""
for filename, upload_path in link_re.findall(description):
name = filename.lower()
if match in name and name.endswith(".zip"):
return f"{_GITGUD_HOST}/-/project/{_WOLFDAWN_PROJECT_ID}{upload_path}"
return None
asset = _latest_release_asset(platform)
return asset[1] if asset else None
def _extract_wolf_from_zip(zip_bytes: bytes, dest: Path, log_fn=None) -> Path:

Binary file not shown.

View file

@ -0,0 +1,215 @@
"""Build WolfDawn ``wolf`` binaries from upstream source (temporary shallow clone)."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import tempfile
import urllib.parse
import urllib.request
from pathlib import Path
from util.wolfdawn import (
WolfDawnError,
_DOWNLOAD_UA,
_GITGUD_HOST,
bundled_binary_path,
)
WOLFDAWN_PROJECT = "zero64801/wolfdawn"
WOLFDAWN_BRANCH = "master"
GITGUD_API = "https://gitgud.io/api/v4"
CLONE_URL = f"{_GITGUD_HOST}/{WOLFDAWN_PROJECT}.git"
_BUILD_TARGETS: dict[str, dict[str, str | None]] = {
"linux": {"triple": None, "exe": "wolf"},
"windows": {"triple": "x86_64-pc-windows-gnu", "exe": "wolf.exe"},
}
def _platform_dir() -> str:
import sys
if sys.platform.startswith("win"):
return "windows"
if sys.platform == "darwin":
return "macos"
return "linux"
def _log(msg: str, log_fn) -> None:
if log_fn:
log_fn(msg)
else:
print(msg, flush=True)
def _project_id() -> str:
return urllib.parse.quote(WOLFDAWN_PROJECT, safe="")
def upstream_commit() -> str:
url = f"{GITGUD_API}/projects/{_project_id()}/repository/commits/{WOLFDAWN_BRANCH}"
req = urllib.request.Request(url, headers={"User-Agent": _DOWNLOAD_UA})
with urllib.request.urlopen(req, timeout=60) as resp:
data = json.loads(resp.read().decode("utf-8"))
commit = data.get("id") or data.get("sha") or ""
if not commit:
raise RuntimeError(f"Could not resolve upstream commit for {WOLFDAWN_PROJECT}")
return commit
def _cargo_available() -> bool:
return shutil.which("cargo") is not None
def _git_available() -> bool:
return shutil.which("git") is not None
def _mingw_linker_available() -> bool:
return bool(shutil.which("x86_64-w64-mingw32-gcc"))
def buildable_from_source(platform: str) -> bool:
"""Return True when ``platform`` can plausibly be built on this machine."""
if not _cargo_available():
return False
host = _platform_dir()
if platform == host:
return True
if platform == "windows" and host == "linux":
return _mingw_linker_available()
return False
def _run(cmd: list[str], *, cwd: Path | None = None, env: dict[str, str] | None = None, log_fn=None) -> bool:
_log(f"$ {' '.join(cmd)}", log_fn)
try:
proc = subprocess.run(
cmd,
cwd=str(cwd) if cwd else None,
env=env,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
except OSError as exc:
_log(f"ERROR: {exc}", log_fn)
return False
if proc.stdout.strip():
for line in proc.stdout.strip().splitlines()[-8:]:
_log(line, log_fn)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "").strip()
if err:
for line in err.splitlines()[-8:]:
_log(line, log_fn)
return False
return True
def _ensure_rust_target(triple: str | None, log_fn) -> bool:
if not triple:
return True
return _run(["rustup", "target", "add", triple], log_fn=log_fn)
def clone_source(dest: Path, log_fn=print) -> bool:
"""Shallow-clone WolfDawn ``master`` into ``dest``."""
if not _git_available():
_log("ERROR: git not found; cannot clone WolfDawn source.", log_fn)
return False
if dest.exists():
shutil.rmtree(dest)
return _run(
["git", "clone", "--depth", "1", "--branch", WOLFDAWN_BRANCH, CLONE_URL, str(dest)],
log_fn=log_fn,
)
def _artifact_path(source_dir: Path, platform: str) -> Path:
spec = _BUILD_TARGETS[platform]
triple = spec["triple"]
exe = spec["exe"]
if triple:
return source_dir / "target" / triple / "release" / exe
return source_dir / "target" / "release" / exe
def build_platform(source_dir: Path, platform: str, log_fn=print) -> Path | None:
"""Compile ``wolf`` for ``platform`` inside an existing source checkout."""
if platform not in _BUILD_TARGETS:
_log(f"ERROR: unsupported WolfDawn build platform '{platform}'.", log_fn)
return None
if not buildable_from_source(platform):
_log(f"Skipping source build for '{platform}' on this machine.", log_fn)
return None
spec = _BUILD_TARGETS[platform]
triple = spec["triple"]
if not _ensure_rust_target(triple, log_fn):
return None
cmd = ["cargo", "build", "--release", "-p", "wolf-cli"]
if triple:
cmd += ["--target", triple]
target_dir = source_dir / "target"
env = os.environ.copy()
env["CARGO_TARGET_DIR"] = str(target_dir)
if not _run(cmd, cwd=source_dir, env=env, log_fn=log_fn):
return None
built = _artifact_path(source_dir, platform)
if not built.is_file():
_log(f"ERROR: expected build output missing: {built}", log_fn)
return None
return built
def install_built_binary(artifact: Path, platform: str, log_fn=print) -> Path:
"""Copy a built ``wolf`` executable into the bundled offline path."""
dest = bundled_binary_path(platform)
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(artifact, dest)
try:
dest.chmod(0o755)
except OSError:
pass
_log(f"Installed source-built WolfDawn binary at {dest}", log_fn)
return dest
def build_and_install_platform(source_dir: Path, platform: str, log_fn=print) -> bool:
artifact = build_platform(source_dir, platform, log_fn=log_fn)
if artifact is None:
return False
install_built_binary(artifact, platform, log_fn=log_fn)
return True
def build_and_install_platforms(
platforms: tuple[str, ...] = ("linux", "windows"),
log_fn=print,
) -> dict[str, bool]:
"""Clone upstream source to a temp dir, build each platform, install successes."""
results = {platform: False for platform in platforms}
buildable = [p for p in platforms if buildable_from_source(p)]
if not buildable:
_log("No WolfDawn platforms can be built from source on this machine.", log_fn)
return results
if not _cargo_available():
_log("cargo not found; skipping WolfDawn source builds.", log_fn)
return results
_log(f"Cloning WolfDawn ({WOLFDAWN_BRANCH}) for source build…", log_fn)
with tempfile.TemporaryDirectory(prefix="wolfdawn-src-") as tmp:
source_dir = Path(tmp) / "wolfdawn"
if not clone_source(source_dir, log_fn=log_fn):
return results
for platform in buildable:
results[platform] = build_and_install_platform(source_dir, platform, log_fn=log_fn)
return results

View file

@ -0,0 +1,206 @@
"""Check for and apply WolfDawn ``wolf`` binary updates.
Resolution order for each platform:
1. Temporary shallow clone + ``cargo`` source build (when possible on this machine)
2. Prebuilt zip from the latest GitLab release
3. Keep the existing offline bundled binary
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Sequence
from util.wolfdawn import (
WolfDawnError,
_latest_release_asset,
_platform_dir,
bundled_binary_path,
download_wolf_binary,
)
from util.wolfdawn.build_tools import (
build_and_install_platforms,
buildable_from_source,
upstream_commit,
)
_PKG_ROOT = Path(__file__).resolve().parent
VERSION_FILE = _PKG_ROOT / ".wolf_version.json"
BUNDLED_PLATFORMS: tuple[str, ...] = ("linux", "windows")
def _load_versions() -> dict:
if not VERSION_FILE.is_file():
return {}
try:
return json.loads(VERSION_FILE.read_text(encoding="utf-8"))
except Exception:
return {}
def _save_versions(data: dict) -> None:
VERSION_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
def _log(msg: str, log_fn) -> None:
if log_fn:
log_fn(msg)
else:
print(msg, flush=True)
def _platform_versions() -> dict[str, str]:
versions = _load_versions()
platforms = versions.get("platforms")
if isinstance(platforms, dict):
return {str(k): str(v) for k, v in platforms.items() if v}
tag = versions.get("tag", "")
if tag:
return {p: str(tag) for p in BUNDLED_PLATFORMS}
commit = versions.get("commit", "")
if commit:
return {p: str(commit) for p in BUNDLED_PLATFORMS}
return {}
def _local_version(platform: str) -> str:
return _platform_versions().get(platform, "")
def _record_platform_version(platform: str, version: str) -> None:
versions = _load_versions()
platforms = versions.setdefault("platforms", {})
if not isinstance(platforms, dict):
platforms = {}
versions["platforms"] = platforms
platforms[platform] = version
if version and not versions.get("commit"):
versions["commit"] = version
_save_versions(versions)
def _desired_version(platform: str) -> str:
"""Best upstream version we can install for ``platform`` on this machine."""
if buildable_from_source(platform):
return upstream_commit()
asset = _latest_release_asset(platform)
if asset:
return asset[0]
return upstream_commit()
def check_wolfdawn_update(platform: str | None = None) -> bool:
"""Return True when upstream WolfDawn differs from the bundled binary."""
platform = platform or _platform_dir()
if not bundled_binary_path(platform).is_file():
return True
try:
return _local_version(platform) != _desired_version(platform)
except Exception:
return False
def _refresh_from_release(platform: str, log_fn=print) -> str | None:
asset = _latest_release_asset(platform)
if not asset:
return None
tag, _ = asset
try:
download_wolf_binary(platform=platform, log_fn=log_fn)
except WolfDawnError as exc:
_log(f"Warning: release download failed for {platform} ({exc}).", log_fn)
return None
return tag
def refresh_wolfdawn_binary(
platform: str | None = None,
*,
platforms: Sequence[str] | None = None,
log_fn=print,
) -> bool:
"""Refresh WolfDawn binaries, trying source builds first."""
targets = tuple(platforms or (platform or _platform_dir(),))
try:
upstream = upstream_commit()
except Exception as exc:
_log(f"ERROR: could not contact WolfDawn upstream ({exc})", log_fn)
return False
_log(f"Upstream WolfDawn commit: {upstream[:12]}", log_fn)
source_results = build_and_install_platforms(
tuple(p for p in targets if p in BUNDLED_PLATFORMS),
log_fn=log_fn,
)
ok = True
versions = _load_versions()
versions["commit"] = upstream
platform_versions = versions.setdefault("platforms", {})
if not isinstance(platform_versions, dict):
platform_versions = {}
versions["platforms"] = platform_versions
for plat in targets:
if source_results.get(plat):
platform_versions[plat] = upstream
_log(f"WolfDawn updated from source ({plat}, {upstream[:12]})", log_fn)
continue
tag = _refresh_from_release(plat, log_fn=log_fn)
if tag:
platform_versions[plat] = tag
_log(f"WolfDawn updated from release ({plat}, {tag})", log_fn)
continue
if bundled_binary_path(plat).is_file():
_log(f"Keeping existing offline WolfDawn binary ({plat}).", log_fn)
if plat not in platform_versions:
platform_versions[plat] = _local_version(plat) or "offline"
continue
_log(f"ERROR: no WolfDawn binary available for '{plat}'.", log_fn)
ok = False
_save_versions(versions)
return ok
def ensure_wolfdawn_binary(force: bool = False, log_fn=print) -> bool:
"""Ensure the ``wolf`` binary is present; refresh when missing or stale."""
platform = _platform_dir()
bundled = bundled_binary_path(platform)
missing = not bundled.is_file()
need_fetch = missing or force
if not need_fetch:
try:
need_fetch = check_wolfdawn_update(platform)
except Exception as exc:
if missing:
_log(f"ERROR: WolfDawn upstream unavailable ({exc})", log_fn)
return False
_log(f"Warning: could not check WolfDawn update ({exc}); using local copy.", log_fn)
if need_fetch:
if refresh_wolfdawn_binary(platforms=BUNDLED_PLATFORMS, log_fn=log_fn):
return bundled_binary_path(platform).is_file()
if force or missing:
_log("ERROR: WolfDawn update failed.", log_fn)
return False
_log("Warning: could not update WolfDawn; using local copy.", log_fn)
return bundled.is_file()
def main() -> int:
if "--refresh-all" in sys.argv:
return 0 if refresh_wolfdawn_binary(platforms=BUNDLED_PLATFORMS) else 1
force = "--force" in sys.argv or "-f" in sys.argv
return 0 if ensure_wolfdawn_binary(force=force) else 1
if __name__ == "__main__":
raise SystemExit(main())