diff --git a/README.md b/README.md index 225f014..b9d403c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ An AI-powered game translation tool with a GUI. Translate RPG Maker, Ren'Py, Tyr ## Credits -- **[Sinflower](https://github.com/Sinflower)** — [RV2JSON](https://github.com/Sinflower/RV2JSON) — enables RPGMaker Ace games to be translated the same way as MV/MZ by converting rvdata2 files to JSON and back. A copy is bundled offline in `util/ace/offline/`; newer builds are downloaded when online. +- **[Sinflower](https://github.com/Sinflower)** — [RV2JSON](https://github.com/Sinflower/RV2JSON) — enables RPGMaker Ace games to be translated the same way as MV/MZ by converting rvdata2 files to JSON and back. A curated copy is bundled offline in `util/ace/offline/` and updates with DazedMTLTool. - **Sakura & Kao_SSS** — TL Inspector (`util/tl_inspector/`) — in-game translation source inspector and live-edit plugin for RPG Maker MV/MZ playtesting. - **Len** — [Forge](https://gitgud.io/zero64801/forge-mvmz) MV/MZ playtest plugin (`util/forge/`), Mistral API support (provider integration and adaptive rate limiting), and batch translation mode (Anthropic Message Batches API). @@ -333,7 +333,7 @@ Open the **Workflow** tab and choose **Wolf RPG (WolfDawn)** from the engine sel | **6 Package** | Run from loose `Data/` (backs up `Data.wolf` → `.bak`) or repack `Data.wolf`. | | **7 Saves** | *(Optional)* Update existing `.sav` files for the translated build. | -> **`wolf` binary:** Prebuilt `wolf` CLIs for Windows and Linux are bundled offline under `util/wolfdawn/bin//`, so no toolchain or build step is needed. If your platform's binary is missing, the tool downloads a prebuilt one from the [WolfDawn release page](https://gitgud.io/zero64801/wolfdawn) and caches it into that same folder for offline reuse. A clear error is shown if no bundled binary is present and the download can't be completed (e.g. no published binary for your platform). +> **`wolf` binary:** Prebuilt `wolf` CLIs for Windows and Linux are bundled offline under `util/wolfdawn/bin//`, so no toolchain or build step is needed. They update when you update DazedMTLTool. If your platform's binary is missing, update the tool or ask the maintainer to refresh the bundled copy. > **Legacy modules:** The older `Wolf RPG` / `Wolf RPG 2` modules (configured in the Engine Config tab) still exist for edge cases, but the WolfDawn workflow above is the recommended path. diff --git a/gui/main.py b/gui/main.py index 607411a..bc3ec3c 100644 --- a/gui/main.py +++ b/gui/main.py @@ -55,87 +55,13 @@ def check_tool_update() -> str | None: return None -def check_all_updates() -> dict: - """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 - results["ace"] = check_ace_tools_update() - except Exception: - pass - try: - from util.forge.update_tools import check_forge_plugins_update - 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") - or results.get("wolf") - ) - - class BackgroundUpdateCheckThread(QThread): - """Checks for tool, Ace, Forge, and WolfDawn updates without blocking the UI.""" + """Checks for DazedMTLTool updates without blocking the UI.""" - finished = pyqtSignal(dict) + finished = pyqtSignal(object) # str | None — pending tool SHA when available def run(self): - self.finished.emit(check_all_updates()) - - -class AuxToolsUpdateThread(QThread): - """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, - 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: - if self.update_ace: - from util.ace.update_tools import ensure_ace_tools - self.progress.emit("Updating RPG Maker Ace tools…") - if not ensure_ace_tools(log_fn=lambda m: self.progress.emit(m)): - self.finished.emit(False, "Ace tool update failed.") - return - if self.update_forge: - from util.forge.update_tools import ensure_forge_plugins - self.progress.emit("Updating Forge plugins…") - 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)) + self.finished.emit(check_tool_update()) class UpdateThread(QThread): @@ -157,9 +83,8 @@ class UpdateThread(QThread): progress = pyqtSignal(str) # status message finished = pyqtSignal(bool, str) # (success, message) - def __init__(self, check_only=False, parent=None): + def __init__(self, parent=None): super().__init__(parent) - self.check_only = check_only # ------------------------------------------------------------------ # @@ -172,10 +97,6 @@ class UpdateThread(QThread): self.finished.emit(True, "already_up_to_date") return - if self.check_only: - self.finished.emit(True, f"update_available:{latest_sha}") - return - self._download_and_apply(latest_sha) except Exception as exc: @@ -249,31 +170,13 @@ class UpdateThread(QThread): dst.chmod(mode) Path(self.SHA_FILE).write_text(latest_sha) - try: - from util.ace.update_tools import ensure_ace_tools - self.progress.emit("Updating RPG Maker Ace tools…") - ensure_ace_tools(log_fn=lambda m: self.progress.emit(m)) - except Exception as exc: - self.progress.emit(f"Ace tool update skipped: {exc}") - try: - from util.forge.update_tools import ensure_forge_plugins - self.progress.emit("Updating Forge plugins…") - 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]}") class UpdateDialog(QDialog): """Modal dialog that shows update progress and result.""" - def __init__(self, parent=None, pending_updates: dict | None = None): + def __init__(self, parent=None, pending_tool_sha: str | None = None): super().__init__(parent) self.setWindowTitle("Tool Update") self.setMinimumWidth(420) @@ -295,10 +198,10 @@ class UpdateDialog(QDialog): layout.addWidget(self.close_btn) self._thread = None - self._pending = pending_updates or {} + self._pending_tool_sha = pending_tool_sha - def start(self, check_only=False): - if self._pending: + def start(self): + if self._pending_tool_sha: self._show_pending_updates() return @@ -306,44 +209,28 @@ class UpdateDialog(QDialog): self._thread.finished.connect(self._on_check_finished) self._thread.start() - def _update_labels(self) -> list[str]: - labels = [] - if self._pending.get("tool"): - labels.append("DazedMTLTool") - if self._pending.get("ace"): - 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): self.progress_bar.setRange(0, 1) self.progress_bar.setValue(1) self.close_btn.setText("Close") - labels = self._update_labels() - if not labels: + tool_sha = self._pending_tool_sha + if not tool_sha: self.status_label.setText("✅ Already up to date.") return - detail = "\n".join(f" • {label}" for label in labels) - tool_sha = self._pending.get("tool") - if tool_sha: - detail = f" • DazedMTLTool ({tool_sha[:8]})\n" + "\n".join( - f" • {label}" for label in labels if label != "DazedMTLTool" - ) self.status_label.setText( - f"🆕 Updates available:\n{detail}\n\nClick 'Update Now' to install." + f"🆕 Update available:\n" + f" • DazedMTLTool ({tool_sha[:8]})\n\n" + f"Click 'Update Now' to install." ) self.close_btn.setText("Cancel") update_btn = QPushButton("Update Now") update_btn.clicked.connect(self._do_update) self.layout().insertWidget(self.layout().count() - 1, update_btn) - def _on_check_finished(self, results: dict): - self._pending = results + def _on_check_finished(self, tool_sha: str | None): + self._pending_tool_sha = tool_sha self._show_pending_updates() def _on_finished(self, success, message): @@ -357,23 +244,9 @@ 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, "wolf": False} - self.status_label.setText("✅ Component updates installed.") - if isinstance(self.parent(), DazedMTLGUI): - self.parent().clear_update_indicator() - elif message.startswith("update_available:"): - sha = message.split(":", 1)[1] - self.status_label.setText( - f"🆕 Update available ({sha[:8]}).\nClick 'Update Now' to install." - ) - self.close_btn.setText("Cancel") - update_btn = QPushButton("Update Now") - update_btn.clicked.connect(self._do_update) - 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, "wolf": False} + self._pending_tool_sha = None self.status_label.setText( f"✅ Updated to {sha}.\n\nPlease restart the tool for changes to take effect." ) @@ -395,25 +268,9 @@ class UpdateDialog(QDialog): self.progress_bar.setRange(0, 0) self.close_btn.setText("Cancel") - if self._pending.get("tool"): + if self._pending_tool_sha: self.status_label.setText("Downloading update…") - self._thread = UpdateThread(check_only=False, parent=self) - self._thread.progress.connect(self.status_label.setText) - self._thread.finished.connect(self._on_finished) - self._thread.start() - return - - update_ace = bool(self._pending.get("ace")) - update_forge = bool(self._pending.get("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 = UpdateThread(parent=self) self._thread.progress.connect(self.status_label.setText) self._thread.finished.connect(self._on_finished) self._thread.start() @@ -435,7 +292,7 @@ class DazedMTLGUI(QMainWindow): def __init__(self): super().__init__() self.settings = QSettings("DazedTranslations", "DazedMTLTool") - self._pending_updates: dict = {} + self._pending_tool_sha: str | None = None self._update_check_thread = None self._shutdown_started = False self._update_icon = "🔄" @@ -780,16 +637,16 @@ class DazedMTLGUI(QMainWindow): return container def start_background_update_check(self): - """Check for tool, Ace, and Forge updates after the GUI is visible.""" + """Check for DazedMTLTool updates after the GUI is visible.""" if self._update_check_thread and self._update_check_thread.isRunning(): return self._update_check_thread = BackgroundUpdateCheckThread(self) self._update_check_thread.finished.connect(self._on_background_update_check) self._update_check_thread.start() - def _on_background_update_check(self, results: dict): - self._pending_updates = results - if any_updates_available(results): + def _on_background_update_check(self, tool_sha: str | None): + self._pending_tool_sha = tool_sha + if tool_sha: self.set_update_indicator() else: self.clear_update_indicator() @@ -806,7 +663,7 @@ class DazedMTLGUI(QMainWindow): def clear_update_indicator(self): """Restore the default update button appearance.""" - self._pending_updates = {} + self._pending_tool_sha = None if not self.btn_update: return self.btn_update.setToolTip("Check for Updates") @@ -817,11 +674,11 @@ class DazedMTLGUI(QMainWindow): def show_update_dialog(self): """Open the update dialog and check for a newer version.""" - dlg = UpdateDialog(self, pending_updates=self._pending_updates.copy()) - dlg.start(check_only=True) + dlg = UpdateDialog(self, pending_tool_sha=self._pending_tool_sha) + dlg.start() dlg.exec_() - self._pending_updates = dlg._pending - if any_updates_available(dlg._pending): + self._pending_tool_sha = dlg._pending_tool_sha + if self._pending_tool_sha: self.set_update_indicator() else: self.clear_update_indicator() diff --git a/tests/test_bundled_updates.py b/tests/test_bundled_updates.py new file mode 100644 index 0000000..d5a5048 --- /dev/null +++ b/tests/test_bundled_updates.py @@ -0,0 +1,312 @@ +"""Integration tests for bundled-only utility updates and tool-only auto-update checks. + +These tests use temporary directories and mocks — no live network or GUI window. +They guard the security model: third-party utilities must not fetch upstream unless +a maintainer explicitly uses --force / --refresh-offline / --refresh-all. +""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from util.wolfdawn import WolfDawnError, bundled_binary_path, ensure_wolf_binary + + +class CheckToolUpdateTests(unittest.TestCase): + """Tool update check compares remote SHA to data/last_update_sha.txt only.""" + + def test_returns_sha_when_remote_differs(self): + with tempfile.TemporaryDirectory() as raw: + sha_file = Path(raw) / "last_update_sha.txt" + sha_file.write_text("aaaaaaaa", encoding="utf-8") + from gui.main import UpdateThread, check_tool_update + + with patch.object(UpdateThread, "SHA_FILE", str(sha_file)): + with patch.object( + UpdateThread, "fetch_latest_sha", return_value="bbbbbbbb" + ): + self.assertEqual(check_tool_update(), "bbbbbbbb") + + def test_returns_none_when_up_to_date(self): + with tempfile.TemporaryDirectory() as raw: + sha_file = Path(raw) / "last_update_sha.txt" + sha_file.write_text("same1234", encoding="utf-8") + from gui.main import UpdateThread, check_tool_update + + with patch.object(UpdateThread, "SHA_FILE", str(sha_file)): + with patch.object( + UpdateThread, "fetch_latest_sha", return_value="same1234" + ): + self.assertIsNone(check_tool_update()) + + def test_returns_none_on_fetch_failure(self): + from gui.main import UpdateThread, check_tool_update + + with patch.object( + UpdateThread, "fetch_latest_sha", side_effect=OSError("offline") + ): + self.assertIsNone(check_tool_update()) + + +class AceBundledOnlyTests(unittest.TestCase): + def _ace_paths(self, base: Path) -> dict: + offline = base / "offline" + offline.mkdir(parents=True, exist_ok=True) + return { + "OFFLINE_DIR": offline, + "RV2JSON_LOCAL": base / "RV2JSON.exe", + "DECRYPTER_LOCAL": base / "RPGMakerDecrypter-cli.exe", + "DECRYPTER_LEGACY": base / "RPGMakerDecrypter.exe", + "VERSION_FILE": base / ".tools_version.json", + } + + def test_seed_overwrites_stale_runtime_copy(self): + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + paths = self._ace_paths(base) + (paths["OFFLINE_DIR"] / "RV2JSON.exe").write_bytes(b"OFFLINE") + paths["RV2JSON_LOCAL"].write_bytes(b"STALE") + + import util.ace.update_tools as ace + + with patch.multiple("util.ace.update_tools", **paths): + ace._seed_from_offline(paths["RV2JSON_LOCAL"], "RV2JSON.exe", None) + self.assertEqual(paths["RV2JSON_LOCAL"].read_bytes(), b"OFFLINE") + + def test_ensure_rv2json_does_not_contact_upstream(self): + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + paths = self._ace_paths(base) + (paths["OFFLINE_DIR"] / "RV2JSON.exe").write_bytes(b"OK") + + import util.ace.update_tools as ace + + with patch.multiple("util.ace.update_tools", **paths): + with patch.object( + ace, + "_rv2json_upstream", + side_effect=AssertionError("must not fetch upstream"), + ): + self.assertTrue(ace.ensure_rv2json(force=False)) + self.assertEqual(paths["RV2JSON_LOCAL"].read_bytes(), b"OK") + + def test_ensure_rv2json_fails_when_nothing_bundled(self): + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + paths = self._ace_paths(base) + + import util.ace.update_tools as ace + + with patch.multiple("util.ace.update_tools", **paths): + with patch.object( + ace, + "_rv2json_upstream", + side_effect=AssertionError("must not fetch upstream"), + ): + self.assertFalse(ace.ensure_rv2json(force=False)) + + def test_force_delegates_to_upstream_fetch(self): + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + paths = self._ace_paths(base) + + import util.ace.update_tools as ace + + with patch.multiple("util.ace.update_tools", **paths): + with patch.object(ace, "_fetch_rv2json_upstream", return_value=True) as fetch: + self.assertTrue(ace.ensure_rv2json(force=True)) + fetch.assert_called_once() + + +class ForgeBundledOnlyTests(unittest.TestCase): + def _forge_paths(self, base: Path, *, with_plugins: bool) -> dict: + upstream = base / "upstream" + upstream.mkdir(parents=True, exist_ok=True) + plugin_body = b"// forge plugin\n" + paths = { + "_PKG_ROOT": base, + "UPSTREAM_DIR": upstream, + "VERSION_FILE": base / ".forge_version.json", + } + if with_plugins: + for name in ("Forge_MV.js", "Forge_MZ.js"): + (base / name).write_bytes(plugin_body) + (upstream / name).write_bytes(plugin_body) + return paths + + def test_ensure_forge_plugins_does_not_contact_upstream(self): + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + paths = self._forge_paths(base, with_plugins=True) + + import util.forge.update_tools as forge + + with patch.multiple("util.forge.update_tools", **paths): + with patch.object( + forge, + "_upstream_commit", + side_effect=AssertionError("must not fetch upstream"), + ): + with patch.object( + forge, + "refresh_forge_plugins", + side_effect=AssertionError("must not refresh upstream"), + ): + self.assertTrue(forge.ensure_forge_plugins(force=False)) + + def test_ensure_forge_plugins_seeds_from_upstream_folder(self): + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + paths = self._forge_paths(base, with_plugins=False) + (paths["UPSTREAM_DIR"] / "Forge_MV.js").write_bytes(b"// mv") + (paths["UPSTREAM_DIR"] / "Forge_MZ.js").write_bytes(b"// mz") + + import util.forge.update_tools as forge + + with patch.multiple("util.forge.update_tools", **paths): + self.assertTrue(forge.ensure_forge_plugins(force=False)) + self.assertTrue((base / "Forge_MV.js").is_file()) + self.assertTrue((base / "Forge_MZ.js").is_file()) + + def test_ensure_forge_plugins_fails_when_missing_everywhere(self): + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + paths = self._forge_paths(base, with_plugins=False) + + import util.forge.update_tools as forge + + with patch.multiple("util.forge.update_tools", **paths): + self.assertFalse(forge.ensure_forge_plugins(force=False)) + + def test_force_calls_refresh_forge_plugins(self): + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + paths = self._forge_paths(base, with_plugins=True) + + import util.forge.update_tools as forge + + with patch.multiple("util.forge.update_tools", **paths): + with patch.object(forge, "refresh_forge_plugins", return_value=True) as refresh: + self.assertTrue(forge.ensure_forge_plugins(force=True)) + refresh.assert_called_once() + + +class WolfBundledOnlyTests(unittest.TestCase): + def test_ensure_wolf_binary_uses_bundled_path(self): + with tempfile.TemporaryDirectory() as raw: + wolf = Path(raw) / "wolf" + wolf.write_bytes(b"\x7fELF") + with patch("util.wolfdawn.bundled_binary_path", return_value=wolf): + self.assertEqual(ensure_wolf_binary(), wolf) + + def test_ensure_wolf_binary_raises_when_missing(self): + missing = Path("/nonexistent/wolf/missing") + with patch("util.wolfdawn.bundled_binary_path", return_value=missing): + with self.assertRaises(WolfDawnError) as ctx: + ensure_wolf_binary() + self.assertIn("Update DazedMTLTool", str(ctx.exception)) + + def test_ensure_wolf_binary_never_downloads(self): + missing = Path("/nonexistent/wolf/missing") + with patch("util.wolfdawn.bundled_binary_path", return_value=missing): + with patch( + "util.wolfdawn.download_wolf_binary", + side_effect=AssertionError("must not download at runtime"), + ): + with self.assertRaises(WolfDawnError): + ensure_wolf_binary() + + def test_ensure_wolfdawn_binary_does_not_refresh_by_default(self): + import util.wolfdawn.update_tools as wd_update + + wolf = bundled_binary_path() + if not wolf.is_file(): + self.skipTest(f"no bundled wolf binary at {wolf}") + + with patch.object( + wd_update, + "refresh_wolfdawn_binary", + side_effect=AssertionError("must not refresh upstream"), + ): + self.assertTrue(wd_update.ensure_wolfdawn_binary(force=False)) + + def test_ensure_wolfdawn_binary_force_calls_refresh(self): + import util.wolfdawn.update_tools as wd_update + + with patch.object(wd_update, "refresh_wolfdawn_binary", return_value=True) as refresh: + with patch.object(wd_update, "bundled_binary_path", return_value=Path("/wolf")): + with patch("pathlib.Path.is_file", return_value=True): + self.assertTrue(wd_update.ensure_wolfdawn_binary(force=True)) + refresh.assert_called_once() + + +class MaintainerRefreshTests(unittest.TestCase): + """Maintainer CLI paths should still wire upstream fetch when explicitly invoked.""" + + def test_refresh_offline_bundle_downloads_into_offline_dir(self): + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + offline = base / "offline" + offline.mkdir() + + import util.ace.update_tools as ace + + paths = { + "OFFLINE_DIR": offline, + "RV2JSON_LOCAL": base / "RV2JSON.exe", + "DECRYPTER_LOCAL": base / "RPGMakerDecrypter-cli.exe", + "DECRYPTER_LEGACY": base / "RPGMakerDecrypter.exe", + "VERSION_FILE": base / ".tools_version.json", + } + + def fake_download(url: str, dest: Path) -> None: + dest.write_bytes(b"downloaded") + + with patch.multiple("util.ace.update_tools", **paths): + with patch.object( + ace, + "_rv2json_upstream", + return_value=("sha1", "https://example/rv2json"), + ): + with patch.object( + ace, + "_decrypter_upstream", + return_value=("v1", "https://example/dec"), + ): + with patch.object(ace, "_download", side_effect=fake_download): + self.assertTrue(ace.refresh_offline_bundle(log_fn=None)) + + self.assertEqual((offline / "RV2JSON.exe").read_bytes(), b"downloaded") + self.assertEqual( + (offline / ace.DECRYPTER_ASSET).read_bytes(), b"downloaded" + ) + + def test_refresh_forge_plugins_installs_downloaded_bytes(self): + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + upstream = base / "upstream" + upstream.mkdir() + + import util.forge.update_tools as forge + + paths = { + "_PKG_ROOT": base, + "UPSTREAM_DIR": upstream, + "VERSION_FILE": base / ".forge_version.json", + } + plugin = b"// downloaded forge\n" + + with patch.multiple("util.forge.update_tools", **paths): + with patch.object(forge, "_upstream_commit", return_value="abc123"): + with patch.object(forge, "_fetch_plugins", return_value=plugin): + self.assertTrue(forge.refresh_forge_plugins(log_fn=None)) + + self.assertEqual((base / "Forge_MV.js").read_bytes(), plugin) + self.assertEqual((upstream / "Forge_MZ.js").read_bytes(), plugin) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/util/ace/__init__.py b/util/ace/__init__.py index eee6e89..50e44bf 100644 --- a/util/ace/__init__.py +++ b/util/ace/__init__.py @@ -1,3 +1,3 @@ -"""RPG Maker Ace helper binaries (downloaded on demand, not committed to git).""" +"""RPG Maker Ace helper binaries (seeded from offline bundle, not committed to git).""" __all__ = ["ace_tool_path", "build_decrypter_command", "ensure_ace_tools"] diff --git a/util/ace/update_tools.py b/util/ace/update_tools.py index 41021ac..b81b128 100644 --- a/util/ace/update_tools.py +++ b/util/ace/update_tools.py @@ -2,6 +2,10 @@ RV2JSON.exe — https://github.com/Sinflower/RV2JSON (bin/RV2JSON.exe) Decrypter CLI — https://github.com/uuksu/RPGMakerDecrypter (release asset) + +End users receive curated copies via the offline bundle (``util/ace/offline/``) +shipped with DazedMTLTool updates. Upstream fetches are maintainer-only +(``--refresh-offline`` or ``--force``). """ from __future__ import annotations @@ -67,15 +71,13 @@ def _log(msg: str, log_fn) -> None: def _seed_from_offline(local: Path, offline_name: str, log_fn) -> bool: - """Copy a bundled offline exe into the active path if missing.""" - if local.is_file(): - return True + """Copy a bundled offline exe into the active path when the bundle exists.""" src = OFFLINE_DIR / offline_name if not src.is_file(): - return False + return local.is_file() local.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, local) - _log(f"Using offline bundled {offline_name}", log_fn) + _log(f"Synced {offline_name} from offline bundle", log_fn) return True @@ -103,9 +105,8 @@ def _decrypter_upstream() -> tuple[str, str]: raise RuntimeError(f"No {DECRYPTER_ASSET} asset in latest {DECRYPTER_REPO} release") -def ensure_rv2json(force: bool = False, log_fn=print) -> bool: - """Download RV2JSON.exe if missing or upstream changed. Returns True if ready.""" - _seed_from_offline(RV2JSON_LOCAL, "RV2JSON.exe", log_fn) +def _fetch_rv2json_upstream(force: bool, log_fn=print) -> bool: + """Download RV2JSON.exe from upstream (maintainer-only).""" versions = _load_versions() try: remote_sha, download_url = _rv2json_upstream() @@ -113,8 +114,6 @@ def ensure_rv2json(force: bool = False, log_fn=print) -> bool: if RV2JSON_LOCAL.is_file(): _log(f"Warning: Could not check RV2JSON update ({exc}); using local copy.", log_fn) return True - if _seed_from_offline(RV2JSON_LOCAL, "RV2JSON.exe", log_fn): - return True _log(f"ERROR: RV2JSON unavailable ({exc}).", log_fn) return False @@ -129,8 +128,6 @@ def ensure_rv2json(force: bool = False, log_fn=print) -> bool: if RV2JSON_LOCAL.is_file(): _log(f"Warning: RV2JSON update failed ({exc}); keeping existing copy.", log_fn) return True - if _seed_from_offline(RV2JSON_LOCAL, "RV2JSON.exe", log_fn): - return True _log(f"ERROR: RV2JSON download failed: {exc}", log_fn) return False @@ -140,9 +137,8 @@ def ensure_rv2json(force: bool = False, log_fn=print) -> bool: return True -def ensure_decrypter(force: bool = False, log_fn=print) -> bool: - """Download RPGMakerDecrypter CLI if missing or upstream release changed.""" - _seed_from_offline(DECRYPTER_LOCAL, DECRYPTER_ASSET, log_fn) +def _fetch_decrypter_upstream(force: bool, log_fn=print) -> bool: + """Download RPGMakerDecrypter CLI from upstream (maintainer-only).""" if DECRYPTER_LOCAL.is_file(): pass # prefer CLI (offline bundle or download) elif DECRYPTER_LEGACY.is_file(): @@ -156,8 +152,6 @@ def ensure_decrypter(force: bool = False, log_fn=print) -> bool: if DECRYPTER_LOCAL.is_file() or DECRYPTER_LEGACY.is_file(): _log(f"Warning: Could not check decrypter update ({exc}); using local copy.", log_fn) return True - if _seed_from_offline(DECRYPTER_LOCAL, DECRYPTER_ASSET, log_fn): - return True _log(f"ERROR: Decrypter unavailable ({exc}).", log_fn) return False @@ -172,8 +166,6 @@ def ensure_decrypter(force: bool = False, log_fn=print) -> bool: if DECRYPTER_LOCAL.is_file() or DECRYPTER_LEGACY.is_file(): _log(f"Warning: Decrypter update failed ({exc}); keeping existing copy.", log_fn) return True - if _seed_from_offline(DECRYPTER_LOCAL, DECRYPTER_ASSET, log_fn): - return True _log(f"ERROR: Decrypter download failed: {exc}", log_fn) return False @@ -183,40 +175,52 @@ def ensure_decrypter(force: bool = False, log_fn=print) -> bool: return True +def ensure_rv2json(force: bool = False, log_fn=print) -> bool: + """Ensure RV2JSON.exe is present from the offline bundle (no upstream fetch).""" + _seed_from_offline(RV2JSON_LOCAL, "RV2JSON.exe", log_fn) + if force: + return _fetch_rv2json_upstream(force=force, log_fn=log_fn) + if RV2JSON_LOCAL.is_file(): + return True + _log( + f"ERROR: RV2JSON.exe not found. Update DazedMTLTool or ask the maintainer " + f"to refresh util/ace/offline/RV2JSON.exe.", + log_fn, + ) + return False + + +def ensure_decrypter(force: bool = False, log_fn=print) -> bool: + """Ensure the RPG Maker decrypter CLI is present from the offline bundle.""" + _seed_from_offline(DECRYPTER_LOCAL, DECRYPTER_ASSET, log_fn) + if DECRYPTER_LOCAL.is_file(): + if not force: + return True + elif DECRYPTER_LEGACY.is_file() and not force: + _log("Using legacy RPGMakerDecrypter.exe (local).", log_fn) + return True + + if force: + return _fetch_decrypter_upstream(force=force, log_fn=log_fn) + + if DECRYPTER_LOCAL.is_file() or DECRYPTER_LEGACY.is_file(): + return True + _log( + f"ERROR: {DECRYPTER_ASSET} not found. Update DazedMTLTool or ask the maintainer " + f"to refresh util/ace/offline/{DECRYPTER_ASSET}.", + log_fn, + ) + return False + + def seed_ace_tools(log_fn=None) -> None: - """Copy offline-bundled Ace tools if missing (no network).""" + """Sync runtime Ace tools from the offline bundle (no network).""" _seed_from_offline(RV2JSON_LOCAL, "RV2JSON.exe", log_fn) _seed_from_offline(DECRYPTER_LOCAL, DECRYPTER_ASSET, log_fn) -def check_ace_tools_update() -> bool: - """Return True if upstream Ace tools differ from the local copies.""" - seed_ace_tools() - versions = _load_versions() - - try: - remote_sha, _ = _rv2json_upstream() - if not RV2JSON_LOCAL.is_file() or versions.get("rv2json_sha", "") != remote_sha: - return True - except Exception: - if not RV2JSON_LOCAL.is_file(): - return True - - try: - tag, _ = _decrypter_upstream() - if not (DECRYPTER_LOCAL.is_file() or DECRYPTER_LEGACY.is_file()): - return True - if DECRYPTER_LOCAL.is_file() and versions.get("decrypter_tag", "") != tag: - return True - except Exception: - if not (DECRYPTER_LOCAL.is_file() or DECRYPTER_LEGACY.is_file()): - return True - - return False - - def ensure_ace_tools(force: bool = False, log_fn=print) -> bool: - """Ensure both Ace tools are present (download / update if needed).""" + """Ensure both Ace tools are present (offline bundle; upstream only with force=True).""" ok_rv = ensure_rv2json(force=force, log_fn=log_fn) ok_dec = ensure_decrypter(force=force, log_fn=log_fn) return ok_rv and ok_dec diff --git a/util/forge/update_tools.py b/util/forge/update_tools.py index 2bf8369..a50b917 100644 --- a/util/forge/update_tools.py +++ b/util/forge/update_tools.py @@ -4,6 +4,9 @@ Upstream: https://gitgud.io/zero64801/forge-mvmz CI builds a unified forge.js plugin (master branch artifacts). Offline copies: util/forge/upstream/ Active plugins: util/forge/Forge_MZ.js, util/forge/Forge_MV.js + +End users receive curated copies shipped with DazedMTLTool updates. +Upstream fetches are maintainer-only (``--refresh-offline`` or ``--force``). """ from __future__ import annotations @@ -164,49 +167,31 @@ def seed_forge_plugins(log_fn=None) -> None: _seed_from_offline(engine, log_fn) -def check_forge_plugins_update() -> bool: - """Return True if upstream Forge plugins differ from the local copies.""" - seed_forge_plugins() - missing = [e for e in PLUGIN_BY_ENGINE if not bundled_plugin_path(e).is_file()] - if missing: - return True - versions = _load_versions() - local_commit = versions.get("commit", "") - try: - return _upstream_commit() != local_commit - except Exception: - return False - - def ensure_forge_plugins(force: bool = False, log_fn=print) -> bool: - """Ensure Forge plugins are present; fetch upstream when missing or stale.""" + """Ensure Forge plugins are present from the offline bundle (no upstream fetch).""" for engine in PLUGIN_BY_ENGINE: _seed_from_offline(engine, log_fn) missing = [e for e in PLUGIN_BY_ENGINE if not bundled_plugin_path(e).is_file()] - versions = _load_versions() - local_commit = versions.get("commit", "") + if missing and not force: + names = ", ".join(PLUGIN_BY_ENGINE[e] + ".js" for e in missing) + _log( + f"ERROR: Forge plugin(s) missing ({names}). " + "Update DazedMTLTool or ask the maintainer to refresh util/forge/upstream/.", + log_fn, + ) + return False - need_fetch = bool(missing) or force - if not need_fetch: - try: - need_fetch = _upstream_commit() != local_commit - except Exception as exc: - if missing: - _log(f"ERROR: Forge upstream unavailable ({exc})", log_fn) - return False - _log(f"Warning: could not check Forge update ({exc}); using local copy.", log_fn) - - if need_fetch: + if force: if refresh_forge_plugins(log_fn=log_fn): return True have_local = all(bundled_plugin_path(e).is_file() for e in PLUGIN_BY_ENGINE) - if force or not have_local: + if not have_local: _log("ERROR: Forge plugin update failed.", log_fn) return False _log("Warning: could not update Forge plugins; using local copy.", log_fn) - return True + return all(bundled_plugin_path(e).is_file() for e in PLUGIN_BY_ENGINE) def main() -> int: diff --git a/util/wolfdawn/__init__.py b/util/wolfdawn/__init__.py index ecae330..3413baf 100644 --- a/util/wolfdawn/__init__.py +++ b/util/wolfdawn/__init__.py @@ -3,9 +3,8 @@ WolfDawn is the Rust toolchain (https://gitgud.io/zero64801/wolfdawn) that unpacks, extracts, injects, and repacks WOLF RPG Editor game data. DazedMTLTool ships prebuilt ``wolf`` binaries offline under ``util/wolfdawn/bin//`` -so end users never need a Rust toolchain. When no offline binary is bundled for -the running platform, the tool pulls a prebuilt one from the WolfDawn release -page and caches it into that same folder. Everything the Wolf workflow needs +so end users never need a Rust toolchain or a live upstream fetch. Binaries are +updated when DazedMTLTool itself is updated. Everything the Wolf workflow needs goes through the helpers here so command syntax and exit-code handling live in one place. @@ -97,7 +96,7 @@ def inject_had_applied(applied: int | None) -> bool: return applied is not None and applied > 0 # Committed, prebuilt binaries live here (per-platform) so end users don't need a -# Rust toolchain. When one is missing we download it from the WolfDawn release. +# Rust toolchain or a live upstream fetch. _PACKAGE_DIR = Path(__file__).resolve().parent _BUNDLED_DIR = _PACKAGE_DIR / "bin" @@ -266,23 +265,20 @@ def download_wolf_binary(platform: Optional[str] = None, log_fn=print) -> Path: return _extract_wolf_from_zip(zip_bytes, dest, log_fn) -def ensure_wolf_binary(force: bool = False, log_fn=print) -> Path: - """Return the ``wolf`` binary path, downloading it only if needed. +def ensure_wolf_binary(log_fn=print) -> Path: + """Return the bundled ``wolf`` binary path (no upstream fetch at runtime). - Resolution order: - 1. Committed/cached prebuilt binary under ``util/wolfdawn/bin//`` - — used directly so the tool works fully offline. - 2. Otherwise (or when ``force`` is set), pull the prebuilt binary from the - WolfDawn release page and cache it into that same folder. - - Raises :class:`WolfDawnError` with actionable guidance when no offline binary - is present and the download can't be completed. + Raises :class:`WolfDawnError` when no offline binary is present for this + platform. Maintainers refresh bundled binaries with + ``python -m util.wolfdawn.update_tools --refresh-all``. """ - if not force: - bundled = bundled_binary_path() - if bundled.is_file(): - return bundled - return download_wolf_binary(log_fn=log_fn) + bundled = bundled_binary_path() + if bundled.is_file(): + return bundled + raise WolfDawnError( + f"No bundled WolfDawn binary for '{_platform_dir()}' at {bundled}. " + "Update DazedMTLTool to receive a prebuilt wolf binary." + ) def _log(msg: str, log_fn) -> None: diff --git a/util/wolfdawn/update_tools.py b/util/wolfdawn/update_tools.py index 5dd04e1..3e09596 100644 --- a/util/wolfdawn/update_tools.py +++ b/util/wolfdawn/update_tools.py @@ -1,9 +1,7 @@ -"""Check for and apply WolfDawn ``wolf`` binary updates. +"""Check for and apply WolfDawn ``wolf`` binary updates (maintainer-only upstream fetch). -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 +End users receive prebuilt binaries under ``util/wolfdawn/bin//`` via +DazedMTLTool updates. Maintainers refresh them with ``--refresh-all`` or ``--force``. """ from __future__ import annotations @@ -22,7 +20,6 @@ from util.wolfdawn import ( ) from util.wolfdawn.build_tools import ( build_and_install_platforms, - buildable_from_source, upstream_commit, ) @@ -69,39 +66,6 @@ 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: @@ -169,30 +133,27 @@ def refresh_wolfdawn_binary( def ensure_wolfdawn_binary(force: bool = False, log_fn=print) -> bool: - """Ensure the ``wolf`` binary is present; refresh when missing or stale.""" + """Ensure the bundled ``wolf`` binary is present (no upstream fetch by default).""" 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 force: 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) + if bundled.is_file(): + _log("Warning: WolfDawn update failed; using bundled copy.", log_fn) + return True + _log("ERROR: WolfDawn update failed.", log_fn) + return False - return bundled.is_file() + if bundled.is_file(): + return True + _log( + f"ERROR: no bundled WolfDawn binary for '{platform}' at {bundled}. " + "Update DazedMTLTool to receive a prebuilt wolf binary.", + log_fn, + ) + return False def main() -> int: