diff --git a/.gitignore b/.gitignore index 7782aa5..9d1f119 100644 --- a/.gitignore +++ b/.gitignore @@ -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. diff --git a/gui/main.py b/gui/main.py index 345eb35..2db3af9 100644 --- a/gui/main.py +++ b/gui/main.py @@ -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) diff --git a/util/wolfdawn/__init__.py b/util/wolfdawn/__init__.py index a65d396..edd5444 100644 --- a/util/wolfdawn/__init__.py +++ b/util/wolfdawn/__init__.py @@ -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: diff --git a/util/wolfdawn/bin/linux/wolf b/util/wolfdawn/bin/linux/wolf index a55f656..9ec2fd9 100644 Binary files a/util/wolfdawn/bin/linux/wolf and b/util/wolfdawn/bin/linux/wolf differ diff --git a/util/wolfdawn/build_tools.py b/util/wolfdawn/build_tools.py new file mode 100644 index 0000000..bec9e3a --- /dev/null +++ b/util/wolfdawn/build_tools.py @@ -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 diff --git a/util/wolfdawn/update_tools.py b/util/wolfdawn/update_tools.py new file mode 100644 index 0000000..5dd04e1 --- /dev/null +++ b/util/wolfdawn/update_tools.py @@ -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())