fix: harden DXVK ownership and local builds
This commit is contained in:
@@ -1 +1,39 @@
|
||||
pyinstaller --noconsole --onefile --icon=PurpleWowLogo.ico --add-data "PurpleWowLogo.ico;." --add-data "Payload;Payload" --add-data "vanilla-tweaks.exe;." --name "WoW_Modernization_Tool" setup_tool_dynamic.py
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
|
||||
where py >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo ERROR: Python launcher ^(py.exe^) was not found.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
py -3.11 -c "import sys; a=sys.version_info[:3]; sys.exit(0 if a==(3,11,9) else f'ERROR: Python 3.11.9 is required; found {a[0]}.{a[1]}.{a[2]}')"
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
py -3.11 -m pip install --disable-pip-version-check -r requirements-build.txt
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
py -3.11 -m py_compile setup_tool.py setup_tool_dynamic.py remote_packages.py tests/test_safety.py
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
py -3.11 -m unittest discover -s tests -v
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
py -3.11 -m PyInstaller ^
|
||||
--noconsole ^
|
||||
--onefile ^
|
||||
--icon=PurpleWowLogo.ico ^
|
||||
--add-data "PurpleWowLogo.ico;." ^
|
||||
--add-data "Payload;Payload" ^
|
||||
--add-data "vanilla-tweaks.exe;." ^
|
||||
--name "WoW_Modernization_Tool" ^
|
||||
setup_tool_dynamic.py
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
if not exist "dist\WoW_Modernization_Tool.exe" (
|
||||
echo ERROR: Build output is missing.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Build complete: dist\WoW_Modernization_Tool.exe
|
||||
|
||||
+125
-34
@@ -1236,41 +1236,68 @@ class WowSetupTool:
|
||||
return True
|
||||
|
||||
def clean_unselected_files(self, target):
|
||||
"""Removes managed files/folders if they were explicitly unselected by the user."""
|
||||
|
||||
# 1. Clean AutoLogin files if unselected
|
||||
"""Remove explicitly unselected tool-managed files without hiding failures."""
|
||||
|
||||
def remove_managed_file(path, label):
|
||||
if not os.path.lexists(path):
|
||||
return
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError as exc:
|
||||
raise RuntimeError(
|
||||
f"Could not remove {label}. Close WoW and any program using the file, then try again."
|
||||
) from exc
|
||||
|
||||
def remove_managed_tree(path, label):
|
||||
if not os.path.lexists(path):
|
||||
return
|
||||
try:
|
||||
shutil.rmtree(path)
|
||||
except OSError as exc:
|
||||
raise RuntimeError(
|
||||
f"Could not remove {label}. Close WoW and any program using the folder, then try again."
|
||||
) from exc
|
||||
|
||||
# 1. Clean AutoLogin files if unselected.
|
||||
if not self.install_autologin.get():
|
||||
glue_dir = os.path.join(target, "Data", "Interface", "GlueXML")
|
||||
for file_name in ["AutoLogin.lua", "AutoLogin.xml", "GlueXML.toc"]:
|
||||
file_path = os.path.join(glue_dir, file_name)
|
||||
if os.path.exists(file_path):
|
||||
try: os.remove(file_path)
|
||||
except: pass
|
||||
if os.path.exists(glue_dir) and not os.listdir(glue_dir):
|
||||
try: os.rmdir(glue_dir)
|
||||
except: pass
|
||||
remove_managed_file(
|
||||
os.path.join(glue_dir, file_name),
|
||||
f"managed AutoLogin file {file_name}",
|
||||
)
|
||||
if os.path.isdir(glue_dir):
|
||||
try:
|
||||
if not os.listdir(glue_dir):
|
||||
os.rmdir(glue_dir)
|
||||
except OSError:
|
||||
# Leaving an empty directory behind is harmless; unlike a
|
||||
# stale DLL/AddOn it cannot change the running client.
|
||||
pass
|
||||
|
||||
# 2. Clean unselected Core Plugins and their dependent AddOns
|
||||
# 2. Clean unselected Core Plugins and their dependent AddOns.
|
||||
for dll_name, var in self.core_plugins.items():
|
||||
if not var.get():
|
||||
dll_path = os.path.join(target, dll_name)
|
||||
if os.path.exists(dll_path):
|
||||
try: os.remove(dll_path)
|
||||
except: pass
|
||||
|
||||
remove_managed_file(
|
||||
os.path.join(target, dll_name),
|
||||
f"managed plugin {dll_name}",
|
||||
)
|
||||
|
||||
addon_folder = self.addon_dependencies.get(dll_name)
|
||||
if addon_folder:
|
||||
addon_path = os.path.join(target, "Interface", "AddOns", addon_folder)
|
||||
if os.path.exists(addon_path):
|
||||
shutil.rmtree(addon_path, ignore_errors=True)
|
||||
remove_managed_tree(
|
||||
os.path.join(target, "Interface", "AddOns", addon_folder),
|
||||
f"managed addon {addon_folder}",
|
||||
)
|
||||
|
||||
# 3. Clean unselected Optional plugins
|
||||
# 3. Clean unselected Optional plugins.
|
||||
for dll_name, var in self.optional_plugins.items():
|
||||
if not var.get():
|
||||
dll_path = os.path.join(target, dll_name)
|
||||
if os.path.exists(dll_path):
|
||||
try: os.remove(dll_path)
|
||||
except: pass
|
||||
remove_managed_file(
|
||||
os.path.join(target, dll_name),
|
||||
f"managed plugin {dll_name}",
|
||||
)
|
||||
|
||||
|
||||
def configure_script_memory(self, target):
|
||||
"""Optionally set AddOn Script Memory to 0 (unlimited) in WTF/Config.wtf."""
|
||||
@@ -1996,23 +2023,87 @@ oLink.Save
|
||||
if os.path.exists(source_file): shutil.copy2(source_file, target)
|
||||
|
||||
|
||||
def _previous_settings_used_dxvk(self, target):
|
||||
"""Use the previous saved selection as legacy ownership proof."""
|
||||
settings_path = self._settings_path(target)
|
||||
if not os.path.isfile(settings_path):
|
||||
return False
|
||||
try:
|
||||
with open(settings_path, "r", encoding="utf-8") as handle:
|
||||
saved = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError, ValueError, TypeError):
|
||||
return False
|
||||
return (
|
||||
isinstance(saved, dict)
|
||||
and saved.get("rendering_mode") == "dxvk"
|
||||
)
|
||||
|
||||
def configure_dxvk(self, target):
|
||||
"""Manage DXVK transactionally and never delete unowned renderer files."""
|
||||
payload_dir = os.path.join(get_base_path(), "Payload")
|
||||
d3d9_src = os.path.join(payload_dir, "DXVK_Standard", "d3d9.dll")
|
||||
conf_src = os.path.join(payload_dir, "dxvk.conf")
|
||||
d3d9_target = os.path.join(target, "d3d9.dll")
|
||||
conf_target = os.path.join(target, "dxvk.conf")
|
||||
managed_id = "renderer_dxvk"
|
||||
|
||||
managed_files = remote_packages._load_managed_manifest(target, managed_id)
|
||||
|
||||
if self.rendering_mode.get() == "dxvk":
|
||||
d3d9_src = os.path.join(payload_dir, "DXVK_Standard", "d3d9.dll")
|
||||
conf_src = os.path.join(payload_dir, "dxvk.conf")
|
||||
missing = [
|
||||
path for path in (d3d9_src, conf_src)
|
||||
if not os.path.isfile(path)
|
||||
]
|
||||
if missing:
|
||||
raise FileNotFoundError(
|
||||
"Bundled DXVK files are incomplete: "
|
||||
+ ", ".join(os.path.basename(path) for path in missing)
|
||||
)
|
||||
|
||||
# Migrate installations created before renderer ownership manifests
|
||||
# existed. A saved DXVK selection is explicit evidence that these
|
||||
# two paths were managed by this tool on the previous Apply.
|
||||
if not managed_files and self._previous_settings_used_dxvk(target):
|
||||
for path in (conf_target, d3d9_target):
|
||||
if os.path.lexists(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError as exc:
|
||||
raise RuntimeError(
|
||||
f"Could not migrate the previous managed DXVK file {os.path.basename(path)}. "
|
||||
"Close WoW and any program using it, then try again."
|
||||
) from exc
|
||||
|
||||
remote_packages._install_managed_files_transactional(
|
||||
target,
|
||||
managed_id,
|
||||
[
|
||||
(d3d9_src, "d3d9.dll"),
|
||||
(conf_src, "dxvk.conf"),
|
||||
],
|
||||
revision="1",
|
||||
)
|
||||
return
|
||||
|
||||
# New managed installs restore any files that existed before DXVK was
|
||||
# enabled. This means a user's own d3d9.dll/dxvk.conf is never lost.
|
||||
if managed_files:
|
||||
remote_packages.remove_managed_mod(target, managed_id)
|
||||
return
|
||||
|
||||
# Legacy cleanup is allowed only with explicit previous settings proof.
|
||||
# Unknown/manual renderer files are deliberately preserved.
|
||||
if self._previous_settings_used_dxvk(target):
|
||||
for path in (conf_target, d3d9_target):
|
||||
if os.path.lexists(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError as exc:
|
||||
raise RuntimeError(
|
||||
f"Could not remove the previous managed DXVK file {os.path.basename(path)}. "
|
||||
"Close WoW and any program using it, then try again."
|
||||
) from exc
|
||||
|
||||
if os.path.exists(d3d9_src):
|
||||
shutil.copy2(d3d9_src, d3d9_target)
|
||||
if os.path.exists(conf_src):
|
||||
shutil.copy2(conf_src, conf_target)
|
||||
else:
|
||||
for path in (d3d9_target, conf_target):
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
|
||||
def _managed_dll_entries(self):
|
||||
"""Return every dlls.txt entry owned by this tool."""
|
||||
|
||||
@@ -155,6 +155,83 @@ class WdbBlockerTests(unittest.TestCase):
|
||||
self.assertEqual(handle.read(), b"foreign")
|
||||
|
||||
|
||||
class DxvkOwnershipTests(unittest.TestCase):
|
||||
def make_tool(self, mode):
|
||||
tool = WowSetupTool.__new__(WowSetupTool)
|
||||
tool.rendering_mode = FakeVar(mode)
|
||||
return tool
|
||||
|
||||
def make_payload(self, root):
|
||||
payload = os.path.join(root, "Payload")
|
||||
dxvk = os.path.join(payload, "DXVK_Standard")
|
||||
os.makedirs(dxvk)
|
||||
with open(os.path.join(dxvk, "d3d9.dll"), "wb") as handle:
|
||||
handle.write(b"bundled-dxvk")
|
||||
with open(os.path.join(payload, "dxvk.conf"), "wb") as handle:
|
||||
handle.write(b"bundled-conf")
|
||||
|
||||
def test_enable_then_disable_restores_preexisting_renderer_files(self):
|
||||
tool = self.make_tool("dxvk")
|
||||
with tempfile.TemporaryDirectory() as bundle, tempfile.TemporaryDirectory() as target:
|
||||
self.make_payload(bundle)
|
||||
d3d9 = os.path.join(target, "d3d9.dll")
|
||||
conf = os.path.join(target, "dxvk.conf")
|
||||
with open(d3d9, "wb") as handle:
|
||||
handle.write(b"user-d3d9")
|
||||
with open(conf, "wb") as handle:
|
||||
handle.write(b"user-conf")
|
||||
|
||||
with mock.patch("setup_tool.get_base_path", return_value=bundle):
|
||||
tool.configure_dxvk(target)
|
||||
with open(d3d9, "rb") as handle:
|
||||
self.assertEqual(handle.read(), b"bundled-dxvk")
|
||||
with open(conf, "rb") as handle:
|
||||
self.assertEqual(handle.read(), b"bundled-conf")
|
||||
|
||||
tool.rendering_mode.set("directx9")
|
||||
tool.configure_dxvk(target)
|
||||
|
||||
with open(d3d9, "rb") as handle:
|
||||
self.assertEqual(handle.read(), b"user-d3d9")
|
||||
with open(conf, "rb") as handle:
|
||||
self.assertEqual(handle.read(), b"user-conf")
|
||||
|
||||
def test_directx9_preserves_unowned_renderer_files(self):
|
||||
tool = self.make_tool("directx9")
|
||||
with tempfile.TemporaryDirectory() as target:
|
||||
d3d9 = os.path.join(target, "d3d9.dll")
|
||||
conf = os.path.join(target, "dxvk.conf")
|
||||
with open(d3d9, "wb") as handle:
|
||||
handle.write(b"manual-d3d9")
|
||||
with open(conf, "wb") as handle:
|
||||
handle.write(b"manual-conf")
|
||||
|
||||
tool.configure_dxvk(target)
|
||||
|
||||
with open(d3d9, "rb") as handle:
|
||||
self.assertEqual(handle.read(), b"manual-d3d9")
|
||||
with open(conf, "rb") as handle:
|
||||
self.assertEqual(handle.read(), b"manual-conf")
|
||||
|
||||
|
||||
class StrictCleanupTests(unittest.TestCase):
|
||||
def test_locked_managed_plugin_removal_is_not_silently_ignored(self):
|
||||
tool = WowSetupTool.__new__(WowSetupTool)
|
||||
tool.install_autologin = FakeVar(True)
|
||||
tool.core_plugins = {"CorePlugin.dll": FakeVar(False)}
|
||||
tool.optional_plugins = {}
|
||||
tool.addon_dependencies = {}
|
||||
|
||||
with tempfile.TemporaryDirectory() as target:
|
||||
dll_path = os.path.join(target, "CorePlugin.dll")
|
||||
with open(dll_path, "wb") as handle:
|
||||
handle.write(b"dll")
|
||||
|
||||
with mock.patch("setup_tool.os.remove", side_effect=PermissionError("locked")):
|
||||
with self.assertRaises(RuntimeError):
|
||||
tool.clean_unselected_files(target)
|
||||
|
||||
|
||||
class ManagedPackageTests(unittest.TestCase):
|
||||
def test_mpq_revision_and_magic_are_both_checked(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
|
||||
Reference in New Issue
Block a user