diff --git a/gui/wolf_workflow_tab.py b/gui/wolf_workflow_tab.py
index a42c6b7..2be7abd 100644
--- a/gui/wolf_workflow_tab.py
+++ b/gui/wolf_workflow_tab.py
@@ -4283,6 +4283,19 @@ class WolfWorkflowTab(QWidget):
"archive's encryption where possible (--like the backed-up original)."
))
+ layout.addWidget(_make_hr())
+ layout.addWidget(self._subheading("Public release"))
+ layout.addWidget(self._desc(
+ "Create a player-ready ZIP of the complete game. Translator workspaces, version "
+ "control, documentation, backups, saves, and other tool artifacts are omitted; "
+ "GameUpdate files remain available to players. The game folder is not modified."
+ ))
+ self._release_zip_btn = self._register(
+ _make_btn("📦 Create Public Release ZIP", "#007acc")
+ )
+ self._release_zip_btn.clicked.connect(self._create_public_release)
+ layout.addWidget(self._release_zip_btn)
+
layout.addWidget(_make_hr())
layout.addWidget(self._subheading("Update existing saves (optional)"))
layout.addWidget(self._desc(
@@ -4356,6 +4369,43 @@ class WolfWorkflowTab(QWidget):
self._run_task(task)
+ def _create_public_release(self):
+ if not self._require_root():
+ return
+
+ from util.release_package import default_release_zip_path
+
+ suggested = str(default_release_zip_path(self._game_root))
+ output, _ = QFileDialog.getSaveFileName(
+ self,
+ "Create Public Release ZIP",
+ suggested,
+ "ZIP archives (*.zip)",
+ )
+ if not output:
+ return
+
+ game_root = self._game_root
+
+ def task(log, progress=None):
+ from util.release_package import create_release_zip
+
+ log(f"Creating public release ZIP from {game_root} …")
+ result = create_release_zip(game_root, output, progress=progress)
+ size_mb = result.output_path.stat().st_size / (1024 * 1024)
+ log(
+ f" Added {result.files_added} file(s), omitted "
+ f"{result.excluded_entries} tool/private item(s)."
+ )
+ if result.sanitized_plugin_lists:
+ log(" Removed playtest plugins from the archived plugins.js.")
+ return True, (
+ f"Created {result.output_path.name} ({size_mb:.1f} MB) at "
+ f"{result.output_path}."
+ )
+
+ self._run_task(task)
+
def _browse_saves(self):
start = self.save_edit.text() or (str(Path(self._game_root) / "Save") if self._game_root else str(Path.home()))
folder = QFileDialog.getExistingDirectory(self, "Select Save Folder", start)
diff --git a/gui/workflow_tab.py b/gui/workflow_tab.py
index e1bbc75..f2b4ea7 100644
--- a/gui/workflow_tab.py
+++ b/gui/workflow_tab.py
@@ -371,6 +371,34 @@ class _FileCopyWorker(QThread):
self.done.emit(copied, errors)
+class _ReleaseZipWorker(QThread):
+ """Build a sanitized public-release ZIP without blocking the GUI."""
+
+ done = pyqtSignal(object)
+ error = pyqtSignal(str)
+ progress = pyqtSignal(int, int, str)
+
+ def __init__(self, game_root: str, output_path: str):
+ super().__init__()
+ self.game_root = game_root
+ self.output_path = output_path
+
+ def run(self):
+ try:
+ from util.release_package import create_release_zip
+
+ result = create_release_zip(
+ self.game_root,
+ self.output_path,
+ progress=lambda current, total, label: self.progress.emit(
+ current, total, label
+ ),
+ )
+ self.done.emit(result)
+ except Exception as exc:
+ self.error.emit(str(exc))
+
+
class _JsFormatWorker(QThread):
"""Format a JavaScript file using jsbeautifier (pure Python, no Node required)."""
done = pyqtSignal(bool, str)
@@ -495,7 +523,10 @@ _STEP_HELP: dict[int, str] = {
"Export - copy finished translations from translated/ back "
"into the game's data folder:
"
"• Export Active Files - only names currently in files/
"
- "• Export ALL - everything under translated/"
+ "• Export ALL - everything under translated/
"
+ "Create Public Release ZIP packages the complete game beside its folder while "
+ "omitting translator workspaces, VCS metadata, documentation, backups, saves, and "
+ "playtest plugins. GameUpdate files are kept."
),
6: (
"Step 6 - Images (MV/MZ)
"
@@ -746,6 +777,7 @@ class WorkflowTab(QWidget):
self._current_step_index: int = 0
self._last_import_signature: tuple[str, ...] | None = None
self._pending_import_signature: tuple[str, ...] | None = None
+ self._release_zip_btn: QPushButton | None = None
self._init_ui()
@@ -2191,6 +2223,16 @@ class WorkflowTab(QWidget):
export_all_btn.clicked.connect(self._export_to_game)
inner.addLayout(_labeled_row(export_lbl, export_active_btn, export_all_btn))
+
+ release_lbl = QLabel("Release")
+ self._release_zip_btn = _make_btn("📦 Create Public Release ZIP", "#007acc")
+ self._release_zip_btn.setToolTip(
+ "Archive the detected game folder for players. Excludes DazedTL workspaces, "
+ "version-control files, documentation, backups, saves, and playtest plugins; "
+ "keeps GameUpdate files. The source game folder is not changed."
+ )
+ self._release_zip_btn.clicked.connect(self._create_public_release)
+ inner.addLayout(_labeled_row(release_lbl, self._release_zip_btn))
layout.addWidget(box, 0, Qt.AlignLeft)
# ── Step 6: Editable images ─────────────────────────────────────────────
@@ -4115,6 +4157,69 @@ class WorkflowTab(QWidget):
self._worker = w
w.start()
+ def _create_public_release(self):
+ game_root = self.folder_edit.text().strip()
+ if not game_root or not Path(game_root).is_dir():
+ QMessageBox.warning(
+ self,
+ "No game folder",
+ "Select and detect a game folder in Step 0 first.",
+ )
+ return
+ if self._worker is not None and self._worker.isRunning():
+ QMessageBox.information(self, "Busy", "A task is already running. Please wait.")
+ return
+
+ from util.release_package import default_release_zip_path
+
+ suggested = str(default_release_zip_path(game_root))
+ output, _ = QFileDialog.getSaveFileName(
+ self,
+ "Create Public Release ZIP",
+ suggested,
+ "ZIP archives (*.zip)",
+ )
+ if not output:
+ return
+
+ self._log(f"Creating public release ZIP from {game_root} …")
+ worker = _ReleaseZipWorker(game_root, output)
+ self._worker = worker
+ if self._release_zip_btn is not None:
+ self._release_zip_btn.setEnabled(False)
+
+ def finished():
+ if self._worker is worker:
+ self._worker = None
+ if self._release_zip_btn is not None:
+ self._release_zip_btn.setEnabled(True)
+
+ def failed(message: str):
+ self._log(f"❌ Public release ZIP: {message}")
+ QMessageBox.warning(self, "Release ZIP failed", message)
+
+ worker.done.connect(self._on_public_release_done)
+ worker.error.connect(failed)
+ worker.finished.connect(finished)
+ worker.finished.connect(worker.deleteLater)
+ worker.start()
+
+ def _on_public_release_done(self, result):
+ size_mb = result.output_path.stat().st_size / (1024 * 1024)
+ message = (
+ f"Created {result.output_path.name} ({size_mb:.1f} MB) with "
+ f"{result.files_added} file(s); omitted {result.excluded_entries} "
+ "tool/private item(s)."
+ )
+ if result.sanitized_plugin_lists:
+ message += " Removed playtest plugins from the archived plugins.js."
+ self._log(f"✅ {message}")
+ QMessageBox.information(
+ self,
+ "Public Release ZIP created",
+ f"{message}\n\nSaved to:\n{result.output_path}",
+ )
+
def _resolve_export_path(self) -> str | None:
"""Return the game data path, prompting if not yet set."""
game_data = self._data_path
diff --git a/tests/test_release_package.py b/tests/test_release_package.py
new file mode 100644
index 0000000..b76ce11
--- /dev/null
+++ b/tests/test_release_package.py
@@ -0,0 +1,179 @@
+#!/usr/bin/env python3
+"""Tests for player-ready public release archives and workflow buttons."""
+
+from __future__ import annotations
+
+import tempfile
+import unittest
+import zipfile
+from pathlib import Path
+from unittest.mock import patch
+
+from PyQt5.QtCore import QSettings
+from PyQt5.QtWidgets import QApplication
+
+from util.release_package import ReleasePackageError, create_release_zip
+
+
+class ReleasePackageTests(unittest.TestCase):
+ def _write(self, root: Path, relative: str, content: bytes = b"content") -> Path:
+ path = root / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(content)
+ return path
+
+ def test_release_keeps_game_and_updater_but_omits_tool_artifacts(self):
+ with tempfile.TemporaryDirectory() as raw:
+ base = Path(raw)
+ game = base / "Translated Game"
+ game.mkdir()
+
+ self._write(game, "Game.exe", b"game")
+ self._write(game, "Data.wolf", b"translated archive")
+ self._write(game, "Data.wolf.bak", b"original backup")
+ self._write(game, "Data/manual.txt", b"runtime text")
+ self._write(game, "Save01.sav", b"translator save")
+ self._write(game, "Save/Slot1.rpgsave", b"translator save")
+ self._write(game, ".git/config")
+ self._write(game, ".dazedtl/images/menu.png")
+ self._write(game, "wolf_json/manifest.json")
+ self._write(game, "ace_json/Actors.json")
+ self._write(game, "scripts/release.py")
+ self._write(game, "docs/translation-notes.md")
+ self._write(game, "skills/game.md")
+ self._write(game, "vocab.txt")
+ self._write(game, "README.md")
+ self._write(game, ".gitignore")
+ self._write(game, ".env", b"key=secret")
+
+ self._write(game, "GameUpdate.bat", b"launcher")
+ self._write(game, "GameUpdate_linux.sh", b"launcher")
+ self._write(game, "UberWolfCli.exe", b"wolf updater")
+ self._write(game, "UberWolfCli.LICENSE.txt", b"license")
+ self._write(game, "gameupdate/patch.ps1", b"patch")
+ self._write(game, "gameupdate/patch-config.txt", b"repo=game")
+ self._write(game, "gameupdate/previous_patch_sha.txt", b"private state")
+
+ plugins_js = b"""var $plugins =
+[
+ {"name":"CorePlugin","status":true,"parameters":{"label":"keep"}},
+ {"name":"TLInspector","status":true,"parameters":{}},
+ {"name":"Forge_MZ","status":true,"parameters":{}}
+];
+"""
+ source_plugins = self._write(game, "js/plugins.js", plugins_js)
+ self._write(game, "js/plugins/CorePlugin.js", b"core")
+ self._write(game, "js/plugins/TLInspector.js", b"inspector")
+ self._write(game, "js/plugins/Forge_MZ.js", b"forge")
+
+ output = base / "release.zip"
+ progress = []
+ result = create_release_zip(
+ game,
+ output,
+ progress=lambda current, total, label: progress.append(
+ (current, total, label)
+ ),
+ )
+
+ prefix = "Translated Game/"
+ with zipfile.ZipFile(output) as archive:
+ names = set(archive.namelist())
+ archived_plugins = archive.read(prefix + "js/plugins.js").decode("utf-8")
+
+ for relative in (
+ "Game.exe",
+ "Data.wolf",
+ "Data/manual.txt",
+ "GameUpdate.bat",
+ "GameUpdate_linux.sh",
+ "UberWolfCli.exe",
+ "UberWolfCli.LICENSE.txt",
+ "gameupdate/patch.ps1",
+ "gameupdate/patch-config.txt",
+ "js/plugins/CorePlugin.js",
+ ):
+ self.assertIn(prefix + relative, names)
+
+ for relative in (
+ "Data.wolf.bak",
+ "Save01.sav",
+ "Save/Slot1.rpgsave",
+ ".git/config",
+ ".dazedtl/images/menu.png",
+ "wolf_json/manifest.json",
+ "ace_json/Actors.json",
+ "scripts/release.py",
+ "docs/translation-notes.md",
+ "skills/game.md",
+ "vocab.txt",
+ "README.md",
+ ".gitignore",
+ ".env",
+ "gameupdate/previous_patch_sha.txt",
+ "js/plugins/TLInspector.js",
+ "js/plugins/Forge_MZ.js",
+ ):
+ self.assertNotIn(prefix + relative, names)
+
+ self.assertIn('"name":"CorePlugin"', archived_plugins)
+ self.assertNotIn("TLInspector", archived_plugins)
+ self.assertNotIn("Forge_MZ", archived_plugins)
+ self.assertEqual(source_plugins.read_bytes(), plugins_js)
+ self.assertEqual(result.output_path, output.resolve())
+ self.assertEqual(result.sanitized_plugin_lists, 1)
+ self.assertEqual(progress[-1][:2], (result.files_added, result.files_added))
+
+ def test_release_path_must_be_outside_game_folder(self):
+ with tempfile.TemporaryDirectory() as raw:
+ game = Path(raw) / "Game"
+ game.mkdir()
+ self._write(game, "Game.exe")
+ output = game / "release.zip"
+
+ with self.assertRaisesRegex(ReleasePackageError, "outside the game folder"):
+ create_release_zip(game, output)
+
+ self.assertFalse(output.exists())
+
+ def test_zip_suffix_is_added(self):
+ with tempfile.TemporaryDirectory() as raw:
+ base = Path(raw)
+ game = base / "Game"
+ game.mkdir()
+ self._write(game, "Game.exe")
+
+ result = create_release_zip(game, base / "release")
+
+ self.assertEqual(result.output_path.name, "release.zip")
+ self.assertTrue(result.output_path.is_file())
+
+
+class ReleaseWorkflowButtonTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.app = QApplication.instance() or QApplication([])
+
+ def test_both_workflows_expose_public_release_button(self):
+ from gui.workflow_tab import WorkflowTab
+ from gui.wolf_workflow_tab import WolfWorkflowTab
+
+ with tempfile.TemporaryDirectory() as raw:
+ settings = QSettings(str(Path(raw) / "settings.ini"), QSettings.IniFormat)
+ with (
+ patch("gui.workflow_tab.QSettings", return_value=settings),
+ patch("gui.wolf_workflow_tab.QSettings", return_value=settings),
+ ):
+ rpg = WorkflowTab()
+ wolf = WolfWorkflowTab()
+ try:
+ self.assertEqual(rpg._release_zip_btn.text(), "📦 Create Public Release ZIP")
+ self.assertEqual(wolf._release_zip_btn.text(), "📦 Create Public Release ZIP")
+ self.assertIn("GameUpdate", rpg._release_zip_btn.toolTip())
+ finally:
+ rpg.close()
+ wolf.close()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/util/release_package.py b/util/release_package.py
new file mode 100644
index 0000000..3c2af03
--- /dev/null
+++ b/util/release_package.py
@@ -0,0 +1,344 @@
+"""Build public game archives without translator and playtest artifacts."""
+
+from __future__ import annotations
+
+import codecs
+import os
+import re
+import tempfile
+import zipfile
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Callable
+
+
+ProgressCallback = Callable[[int, int, str], None]
+
+
+class ReleasePackageError(RuntimeError):
+ """Raised when a safe public-release archive cannot be produced."""
+
+
+@dataclass(frozen=True)
+class ReleasePackageResult:
+ output_path: Path
+ files_added: int
+ bytes_added: int
+ excluded_entries: int
+ sanitized_plugin_lists: int
+
+
+# These are never game payloads and should be ignored wherever they occur.
+_EXCLUDED_DIR_NAMES = frozenset(
+ {
+ ".dazedtl",
+ ".git",
+ ".hg",
+ ".svn",
+ "__pycache__",
+ ".pytest_cache",
+ ".mypy_cache",
+ ".ruff_cache",
+ }
+)
+
+# DazedTL creates these at the game root while translating or playtesting.
+_EXCLUDED_ROOT_DIR_NAMES = frozenset(
+ {
+ ".agents",
+ ".claude",
+ ".codex",
+ ".cursor",
+ ".github",
+ ".gitlab",
+ ".idea",
+ ".vscode",
+ "ace_json",
+ "docs",
+ "documentation",
+ "files",
+ "log",
+ "logs",
+ "save",
+ "saves",
+ "scripts",
+ "skills",
+ "test",
+ "tests",
+ "translated",
+ "wolf_json",
+ }
+)
+
+_EXCLUDED_ROOT_FILE_NAMES = frozenset(
+ {
+ ".editorconfig",
+ ".gitattributes",
+ ".gitignore",
+ ".pre-commit-config.yaml",
+ "readme.md",
+ "requirements.txt",
+ "todo.md",
+ "translation_quirks.txt",
+ "vocab.txt",
+ }
+)
+
+_EXCLUDED_FILE_NAMES = frozenset(
+ {
+ ".ds_store",
+ ".editorconfig",
+ ".gitattributes",
+ ".gitignore",
+ "desktop.ini",
+ "forge_mv.bat",
+ "forge_mv.js",
+ "forge_mz.bat",
+ "forge_mz.js",
+ "patch2.ps1",
+ "patch2.sh",
+ "previous_patch_sha.txt",
+ "thumbs.db",
+ "tlinspector.bat",
+ "tlinspector.js",
+ }
+)
+
+_UPDATER_ROOT_FILE_NAMES = frozenset(
+ {
+ "gameupdate.bat",
+ "gameupdate_linux.sh",
+ "uberwolfcli.exe",
+ "uberwolfcli.license.txt",
+ }
+)
+
+_ROOT_DOCUMENT_SUFFIXES = frozenset({".doc", ".docx", ".markdown", ".md", ".pdf", ".rst"})
+_BACKUP_SUFFIXES = (".bak", ".backup", ".orig", ".tmp")
+_PLUGIN_LIST_PATHS = frozenset({"js/plugins.js", "www/js/plugins.js"})
+_PLAYTEST_PLUGIN_NAMES = ("TLInspector", "Forge_MV", "Forge_MZ")
+
+
+def default_release_zip_path(game_root: str | Path) -> Path:
+ """Return a release path beside *game_root*, never inside it."""
+ root = Path(game_root).expanduser().resolve()
+ name = root.name or "game"
+ return root.parent / f"{name}-public.zip"
+
+
+def normalize_release_zip_path(output_path: str | Path) -> Path:
+ output = Path(output_path).expanduser()
+ if output.suffix.casefold() != ".zip":
+ output = output.with_name(output.name + ".zip")
+ return output.resolve()
+
+
+def _is_updater_path(relative: Path) -> bool:
+ parts = tuple(part.casefold() for part in relative.parts)
+ if not parts:
+ return False
+ if parts[0] == "gameupdate":
+ return True
+ return len(parts) == 1 and parts[0] in _UPDATER_ROOT_FILE_NAMES
+
+
+def exclusion_reason(relative: str | Path, *, is_dir: bool = False) -> str | None:
+ """Return why a game-relative path is omitted, or ``None`` when it ships."""
+ relative = Path(relative)
+ parts = tuple(part.casefold() for part in relative.parts)
+ if not parts:
+ return None
+
+ if any(part in _EXCLUDED_DIR_NAMES for part in parts):
+ return "tool metadata or cache"
+
+ name = parts[-1]
+ if name.startswith(".env"):
+ return "private environment configuration"
+ if name in _EXCLUDED_FILE_NAMES:
+ return "tool-generated or temporary file"
+
+ # Player updater files are the explicit exception to translator-artifact
+ # filtering. VCS/cache directories and updater runtime state were handled above.
+ if _is_updater_path(relative):
+ return None
+
+ if len(parts) == 1:
+ if is_dir and name in _EXCLUDED_ROOT_DIR_NAMES:
+ return "translator workspace"
+ if not is_dir:
+ if name in _EXCLUDED_ROOT_FILE_NAMES:
+ return "translator configuration or documentation"
+ if relative.suffix.casefold() in _ROOT_DOCUMENT_SUFFIXES:
+ return "root documentation file"
+ if name.startswith("save") or relative.suffix.casefold() in {".rpgsave", ".sav"}:
+ return "local save data"
+
+ if not is_dir and name.endswith(_BACKUP_SUFFIXES):
+ return "backup or temporary file"
+
+ return None
+
+
+def _iter_release_files(root: Path) -> tuple[list[tuple[Path, Path]], int]:
+ files: list[tuple[Path, Path]] = []
+ excluded = 0
+ for current, dir_names, file_names in os.walk(root, topdown=True, followlinks=False):
+ current_path = Path(current)
+ kept_dirs: list[str] = []
+ for name in sorted(dir_names, key=str.casefold):
+ path = current_path / name
+ relative = path.relative_to(root)
+ if path.is_symlink() or exclusion_reason(relative, is_dir=True):
+ excluded += 1
+ else:
+ kept_dirs.append(name)
+ dir_names[:] = kept_dirs
+
+ for name in sorted(file_names, key=str.casefold):
+ path = current_path / name
+ relative = path.relative_to(root)
+ if path.is_symlink() or exclusion_reason(relative):
+ excluded += 1
+ continue
+ files.append((path, relative))
+ return files, excluded
+
+
+def _remove_plugin_object(content: str, plugin_name: str) -> str:
+ """Remove one object from RPG Maker's JavaScript plugin-array syntax."""
+ marker = re.search(rf'"name"\s*:\s*"{re.escape(plugin_name)}"', content)
+ while marker:
+ start = content.rfind("{", 0, marker.start())
+ if start < 0:
+ raise ReleasePackageError(f"Could not remove {plugin_name} from plugins.js")
+ depth = 0
+ end = None
+ in_string = False
+ escaped = False
+ for index in range(start, len(content)):
+ char = content[index]
+ if in_string:
+ if escaped:
+ escaped = False
+ elif char == "\\":
+ escaped = True
+ elif char == '"':
+ in_string = False
+ continue
+ if char == '"':
+ in_string = True
+ elif char == "{":
+ depth += 1
+ elif char == "}":
+ depth -= 1
+ if depth == 0:
+ end = index + 1
+ break
+ if end is None:
+ raise ReleasePackageError(f"Could not parse {plugin_name} in plugins.js")
+
+ before = content[:start].rstrip()
+ after = content[end:].lstrip()
+ if after.startswith(","):
+ after = after[1:].lstrip()
+ elif before.endswith(","):
+ before = before[:-1].rstrip()
+ separator = "\n" if before and after and not before.endswith("\n") else ""
+ content = before + separator + after
+ marker = re.search(rf'"name"\s*:\s*"{re.escape(plugin_name)}"', content)
+ return re.sub(r",(\s*)\];", r"\1];", content)
+
+
+def sanitize_plugins_js(raw: bytes) -> tuple[bytes, bool]:
+ """Remove DazedTL playtest entries while preserving the source file's BOM."""
+ had_bom = raw.startswith(codecs.BOM_UTF8)
+ try:
+ content = raw.decode("utf-8-sig")
+ except UnicodeDecodeError as exc:
+ raise ReleasePackageError(f"plugins.js is not valid UTF-8: {exc}") from exc
+ original = content
+ for plugin_name in _PLAYTEST_PLUGIN_NAMES:
+ content = _remove_plugin_object(content, plugin_name)
+ if content == original:
+ return raw, False
+ encoded = content.encode("utf-8")
+ return (codecs.BOM_UTF8 + encoded if had_bom else encoded), True
+
+
+def _archive_name(root: Path, relative: Path) -> str:
+ return (Path(root.name or "game") / relative).as_posix()
+
+
+def create_release_zip(
+ game_root: str | Path,
+ output_path: str | Path,
+ *,
+ progress: ProgressCallback | None = None,
+) -> ReleasePackageResult:
+ """Create an atomic public-release ZIP and leave the source game untouched."""
+ root = Path(game_root).expanduser().resolve()
+ if not root.is_dir():
+ raise ReleasePackageError(f"Game folder not found: {root}")
+ if root.parent == root:
+ raise ReleasePackageError("A filesystem root cannot be packaged as a game folder")
+
+ output = normalize_release_zip_path(output_path)
+ try:
+ output.relative_to(root)
+ except ValueError:
+ pass
+ else:
+ raise ReleasePackageError("Save the public ZIP outside the game folder")
+ if output.exists() and output.is_dir():
+ raise ReleasePackageError(f"Release path is a directory: {output}")
+
+ output.parent.mkdir(parents=True, exist_ok=True)
+ files, excluded = _iter_release_files(root)
+ total = len(files)
+ if not total:
+ raise ReleasePackageError("No releasable game files were found")
+
+ descriptor, temporary_name = tempfile.mkstemp(
+ prefix=f".{output.name}.", suffix=".tmp", dir=output.parent
+ )
+ os.close(descriptor)
+ temporary = Path(temporary_name)
+ files_added = 0
+ bytes_added = 0
+ sanitized = 0
+ try:
+ with zipfile.ZipFile(
+ temporary,
+ mode="w",
+ compression=zipfile.ZIP_DEFLATED,
+ compresslevel=6,
+ allowZip64=True,
+ ) as archive:
+ for index, (source, relative) in enumerate(files, start=1):
+ if progress:
+ progress(index, total, relative.as_posix())
+ arcname = _archive_name(root, relative)
+ if relative.as_posix().casefold() in _PLUGIN_LIST_PATHS:
+ raw = source.read_bytes()
+ payload, changed = sanitize_plugins_js(raw)
+ info = zipfile.ZipInfo.from_file(source, arcname=arcname)
+ archive.writestr(info, payload, compress_type=zipfile.ZIP_DEFLATED)
+ sanitized += int(changed)
+ bytes_added += len(payload)
+ else:
+ archive.write(source, arcname=arcname)
+ bytes_added += source.stat().st_size
+ files_added += 1
+ os.replace(temporary, output)
+ except Exception:
+ temporary.unlink(missing_ok=True)
+ raise
+
+ return ReleasePackageResult(
+ output_path=output,
+ files_added=files_added,
+ bytes_added=bytes_added,
+ excluded_entries=excluded,
+ sanitized_plugin_lists=sanitized,
+ )