From b594df60968eb6cfa95a23a6097efb58e46c3011 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Wed, 25 Jun 2025 19:12:36 +0100 Subject: [PATCH 1/8] Update Windows platform_target_version --- src/briefcase/platforms/windows/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/briefcase/platforms/windows/__init__.py b/src/briefcase/platforms/windows/__init__.py index c0d821f71..b34186a80 100644 --- a/src/briefcase/platforms/windows/__init__.py +++ b/src/briefcase/platforms/windows/__init__.py @@ -17,6 +17,7 @@ class WindowsMixin: platform = "windows" supported_host_os = {"Windows"} supported_host_os_reason = "Windows applications can only be built on Windows." + platform_target_version = "0.3.24" def bundle_package_executable_path(self, app): if app.console_app: From cee9ff1ec11af3af1e20e89f587fb99761a927fb Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Fri, 27 Jun 2025 14:15:00 +0100 Subject: [PATCH 2/8] Update integrations/wix --- changes/1185.removal.rst | 1 + src/briefcase/integrations/wix.py | 164 ++++++++---------- tests/integrations/wix/conftest.py | 14 +- tests/integrations/wix/test_WiX__upgrade.py | 140 +--------------- tests/integrations/wix/test_WiX__verify.py | 177 +++++++++----------- 5 files changed, 168 insertions(+), 328 deletions(-) create mode 100644 changes/1185.removal.rst diff --git a/changes/1185.removal.rst b/changes/1185.removal.rst new file mode 100644 index 000000000..920415fb6 --- /dev/null +++ b/changes/1185.removal.rst @@ -0,0 +1 @@ +Briefcase now uses WiX version 5.0.2. Any Windows apps created with previous versions of Briefcase will need to be re-generated by running ``briefcase create``. diff --git a/src/briefcase/integrations/wix.py b/src/briefcase/integrations/wix.py index 91a5a287a..4a2712319 100644 --- a/src/briefcase/integrations/wix.py +++ b/src/briefcase/integrations/wix.py @@ -1,8 +1,10 @@ from __future__ import annotations -import os -import shutil +import re from pathlib import Path +from subprocess import CalledProcessError + +from packaging.version import Version from briefcase.exceptions import BriefcaseCommandError, MissingToolError from briefcase.integrations.base import ManagedTool, ToolCache @@ -13,62 +15,42 @@ class WiX(ManagedTool): full_name = "WiX" supported_host_os = {"Windows"} - def __init__( - self, - tools: ToolCache, - wix_home: Path | None = None, - bin_install: bool = False, - ): - """Create a wrapper around a WiX install. - - :param tools: ToolCache of available tools. - :param wix_home: The path of the WiX installation. - :param bin_install: Is the install a binaries-only install? A full MSI install - of WiX has a `/bin` folder in the paths; a binaries-only install does not. - :returns: A valid WiX SDK wrapper. If WiX is not available, and was not - installed, raises MissingToolError. - """ + # WARNING: version 6 and later have licensing issues: see + # https://github.com/beeware/briefcase/issues/1185. + version = Version("5.0.2") + + def __init__(self, tools: ToolCache): super().__init__(tools=tools) - if wix_home: - self.wix_home = wix_home - else: - self.wix_home = tools.base_path / "wix" - self.bin_install = bin_install + self.wix_home = tools.base_path / "wix" @property def download_url(self) -> str: - return "https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314-binaries.zip" - - @property - def heat_exe(self) -> Path: - if self.bin_install: - return self.wix_home / "heat.exe" - else: - return self.wix_home / "bin/heat.exe" + return ( + f"https://github.com/wixtoolset/wix/releases/download/v{self.version}/" + f"wix-cli-x64.msi" + ) @property - def light_exe(self) -> Path: - if self.bin_install: - return self.wix_home / "light.exe" - else: - return self.wix_home / "bin/light.exe" + def wix_exe(self) -> Path: + return ( + self.wix_home + / f"PFiles64/WiX Toolset v{self.version.major}.{self.version.minor}/bin" + / "wix.exe" + ) - @property - def candle_exe(self) -> Path: - if self.bin_install: - return self.wix_home / "candle.exe" - else: - return self.wix_home / "bin/candle.exe" + def ext_path(self, name: str) -> Path: + return ( + self.wix_home + / f"CFiles64/WixToolset/extensions/WixToolset.{name}.wixext/{self.version}" + / f"wixext{self.version.major}/WixToolset.{name}.wixext.dll" + ) @classmethod def verify_install(cls, tools: ToolCache, install: bool = True, **kwargs) -> WiX: """Verify that there is a WiX install available. - If the WIX environment variable is set, that location will be checked for a - valid WiX installation. - - If the location provided doesn't contain an SDK, or no location is provided, an - SDK is downloaded. + WiX is a small tool, and there's a close relationship between the WiX version + and the template syntax, so we always use a Briefcase-managed copy. :param tools: ToolCache of available tools :param install: Should WiX be installed if it is not found? @@ -77,36 +59,38 @@ def verify_install(cls, tools: ToolCache, install: bool = True, **kwargs) -> WiX if hasattr(tools, "wix"): return tools.wix - # Look for the WIX environment variable - if wix_env := tools.os.environ.get("WIX"): - tools.console.debug("Evaluating WIX...", prefix=cls.full_name) - tools.console.debug(f"WIX={wix_env}") - wix_home = Path(wix_env) - - # Set up the paths for the WiX executables we will use. - wix = WiX(tools=tools, wix_home=wix_home) - - if not wix.exists(): - raise BriefcaseCommandError( - f"""\ -The WIX environment variable: - -{wix_home} - -does not point to an install of the WiX Toolset. -""" + wix = WiX(tools) + if not wix.exists(): + if install: + tools.console.info( + "The WiX toolset was not found; downloading and installing...", + prefix=cls.name, ) - + wix.install() + else: + raise MissingToolError("WiX") else: - wix = WiX(tools=tools, bin_install=True) + try: + # The string returned by --version may include "+" followed by a + # commit ID; ignore this. + installed_version = re.sub( + r"\+.*", + "", + tools.subprocess.check_output([wix.wix_exe, "--version"]), + ).strip() + except (OSError, CalledProcessError) as e: + installed_version = None + tools.console.error( + f"The WiX toolset is unusable ({type(e).__name__}: {e})" + ) - if not wix.exists(): - if install: + if installed_version != str(wix.version): + if installed_version is not None: tools.console.info( - "The WiX toolset was not found; downloading and installing...", - prefix=cls.name, + f"The WiX toolset is an unsupported version ({installed_version})" ) - wix.install() + if install: + wix.upgrade() else: raise MissingToolError("WiX") @@ -115,26 +99,15 @@ def verify_install(cls, tools: ToolCache, install: bool = True, **kwargs) -> WiX return wix def exists(self) -> bool: - return ( - self.heat_exe.is_file() - and self.light_exe.is_file() - and self.candle_exe.is_file() - ) + return self.wix_exe.is_file() @property def managed_install(self) -> bool: - try: - # Determine if wix_home is relative to the briefcase data directory. - # If wix_home isn't inside this directory, this will raise a ValueError, - # indicating it is a non-managed install. - self.wix_home.relative_to(self.tools.base_path) - return True - except ValueError: - return False + return True def install(self): """Download and install WiX.""" - wix_zip_path = self.tools.file.download( + wix_msi_path = self.tools.file.download( url=self.download_url, download_path=self.tools.base_path, role="WiX", @@ -142,22 +115,27 @@ def install(self): try: with self.tools.console.wait_bar("Installing WiX..."): - self.tools.file.unpack_archive( - os.fsdecode(wix_zip_path), - extract_dir=os.fsdecode(self.wix_home), + self.tools.subprocess.run( + [ + "msiexec", + "/a", # Unpack the MSI into individual files + wix_msi_path, + "/qn", # Disable GUI interaction + f"TARGETDIR={self.wix_home}", + ], + check=True, ) - except (shutil.ReadError, EOFError) as e: + except CalledProcessError as e: raise BriefcaseCommandError( f"""\ -Unable to unpack WiX ZIP file. The download may have been +Unable to unpack WiX MSI file. The download may have been interrupted or corrupted. -Delete {wix_zip_path} and run briefcase again. +Delete {wix_msi_path} and run briefcase again. """ ) from e - # Zip file no longer needed once unpacked. - wix_zip_path.unlink() + wix_msi_path.unlink() def uninstall(self): """Uninstall WiX.""" diff --git a/tests/integrations/wix/conftest.py b/tests/integrations/wix/conftest.py index f1feb2b3b..a3f2de86e 100644 --- a/tests/integrations/wix/conftest.py +++ b/tests/integrations/wix/conftest.py @@ -7,7 +7,14 @@ from briefcase.integrations.file import File from briefcase.integrations.subprocess import Subprocess -WIX_DOWNLOAD_URL = "https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314-binaries.zip" +WIX_DOWNLOAD_URL = ( + "https://github.com/wixtoolset/wix/releases/download/v5.0.2/wix-cli-x64.msi" +) +WIX_EXE_PATH = "PFiles64/WiX Toolset v5.0/bin/wix.exe" +WIX_UI_PATH = ( + "CFiles64/WixToolset/extensions/WixToolset.UI.wixext/5.0.2/wixext5/" + "WixToolset.UI.wixext.dll" +) @pytest.fixture @@ -20,3 +27,8 @@ def mock_tools(tmp_path, mock_tools) -> ToolCache: mock_tools.file.download = MagicMock(spec_set=File.download) return mock_tools + + +@pytest.fixture +def wix_path(tmp_path): + return tmp_path / "tools/wix" diff --git a/tests/integrations/wix/test_WiX__upgrade.py b/tests/integrations/wix/test_WiX__upgrade.py index 51d7b9299..5281cf307 100644 --- a/tests/integrations/wix/test_WiX__upgrade.py +++ b/tests/integrations/wix/test_WiX__upgrade.py @@ -1,38 +1,13 @@ -import os -from unittest.mock import MagicMock - import pytest -from briefcase.exceptions import ( - BriefcaseCommandError, - MissingToolError, - NetworkFailure, - NonManagedToolError, -) +from briefcase.exceptions import MissingToolError from briefcase.integrations.wix import WiX -from ...utils import create_zip_file -from .conftest import WIX_DOWNLOAD_URL - - -def test_non_managed_install(mock_tools, tmp_path, capsys): - """If the WiX install points to a non-managed install, no upgrade is attempted.""" - - # Make the installation point to somewhere else. - wix = WiX(mock_tools, wix_home=tmp_path / "other-WiX") - - # Attempt an upgrade. This will fail because the install is non-managed - with pytest.raises(NonManagedToolError): - wix.upgrade() - - # No download was attempted - assert mock_tools.file.download.call_count == 0 - def test_non_existing_wix_install(mock_tools, tmp_path): """If there's no existing managed WiX install, upgrading is an error.""" # Create an SDK wrapper around a non-existing managed install - wix = WiX(mock_tools, wix_home=tmp_path / "tools/wix") + wix = WiX(mock_tools) with pytest.raises(MissingToolError): wix.upgrade() @@ -41,113 +16,12 @@ def test_non_existing_wix_install(mock_tools, tmp_path): assert mock_tools.file.download.call_count == 0 -def test_existing_wix_install(mock_tools, tmp_path): - """If there's an existing managed WiX install, it is deleted and redownloaded.""" - # Create a mock of a previously installed WiX version. - wix_path = tmp_path / "tools/wix" - wix_path.mkdir(parents=True) - (wix_path / "heat.exe").touch() - (wix_path / "light.exe").touch() - (wix_path / "candle.exe").touch() - - # Mock the download - wix_path = tmp_path / "tools/wix" - - wix_zip_path = create_zip_file(tmp_path / "tools/wix.zip", content=[("wix", "wix")]) - - mock_tools.file.download = MagicMock(return_value=wix_zip_path) - - # Create an SDK wrapper - wix = WiX(mock_tools, wix_home=wix_path, bin_install=True) - - # Attempt an upgrade. - wix.upgrade() - - # The old version has been deleted - mock_tools.shutil.rmtree.assert_called_with(wix_path) - - # A download was initiated - mock_tools.file.download.assert_called_with( - url=WIX_DOWNLOAD_URL, - download_path=tmp_path / "tools", - role="WiX", - ) - - # The download was unpacked - mock_tools.shutil.unpack_archive.assert_called_with( - filename=os.fsdecode(wix_zip_path), extract_dir=os.fsdecode(wix_path) - ) - - # The zip file was removed - assert not wix_zip_path.exists() - - -def test_download_fail(mock_tools, tmp_path): - """If the download doesn't complete, the upgrade fails.""" - # Create a mock of a previously installed WiX version. - wix_path = tmp_path / "tools/wix" - wix_path.mkdir(parents=True) - (wix_path / "heat.exe").touch() - (wix_path / "light.exe").touch() - (wix_path / "candle.exe").touch() - - # Mock the download failure - mock_tools.file.download = MagicMock(side_effect=NetworkFailure("mock")) - - # Create an SDK wrapper - wix = WiX(mock_tools, wix_home=wix_path, bin_install=True) - - # Upgrade the install. This will trigger a download that will fail - with pytest.raises(NetworkFailure, match="Unable to mock"): - wix.upgrade() - - # A download was initiated - mock_tools.file.download.assert_called_with( - url=WIX_DOWNLOAD_URL, - download_path=tmp_path / "tools", - role="WiX", - ) - - # ... but the unpack didn't happen - assert mock_tools.shutil.unpack_archive.call_count == 0 - - -def test_unpack_fail(mock_tools, tmp_path): - """If the download archive is corrupted, the validator fails.""" +def test_wix_uninstall(mock_tools, tmp_path): + """The uninstall method removes a managed install.""" # Create a mock of a previously installed WiX version. wix_path = tmp_path / "tools/wix" wix_path.mkdir(parents=True) - (wix_path / "heat.exe").touch() - (wix_path / "light.exe").touch() - (wix_path / "candle.exe").touch() - - # Mock the download - wix_zip_path = create_zip_file(tmp_path / "tools/wix.zip", content=[("wix", "wix")]) - - mock_tools.file.download = MagicMock(return_value=wix_zip_path) - - # Mock an unpack failure - mock_tools.shutil.unpack_archive.side_effect = EOFError - - # Create an SDK wrapper - wix = WiX(mock_tools, wix_home=wix_path, bin_install=True) - - # Upgrade the install. This will trigger a download, - # but the unpack will fail. - with pytest.raises(BriefcaseCommandError): - wix.upgrade() - - # A download was initiated - mock_tools.file.download.assert_called_with( - url=WIX_DOWNLOAD_URL, - download_path=tmp_path / "tools", - role="WiX", - ) - - # The download was unpacked. - mock_tools.shutil.unpack_archive.assert_called_with( - filename=os.fsdecode(wix_zip_path), extract_dir=os.fsdecode(wix_path) - ) - # The zip file was not removed - assert wix_zip_path.exists() + wix = WiX(mock_tools) + wix.uninstall() + mock_tools.shutil.rmtree.assert_called_once_with(wix_path) diff --git a/tests/integrations/wix/test_WiX__verify.py b/tests/integrations/wix/test_WiX__verify.py index 231461ab2..2730b2a8b 100644 --- a/tests/integrations/wix/test_WiX__verify.py +++ b/tests/integrations/wix/test_WiX__verify.py @@ -1,4 +1,4 @@ -import os +from subprocess import CalledProcessError from unittest.mock import MagicMock import pytest @@ -11,8 +11,8 @@ ) from briefcase.integrations.wix import WiX -from ...utils import assert_url_resolvable, create_zip_file -from .conftest import WIX_DOWNLOAD_URL +from ...utils import assert_url_resolvable +from .conftest import WIX_DOWNLOAD_URL, WIX_EXE_PATH def test_short_circuit(mock_tools): @@ -37,131 +37,117 @@ def test_unsupported_os(mock_tools, host_os): WiX.verify(mock_tools) -def test_valid_wix_envvar(mock_tools, tmp_path): - """If the WiX envvar points to a valid WiX install, the validator succeeds.""" - # Mock the environment for a WiX install - wix_path = tmp_path / "wix" - mock_tools.os.environ.get.return_value = os.fsdecode(wix_path) +def test_existing_wix_install(mock_tools, wix_path): + """If there's an existing managed WiX install, the validator succeeds.""" + # Create a mock of a previously installed WiX version. + wix_exe = wix_path / WIX_EXE_PATH + wix_exe.parent.mkdir(parents=True) + wix_exe.touch() - # Mock the interesting parts of a WiX install - (wix_path / "bin").mkdir(parents=True) - (wix_path / "bin/heat.exe").touch() - (wix_path / "bin/light.exe").touch() - (wix_path / "bin/candle.exe").touch() + mock_tools.subprocess.check_output.return_value = "5.0.2+aa65968c" - # Verify the install wix = WiX.verify(mock_tools) - # The environment was queried. - mock_tools.os.environ.get.assert_called_with("WIX") + # Version was checked + mock_tools.subprocess.check_output.assert_called_once_with([wix_exe, "--version"]) - # The returned paths are as expected (and are the full paths) - assert wix.heat_exe == tmp_path / "wix/bin/heat.exe" - assert wix.light_exe == tmp_path / "wix/bin/light.exe" - assert wix.candle_exe == tmp_path / "wix/bin/candle.exe" + # No download was attempted + assert mock_tools.file.download.call_count == 0 + # The returned paths are as expected + assert wix.wix_exe == wix_exe -def test_invalid_wix_envvar(mock_tools, tmp_path): - """If the WiX envvar points to an invalid WiX install, the validator fails.""" - # Mock the environment for a WiX install - wix_path = tmp_path / "wix" - mock_tools.os.environ.get.return_value = os.fsdecode(wix_path) - # Don't create the actual install, and then attempt to validate - with pytest.raises(BriefcaseCommandError, match="does not point to an install"): - WiX.verify(mock_tools) +def test_download_missing(capsys, mock_tools, wix_path): + """If there's no existing managed WiX install, it is downloaded and unpacked.""" + assert_download(mock_tools, wix_path) + assert "WiX toolset was not found" in capsys.readouterr().out + # Version was not checked + mock_tools.subprocess.check_output.assert_not_called() -def test_existing_wix_install(mock_tools, tmp_path): - """If there's an existing managed WiX install, the validator succeeds.""" - # Mock the environment as if there is not WiX variable - mock_tools.os.environ.get.return_value = None + # The WiX URL is resolvable + assert_url_resolvable(WiX.verify(mock_tools).download_url) - # Create a mock of a previously installed WiX version. - wix_path = tmp_path / "tools/wix" - wix_path.mkdir(parents=True) - (wix_path / "heat.exe").touch() - (wix_path / "light.exe").touch() - (wix_path / "candle.exe").touch() - wix = WiX.verify(mock_tools) +@pytest.mark.parametrize( + "exc", + [PermissionError(), CalledProcessError(1, "wix")], +) +def test_download_unusable(capsys, mock_tools, wix_path, exc): + """If the existing managed WiX install is unusable, it is reinstalled.""" + wix_exe = wix_path / WIX_EXE_PATH + wix_exe.parent.mkdir(parents=True) + wix_exe.touch() + + mock_tools.subprocess.check_output.side_effect = exc + + assert_download(mock_tools, wix_path) + assert ( + f"WiX toolset is unusable ({type(exc).__name__}: {exc})" + in capsys.readouterr().out + ) - # The environment was queried. - mock_tools.os.environ.get.assert_called_with("WIX") + # Version was checked + mock_tools.subprocess.check_output.assert_called_once_with([wix_exe, "--version"]) - # No download was attempted - assert mock_tools.file.download.call_count == 0 - # The returned paths are as expected - assert wix.heat_exe == tmp_path / "tools/wix/heat.exe" - assert wix.light_exe == tmp_path / "tools/wix/light.exe" - assert wix.candle_exe == tmp_path / "tools/wix/candle.exe" +def test_download_version(capsys, mock_tools, wix_path): + """If the existing managed WiX install is the wrong version, it is reinstalled.""" + wix_exe = wix_path / WIX_EXE_PATH + wix_exe.parent.mkdir(parents=True) + wix_exe.touch() + mock_tools.subprocess.check_output.return_value = "5.0.1" -def test_download_wix(mock_tools, tmp_path): - """If there's no existing managed WiX install, it is downloaded and unpacked.""" - # Mock the environment as if there is not WiX variable - mock_tools.os.environ.get.return_value = None + assert_download(mock_tools, wix_path) + assert "WiX toolset is an unsupported version (5.0.1)" in capsys.readouterr().out - # Mock the download - wix_path = tmp_path / "tools/wix" + # Version was checked + mock_tools.subprocess.check_output.assert_called_once_with([wix_exe, "--version"]) - wix_zip_path = create_zip_file(tmp_path / "tools/wix.zip", content=[("wix", "wix")]) - mock_tools.file.download = MagicMock(return_value=wix_zip_path) +def assert_download(mock_tools, wix_path): + # Mock the download + wix_msi_path = wix_path.parent / "wix.msi" + wix_msi_path.touch() + mock_tools.file.download = MagicMock(return_value=wix_msi_path) # Verify the install. This will trigger a download wix = WiX.verify(mock_tools) - # The environment was queried. - mock_tools.os.environ.get.assert_called_with("WIX") - # A download was initiated mock_tools.file.download.assert_called_with( url=WIX_DOWNLOAD_URL, - download_path=tmp_path / "tools", + download_path=wix_path.parent, role="WiX", ) # The download was unpacked. - mock_tools.shutil.unpack_archive.assert_called_with( - filename=os.fsdecode(wix_zip_path), extract_dir=os.fsdecode(wix_path) + mock_tools.subprocess.run.assert_called_with( + ["msiexec", "/a", wix_msi_path, "/qn", f"TARGETDIR={wix_path}"], check=True ) - # The zip file was removed - assert not wix_zip_path.exists() + # The msi file was removed + assert not wix_msi_path.exists() # The returned paths are as expected - assert wix.heat_exe == tmp_path / "tools/wix/heat.exe" - assert wix.light_exe == tmp_path / "tools/wix/light.exe" - assert wix.candle_exe == tmp_path / "tools/wix/candle.exe" - - # The WiX URL is resolvable - assert_url_resolvable(wix.download_url) + assert wix.wix_exe == wix_path / WIX_EXE_PATH def test_dont_install(mock_tools, tmp_path): - """If there's no existing managed WiX install, an install is not requested, verify + """If there's no existing managed WiX install, and install is not requested, verify fails.""" - # Mock the environment as if there is not WiX variable - mock_tools.os.environ.get.return_value = None - # Verify, but don't install. This will fail. with pytest.raises(MissingToolError): WiX.verify(mock_tools, install=False) - # The environment was queried. - mock_tools.os.environ.get.assert_called_with("WIX") - # No download was initiated mock_tools.file.download.assert_not_called() def test_download_fail(mock_tools, tmp_path): """If the download doesn't complete, the validator fails.""" - # Mock the environment as if there is not WiX variable - mock_tools.os.environ.get.return_value = None - # Mock the download failure mock_tools.file.download = MagicMock(side_effect=NetworkFailure("mock")) @@ -169,9 +155,6 @@ def test_download_fail(mock_tools, tmp_path): with pytest.raises(NetworkFailure, match="Unable to mock"): WiX.verify(mock_tools) - # The environment was queried. - mock_tools.os.environ.get.assert_called_with("WIX") - # A download was initiated mock_tools.file.download.assert_called_with( url=WIX_DOWNLOAD_URL, @@ -180,43 +163,35 @@ def test_download_fail(mock_tools, tmp_path): ) # ... but the unpack didn't happen - assert mock_tools.shutil.unpack_archive.call_count == 0 + mock_tools.subprocess.run.assert_not_called() -def test_unpack_fail(mock_tools, tmp_path): +def test_unpack_fail(capsys, mock_tools, wix_path): """If the download archive is corrupted, the validator fails.""" - # Mock the environment as if there is not WiX variable - mock_tools.os.environ.get.return_value = None - # Mock the download - wix_path = tmp_path / "tools/wix" + wix_msi_path = wix_path.parent / "wix.msi" + wix_msi_path.touch() + mock_tools.file.download = MagicMock(return_value=wix_msi_path) - wix_zip_path = create_zip_file(tmp_path / "tools/wix.zip", content=[("wix", "wix")]) - - mock_tools.file.download = MagicMock(return_value=wix_zip_path) - - # Mock an unpack failure - mock_tools.shutil.unpack_archive.side_effect = EOFError + # Mock an msiexec failure + mock_tools.subprocess.run.side_effect = CalledProcessError(1, "msiexec") # Verify the install. This will trigger a download, # but the unpack will fail with pytest.raises(BriefcaseCommandError, match="interrupted or corrupted"): WiX.verify(mock_tools) - # The environment was queried. - mock_tools.os.environ.get.assert_called_with("WIX") - # A download was initiated mock_tools.file.download.assert_called_with( url=WIX_DOWNLOAD_URL, - download_path=tmp_path / "tools", + download_path=wix_path.parent, role="WiX", ) # The download was unpacked. - mock_tools.shutil.unpack_archive.assert_called_with( - filename=os.fsdecode(wix_zip_path), extract_dir=os.fsdecode(wix_path) + mock_tools.subprocess.run.assert_called_with( + ["msiexec", "/a", wix_msi_path, "/qn", f"TARGETDIR={wix_path}"], check=True ) # The zip file was not removed - assert wix_zip_path.exists() + assert wix_msi_path.exists() From d52650e8cb9ae720b6899a7b086b2697307a1072 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Fri, 27 Jun 2025 21:56:10 +0100 Subject: [PATCH 3/8] Update platforms/windows --- src/briefcase/config.py | 3 + src/briefcase/integrations/wix.py | 12 +- src/briefcase/platforms/windows/__init__.py | 71 +-- tests/platforms/windows/app/conftest.py | 15 - tests/platforms/windows/app/test_build.py | 5 +- tests/platforms/windows/app/test_create.py | 56 +- tests/platforms/windows/app/test_package.py | 485 ++---------------- tests/platforms/windows/conftest.py | 18 + .../windows/visualstudio/test_create.py | 24 + .../windows/visualstudio/test_package.py | 103 +--- 10 files changed, 152 insertions(+), 640 deletions(-) create mode 100644 tests/platforms/windows/visualstudio/test_create.py diff --git a/src/briefcase/config.py b/src/briefcase/config.py index 7659880be..6970e7917 100644 --- a/src/briefcase/config.py +++ b/src/briefcase/config.py @@ -279,6 +279,9 @@ def update(self, data): for key, configs in data.items(): setattr(self, key, configs) + def copy(self): + return type(self)(**self.__dict__) + def setdefault(self, field_name, default_value): """Return the field_name field or, if it does not exist, create it to hold default_value. diff --git a/src/briefcase/integrations/wix.py b/src/briefcase/integrations/wix.py index 4a2712319..6c17b1f66 100644 --- a/src/briefcase/integrations/wix.py +++ b/src/briefcase/integrations/wix.py @@ -19,9 +19,17 @@ class WiX(ManagedTool): # https://github.com/beeware/briefcase/issues/1185. version = Version("5.0.2") - def __init__(self, tools: ToolCache): + def __init__(self, tools: ToolCache, wix_home: Path | None = None): + """Create a wrapper around a WiX install. + + :param tools: ToolCache of available tools. + :param wix_home: The path of the WiX installation. + """ super().__init__(tools=tools) - self.wix_home = tools.base_path / "wix" + if wix_home: + self.wix_home = wix_home + else: + self.wix_home = tools.base_path / "wix" @property def download_url(self) -> str: diff --git a/src/briefcase/platforms/windows/__init__.py b/src/briefcase/platforms/windows/__init__.py index b34186a80..fdae087f7 100644 --- a/src/briefcase/platforms/windows/__init__.py +++ b/src/briefcase/platforms/windows/__init__.py @@ -104,6 +104,7 @@ def output_format_template_context(self, app: AppConfig): "version_triple": version_triple, "guid": str(guid), "install_scope": install_scope, + "package_path": str(self.package_path(app)), "binary_path": self.package_executable_path(app), } @@ -378,85 +379,25 @@ def package_app( def _package_msi(self, app): """Build the msi installer.""" - - self.console.info("Building MSI...", prefix=app.app_name) - try: - self.console.info("Compiling application manifest...") - with self.console.wait_bar("Compiling..."): - self.tools.subprocess.run( - [ - self.tools.wix.heat_exe, - "dir", - self.package_path(app), - "-nologo", # Don't display startup text - "-gg", # Generate GUIDs - "-sfrag", # Suppress fragment generation for directories - "-sreg", # Suppress registry harvesting - "-srd", # Suppress harvesting the root directory - "-scom", # Suppress harvesting COM components - "-dr", - f"{app.module_name}_ROOTDIR", # Root directory reference name - "-cg", - f"{app.module_name}_COMPONENTS", # Root component group name - "-var", - "var.SourceDir", # variable to use as the source dir - "-out", - f"{app.app_name}-manifest.wxs", - ], - check=True, - cwd=self.bundle_path(app), - ) - except subprocess.CalledProcessError as e: - raise BriefcaseCommandError( - f"Unable to generate manifest for app {app.app_name}." - ) from e - try: - self.console.info("Compiling application installer...") - with self.console.wait_bar("Compiling..."): + with self.console.wait_bar("Building MSI..."): self.tools.subprocess.run( [ - self.tools.wix.candle_exe, - "-nologo", # Don't display startup text - "-ext", - "WixUtilExtension", + self.tools.wix.wix_exe, + "build", "-ext", - "WixUIExtension", - "-arch", - "x64", - f"-dSourceDir={self.package_path(app)}", + self.tools.wix.ext_path("UI"), f"{app.app_name}.wxs", - f"{app.app_name}-manifest.wxs", - ], - check=True, - cwd=self.bundle_path(app), - ) - except subprocess.CalledProcessError as e: - raise BriefcaseCommandError(f"Unable to compile app {app.app_name}.") from e - - try: - self.console.info("Linking application installer...") - with self.console.wait_bar("Linking..."): - self.tools.subprocess.run( - [ - self.tools.wix.light_exe, - "-nologo", # Don't display startup text - "-ext", - "WixUtilExtension", - "-ext", - "WixUIExtension", "-loc", "unicode.wxl", "-o", self.distribution_path(app), - f"{app.app_name}.wixobj", - f"{app.app_name}-manifest.wixobj", ], check=True, cwd=self.bundle_path(app), ) except subprocess.CalledProcessError as e: - raise BriefcaseCommandError(f"Unable to link app {app.app_name}.") from e + raise BriefcaseCommandError(f"Unable to package app {app.app_name}.") from e def _package_zip(self, app): """Package the app as simple zip file.""" diff --git a/tests/platforms/windows/app/conftest.py b/tests/platforms/windows/app/conftest.py index 27f79c489..6cb6517e8 100644 --- a/tests/platforms/windows/app/conftest.py +++ b/tests/platforms/windows/app/conftest.py @@ -12,18 +12,3 @@ def first_app_templated(first_app_config, tmp_path): create_file(app_path / "Stub.exe", "Stub binary") return first_app_config - - -@pytest.fixture -def external_first_app(first_app_config, tmp_path): - first_app_config.sources = None - first_app_config.external_package_path = tmp_path / "base_path/external/src" - first_app_config.external_package_executable_path = "internal/app.exe" - - # Create the binary - create_file( - tmp_path / "base_path/external/src/internal/app.exe", - "external binary", - ) - - return first_app_config diff --git a/tests/platforms/windows/app/test_build.py b/tests/platforms/windows/app/test_build.py index 4cbfff99f..6d3970697 100644 --- a/tests/platforms/windows/app/test_build.py +++ b/tests/platforms/windows/app/test_build.py @@ -378,11 +378,14 @@ def test_build_app_with_support_package_update( "wb" ) as f: index = { + "briefcase": { + "target_version": "0.3.24", + }, "paths": { "app_path": "src/app", "app_package_path": "src/app_packages", "support_path": "src", - } + }, } tomli_w.dump(index, f) diff --git a/tests/platforms/windows/app/test_create.py b/tests/platforms/windows/app/test_create.py index 750e24aa5..ab50bdc8a 100644 --- a/tests/platforms/windows/app/test_create.py +++ b/tests/platforms/windows/app/test_create.py @@ -62,6 +62,17 @@ def test_unsupported_32bit_python(create_command): create_command() +def test_context(create_command, first_app_config): + context = create_command.output_format_template_context(first_app_config) + assert sorted(context.keys()) == [ + "binary_path", + "guid", + "install_scope", + "package_path", + "version_triple", + ] + + @pytest.mark.parametrize( "version, version_triple", [ @@ -125,38 +136,39 @@ def test_support_package_url(create_command, first_app_config, tmp_path): def test_default_install_scope(create_command, first_app_config, tmp_path): """By default, app should be installed per user.""" context = create_command.output_format_template_context(first_app_config) - - assert context == { - "binary_path": "First App.exe", - "guid": "d666a4f1-c7b7-52cc-888a-3a35a7cc97e5", - "version_triple": "0.0.1", - "install_scope": None, - } + assert context["install_scope"] is None def test_per_machine_install_scope(create_command, first_app_config, tmp_path): - """By default, app should be installed per user.""" + """App can be set to have explicit per-machine scope.""" first_app_config.system_installer = True - context = create_command.output_format_template_context(first_app_config) - - assert context == { - "binary_path": "First App.exe", - "guid": "d666a4f1-c7b7-52cc-888a-3a35a7cc97e5", - "version_triple": "0.0.1", - "install_scope": "perMachine", - } + assert context["install_scope"] == "perMachine" def test_per_user_install_scope(create_command, first_app_config, tmp_path): """App can be set to have explicit per-user scope.""" first_app_config.system_installer = False + context = create_command.output_format_template_context(first_app_config) + assert context["install_scope"] == "perUser" + +def test_package_path(create_command, first_app_config, tmp_path): + """The default package_path is passed as an absolute path.""" context = create_command.output_format_template_context(first_app_config) + assert context["package_path"] == str( + tmp_path / "base_path/build/first-app/windows/app/src" + ) + + +def test_binary_path(create_command, first_app_config, tmp_path): + """The default binary_path is passed as a path relative to package_path.""" + context = create_command.output_format_template_context(first_app_config) + assert context["binary_path"] == "First App.exe" + - assert context == { - "binary_path": "First App.exe", - "guid": "d666a4f1-c7b7-52cc-888a-3a35a7cc97e5", - "version_triple": "0.0.1", - "install_scope": "perUser", - } +def test_external(create_command, external_first_app, tmp_path): + """The package_path and binary_path can be overridden by the user.""" + context = create_command.output_format_template_context(external_first_app) + assert context["package_path"] == str(tmp_path / "base_path/external/src") + assert context["binary_path"] == "internal/app.exe" diff --git a/tests/platforms/windows/app/test_package.py b/tests/platforms/windows/app/test_package.py index e77639740..f9d943db3 100644 --- a/tests/platforms/windows/app/test_package.py +++ b/tests/platforms/windows/app/test_package.py @@ -12,6 +12,7 @@ from briefcase.integrations.wix import WiX from briefcase.platforms.windows.app import WindowsAppPackageCommand +from ....integrations.wix.conftest import WIX_EXE_PATH, WIX_UI_PATH from ....utils import create_file @@ -195,76 +196,40 @@ def test_parse_options(package_command, cli_args, signing_options, is_sdk_needed @pytest.mark.parametrize( - "kwargs", + "kwargs, external", [ - dict(), # Default behavior (adhoc signing) - {"adhoc_sign": True}, # Explicit adhoc signing + # Default behavior (adhoc signing, internal app) + ({}, False), + # External app mode makes no difference when packaging an MSI, because it's + # entirely implemented in `briefcase create`. + ({}, True), + # Explicit adhoc signing + ({"adhoc_sign": True}, False), ], ) -def test_package_msi(package_command, first_app_config, kwargs, tmp_path): +def test_package_msi( + package_command, first_app_config, external_first_app, tmp_path, kwargs, external +): """A Windows app can be packaged as an MSI.""" - package_command.package_app(first_app_config, **kwargs) + package_command.package_app( + external_first_app if external else first_app_config, + **kwargs, + ) - package_path = tmp_path / "base_path/build/first-app/windows/app/src" assert package_command.tools.subprocess.run.mock_calls == [ - # Collect manifest - mock.call( - [ - tmp_path / "wix/bin/heat.exe", - "dir", - package_path, - "-nologo", - "-gg", - "-sfrag", - "-sreg", - "-srd", - "-scom", - "-dr", - "first_app_ROOTDIR", - "-cg", - "first_app_COMPONENTS", - "-var", - "var.SourceDir", - "-out", - "first-app-manifest.wxs", - ], - check=True, - cwd=tmp_path / "base_path/build/first-app/windows/app", - ), # Compile MSI mock.call( [ - tmp_path / "wix/bin/candle.exe", - "-nologo", - "-ext", - "WixUtilExtension", + tmp_path / "wix" / WIX_EXE_PATH, + "build", "-ext", - "WixUIExtension", - "-arch", - "x64", - f"-dSourceDir={package_path}", + tmp_path / "wix" / WIX_UI_PATH, "first-app.wxs", - "first-app-manifest.wxs", - ], - check=True, - cwd=tmp_path / "base_path/build/first-app/windows/app", - ), - # Link MSI - mock.call( - [ - tmp_path / "wix/bin/light.exe", - "-nologo", - "-ext", - "WixUtilExtension", - "-ext", - "WixUIExtension", "-loc", "unicode.wxl", "-o", tmp_path / "base_path/dist/First App-0.0.1.msi", - "first-app.wixobj", - "first-app-manifest.wixobj", ], check=True, cwd=tmp_path / "base_path/build/first-app/windows/app", @@ -345,7 +310,6 @@ def test_package_msi_with_codesigning( timestamp_digest="sha56", ) - package_path = tmp_path / "base_path/build/first-app/windows/app/src" assert package_command.tools.subprocess.run.mock_calls == [ # Codesign app exe mock.call( @@ -385,63 +349,18 @@ def test_package_msi_with_codesigning( ], check=True, ), - # Collect manifest - mock.call( - [ - tmp_path / "wix/bin/heat.exe", - "dir", - package_path, - "-nologo", - "-gg", - "-sfrag", - "-sreg", - "-srd", - "-scom", - "-dr", - "first_app_ROOTDIR", - "-cg", - "first_app_COMPONENTS", - "-var", - "var.SourceDir", - "-out", - "first-app-manifest.wxs", - ], - check=True, - cwd=tmp_path / "base_path/build/first-app/windows/app", - ), # Compile MSI mock.call( [ - tmp_path / "wix/bin/candle.exe", - "-nologo", + tmp_path / "wix" / WIX_EXE_PATH, + "build", "-ext", - "WixUtilExtension", - "-ext", - "WixUIExtension", - "-arch", - "x64", - f"-dSourceDir={package_path}", + tmp_path / "wix" / WIX_UI_PATH, "first-app.wxs", - "first-app-manifest.wxs", - ], - check=True, - cwd=tmp_path / "base_path/build/first-app/windows/app", - ), - # Link MSI - mock.call( - [ - tmp_path / "wix/bin/light.exe", - "-nologo", - "-ext", - "WixUtilExtension", - "-ext", - "WixUIExtension", "-loc", "unicode.wxl", "-o", tmp_path / "base_path/dist/First App-0.0.1.msi", - "first-app.wixobj", - "first-app-manifest.wixobj", ], check=True, cwd=tmp_path / "base_path/build/first-app/windows/app", @@ -596,183 +515,32 @@ def test_package_msi_failed_sign_app(package_command, first_app_config, tmp_path ] -def test_package_msi_failed_manifest(package_command, first_app_config, tmp_path): - """An error is raised if a manifest cannot be built.""" - # Mock a failure in the call to heat.exe - package_command.tools.subprocess.run.side_effect = [ - CalledProcessError(cmd=["heat.exe"], returncode=1), - ] - - with pytest.raises( - BriefcaseCommandError, - match=r"Unable to generate manifest for app first-app.", - ): - package_command.package_app(first_app_config) - - package_path = tmp_path / "base_path/build/first-app/windows/app/src" - assert package_command.tools.subprocess.run.mock_calls == [ - # Collect manifest - mock.call( - [ - tmp_path / "wix/bin/heat.exe", - "dir", - package_path, - "-nologo", - "-gg", - "-sfrag", - "-sreg", - "-srd", - "-scom", - "-dr", - "first_app_ROOTDIR", - "-cg", - "first_app_COMPONENTS", - "-var", - "var.SourceDir", - "-out", - "first-app-manifest.wxs", - ], - check=True, - cwd=tmp_path / "base_path/build/first-app/windows/app", - ), - ] - - def test_package_msi_failed_compile(package_command, first_app_config, tmp_path): """An error is raised if compilation failed.""" - # Mock a failure in the call to candle.exe + # Mock a failure in the call to wix.exe package_command.tools.subprocess.run.side_effect = [ - None, # heat.exe - CalledProcessError(cmd=["candle.exe"], returncode=1), + CalledProcessError(cmd=["wix.exe"], returncode=1), ] with pytest.raises( BriefcaseCommandError, - match=r"Unable to compile app first-app.", + match=r"Unable to package app first-app.", ): package_command.package_app(first_app_config) - package_path = tmp_path / "base_path/build/first-app/windows/app/src" assert package_command.tools.subprocess.run.mock_calls == [ - # Collect manifest - mock.call( - [ - tmp_path / "wix/bin/heat.exe", - "dir", - package_path, - "-nologo", - "-gg", - "-sfrag", - "-sreg", - "-srd", - "-scom", - "-dr", - "first_app_ROOTDIR", - "-cg", - "first_app_COMPONENTS", - "-var", - "var.SourceDir", - "-out", - "first-app-manifest.wxs", - ], - check=True, - cwd=tmp_path / "base_path/build/first-app/windows/app", - ), - # Compile MSI - mock.call( - [ - tmp_path / "wix/bin/candle.exe", - "-nologo", - "-ext", - "WixUtilExtension", - "-ext", - "WixUIExtension", - "-arch", - "x64", - f"-dSourceDir={package_path}", - "first-app.wxs", - "first-app-manifest.wxs", - ], - check=True, - cwd=tmp_path / "base_path/build/first-app/windows/app", - ), - ] - - -def test_package_msi_failed_link(package_command, first_app_config, tmp_path): - """An error is raised if linking fails.""" - # Mock a failure in the call to light.exe - package_command.tools.subprocess.run.side_effect = [ - None, # heat.exe - None, # candle.exe - CalledProcessError(cmd=["link.exe"], returncode=1), - ] - - with pytest.raises( - BriefcaseCommandError, - match=r"Unable to link app first-app.", - ): - package_command.package_app(first_app_config) - - package_path = tmp_path / "base_path/build/first-app/windows/app/src" - assert package_command.tools.subprocess.run.mock_calls == [ - # Collect manifest - mock.call( - [ - tmp_path / "wix/bin/heat.exe", - "dir", - package_path, - "-nologo", - "-gg", - "-sfrag", - "-sreg", - "-srd", - "-scom", - "-dr", - "first_app_ROOTDIR", - "-cg", - "first_app_COMPONENTS", - "-var", - "var.SourceDir", - "-out", - "first-app-manifest.wxs", - ], - check=True, - cwd=tmp_path / "base_path/build/first-app/windows/app", - ), # Compile MSI mock.call( [ - tmp_path / "wix/bin/candle.exe", - "-nologo", + tmp_path / "wix" / WIX_EXE_PATH, + "build", "-ext", - "WixUtilExtension", - "-ext", - "WixUIExtension", - "-arch", - "x64", - f"-dSourceDir={package_path}", + tmp_path / "wix" / WIX_UI_PATH, "first-app.wxs", - "first-app-manifest.wxs", - ], - check=True, - cwd=tmp_path / "base_path/build/first-app/windows/app", - ), - # Link MSI - mock.call( - [ - tmp_path / "wix/bin/light.exe", - "-nologo", - "-ext", - "WixUtilExtension", - "-ext", - "WixUIExtension", "-loc", "unicode.wxl", "-o", tmp_path / "base_path/dist/First App-0.0.1.msi", - "first-app.wixobj", - "first-app-manifest.wixobj", ], check=True, cwd=tmp_path / "base_path/build/first-app/windows/app", @@ -782,12 +550,10 @@ def test_package_msi_failed_link(package_command, first_app_config, tmp_path): def test_package_msi_failed_signing_msi(package_command, first_app_config, tmp_path): """An error is raised if signtool fails for the app MSI.""" - # Mock a failure in the call to light.exe + # Mock a failure in the call to signtool.exe package_command.tools.subprocess.run.side_effect = [ None, # signtool.exe - None, # heat.exe - None, # candle.exe - None, # link.exe + None, # wix.exe CalledProcessError(cmd=["signtool.exe"], returncode=1), ] @@ -801,7 +567,6 @@ def test_package_msi_failed_signing_msi(package_command, first_app_config, tmp_p timestamp_digest="sha56", ) - package_path = tmp_path / "base_path/build/first-app/windows/app/src" assert package_command.tools.subprocess.run.mock_calls == [ # Codesign app exe mock.call( @@ -831,63 +596,18 @@ def test_package_msi_failed_signing_msi(package_command, first_app_config, tmp_p ], check=True, ), - # Collect manifest - mock.call( - [ - tmp_path / "wix/bin/heat.exe", - "dir", - package_path, - "-nologo", - "-gg", - "-sfrag", - "-sreg", - "-srd", - "-scom", - "-dr", - "first_app_ROOTDIR", - "-cg", - "first_app_COMPONENTS", - "-var", - "var.SourceDir", - "-out", - "first-app-manifest.wxs", - ], - check=True, - cwd=tmp_path / "base_path/build/first-app/windows/app", - ), # Compile MSI mock.call( [ - tmp_path / "wix/bin/candle.exe", - "-nologo", - "-ext", - "WixUtilExtension", + tmp_path / "wix" / WIX_EXE_PATH, + "build", "-ext", - "WixUIExtension", - "-arch", - "x64", - f"-dSourceDir={package_path}", + tmp_path / "wix" / WIX_UI_PATH, "first-app.wxs", - "first-app-manifest.wxs", - ], - check=True, - cwd=tmp_path / "base_path/build/first-app/windows/app", - ), - # Link MSI - mock.call( - [ - tmp_path / "wix/bin/light.exe", - "-nologo", - "-ext", - "WixUtilExtension", - "-ext", - "WixUIExtension", "-loc", "unicode.wxl", "-o", tmp_path / "base_path/dist/First App-0.0.1.msi", - "first-app.wixobj", - "first-app-manifest.wixobj", ], check=True, cwd=tmp_path / "base_path/build/first-app/windows/app", @@ -923,145 +643,6 @@ def test_package_msi_failed_signing_msi(package_command, first_app_config, tmp_p ] -def test_external_package_msi( - package_command, - external_first_app, - tmp_path, -): - """A Windows app can be packaged as an MSI and code signed.""" - - package_command.package_app( - external_first_app, - identity="80ee4c3321122916f5637522451993c2a0a4a56a", - file_digest="sha42", - use_local_machine=False, - cert_store="mystore", - timestamp_url="http://freetimestamps.com", - timestamp_digest="sha56", - ) - - package_path = tmp_path / "base_path/external/src" - assert package_command.tools.subprocess.run.mock_calls == [ - # Codesign app exe - mock.call( - [ - tmp_path - / "windows_sdk" - / "bin" - / "81.2.1.0" - / "groovy" - / "signtool.exe", - "sign", - "-s", - "mystore", - "-sha1", - "80ee4c3321122916f5637522451993c2a0a4a56a", - "-fd", - "sha42", - "-d", - "The first simple app \\ demonstration", - "-du", - "https://example.com/first-app", - "-tr", - "http://freetimestamps.com", - "-td", - "sha56", - ] - + [tmp_path / "base_path/external/src/internal/app.exe"], - check=True, - ), - # Collect manifest - mock.call( - [ - tmp_path / "wix/bin/heat.exe", - "dir", - package_path, - "-nologo", - "-gg", - "-sfrag", - "-sreg", - "-srd", - "-scom", - "-dr", - "first_app_ROOTDIR", - "-cg", - "first_app_COMPONENTS", - "-var", - "var.SourceDir", - "-out", - "first-app-manifest.wxs", - ], - check=True, - cwd=tmp_path / "base_path/build/first-app/windows/app", - ), - # Compile MSI - mock.call( - [ - tmp_path / "wix/bin/candle.exe", - "-nologo", - "-ext", - "WixUtilExtension", - "-ext", - "WixUIExtension", - "-arch", - "x64", - f"-dSourceDir={package_path}", - "first-app.wxs", - "first-app-manifest.wxs", - ], - check=True, - cwd=tmp_path / "base_path/build/first-app/windows/app", - ), - # Link MSI - mock.call( - [ - tmp_path / "wix/bin/light.exe", - "-nologo", - "-ext", - "WixUtilExtension", - "-ext", - "WixUIExtension", - "-loc", - "unicode.wxl", - "-o", - tmp_path / "base_path/dist/First App-0.0.1.msi", - "first-app.wixobj", - "first-app-manifest.wixobj", - ], - check=True, - cwd=tmp_path / "base_path/build/first-app/windows/app", - ), - # Codesign app MSI - mock.call( - [ - tmp_path - / "windows_sdk" - / "bin" - / "81.2.1.0" - / "groovy" - / "signtool.exe", - "sign", - "-s", - "mystore", - "-sha1", - "80ee4c3321122916f5637522451993c2a0a4a56a", - "-fd", - "sha42", - "-d", - "The first simple app \\ demonstration", - "-du", - "https://example.com/first-app", - "-tr", - "http://freetimestamps.com", - "-td", - "sha56", - ] - + [tmp_path / "base_path/dist/First App-0.0.1.msi"], - check=True, - ), - ] - - def test_external_package_zip( package_command_with_files, external_first_app, diff --git a/tests/platforms/windows/conftest.py b/tests/platforms/windows/conftest.py index edf30cbfa..d7e1924a7 100644 --- a/tests/platforms/windows/conftest.py +++ b/tests/platforms/windows/conftest.py @@ -1,8 +1,26 @@ import pytest +from ...utils import create_file + # Windows' AppConfig requires attribute 'packaging_format' @pytest.fixture def first_app_config(first_app_config): first_app_config.packaging_format = "msi" return first_app_config + + +@pytest.fixture +def external_first_app(first_app_config, tmp_path): + first_app_config = first_app_config.copy() + first_app_config.sources = None + first_app_config.external_package_path = tmp_path / "base_path/external/src" + first_app_config.external_package_executable_path = "internal/app.exe" + + # Create the binary + create_file( + tmp_path / "base_path/external/src/internal/app.exe", + "external binary", + ) + + return first_app_config diff --git a/tests/platforms/windows/visualstudio/test_create.py b/tests/platforms/windows/visualstudio/test_create.py new file mode 100644 index 000000000..ca07f0c09 --- /dev/null +++ b/tests/platforms/windows/visualstudio/test_create.py @@ -0,0 +1,24 @@ +import pytest + +from briefcase.console import Console +from briefcase.platforms.windows.visualstudio import WindowsVisualStudioCreateCommand + +# Most tests are the same for both "app" and "visualstudio". +from ..app.test_create import * # noqa: F403 + + +@pytest.fixture +def create_command(tmp_path): + return WindowsVisualStudioCreateCommand( + console=Console(), + base_path=tmp_path / "base_path", + data_path=tmp_path / "briefcase", + ) + + +def test_package_path(create_command, first_app_config, tmp_path): + """The default package_path is passed as an absolute path.""" + context = create_command.output_format_template_context(first_app_config) + assert context["package_path"] == str( + tmp_path / "base_path/build/first-app/windows/visualstudio/x64/Release" + ) diff --git a/tests/platforms/windows/visualstudio/test_package.py b/tests/platforms/windows/visualstudio/test_package.py index 3db8425f4..7d8ddd965 100644 --- a/tests/platforms/windows/visualstudio/test_package.py +++ b/tests/platforms/windows/visualstudio/test_package.py @@ -10,6 +10,8 @@ from briefcase.integrations.wix import WiX from briefcase.platforms.windows.visualstudio import WindowsVisualStudioPackageCommand +from ....integrations.wix.conftest import WIX_EXE_PATH, WIX_UI_PATH + @pytest.fixture def package_command(tmp_path): @@ -28,86 +30,21 @@ def test_package_msi(package_command, first_app_config, tmp_path): package_command.package_app(first_app_config) - package_path = ( - tmp_path / "base_path/build/first-app/windows/visualstudio/x64/Release" - ) - package_command.tools.subprocess.run.assert_has_calls( - [ - # Collect manifest - mock.call( - [ - tmp_path / "wix/bin/heat.exe", - "dir", - package_path, - "-nologo", - "-gg", - "-sfrag", - "-sreg", - "-srd", - "-scom", - "-dr", - "first_app_ROOTDIR", - "-cg", - "first_app_COMPONENTS", - "-var", - "var.SourceDir", - "-out", - "first-app-manifest.wxs", - ], - check=True, - cwd=tmp_path - / "base_path" - / "build" - / "first-app" - / "windows" - / "visualstudio", - ), - # Compile MSI - mock.call( - [ - tmp_path / "wix/bin/candle.exe", - "-nologo", - "-ext", - "WixUtilExtension", - "-ext", - "WixUIExtension", - "-arch", - "x64", - f"-dSourceDir={package_path}", - "first-app.wxs", - "first-app-manifest.wxs", - ], - check=True, - cwd=tmp_path - / "base_path" - / "build" - / "first-app" - / "windows" - / "visualstudio", - ), - # Link MSI - mock.call( - [ - tmp_path / "wix/bin/light.exe", - "-nologo", - "-ext", - "WixUtilExtension", - "-ext", - "WixUIExtension", - "-loc", - "unicode.wxl", - "-o", - tmp_path / "base_path/dist/First App-0.0.1.msi", - "first-app.wixobj", - "first-app-manifest.wixobj", - ], - check=True, - cwd=tmp_path - / "base_path" - / "build" - / "first-app" - / "windows" - / "visualstudio", - ), - ] - ) + assert package_command.tools.subprocess.run.mock_calls == [ + # Compile MSI + mock.call( + [ + tmp_path / "wix" / WIX_EXE_PATH, + "build", + "-ext", + tmp_path / "wix" / WIX_UI_PATH, + "first-app.wxs", + "-loc", + "unicode.wxl", + "-o", + tmp_path / "base_path/dist/First App-0.0.1.msi", + ], + check=True, + cwd=tmp_path / "base_path/build/first-app/windows/visualstudio", + ), + ] From 90a3d9d1d726dbdde2b3c92fd6d1e06cf4d93bc5 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Fri, 27 Jun 2025 22:19:48 +0100 Subject: [PATCH 4/8] Complete coverage --- docs/reference/commands/upgrade.rst | 2 +- tests/integrations/wix/test_WiX__verify.py | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/reference/commands/upgrade.rst b/docs/reference/commands/upgrade.rst index fbe28ae96..c642b608e 100644 --- a/docs/reference/commands/upgrade.rst +++ b/docs/reference/commands/upgrade.rst @@ -15,7 +15,7 @@ Over time, it may be necessary to upgrade these tools. The ``upgrade`` command provides a way to perform these upgrades. If you are managing your own version of these tools (e.g., if you have -downloaded a version of WiX and have set the ``WIX_HOME`` environment variable), +downloaded the Android SDK and have set the ``ANDROID_HOME`` environment variable), you must manage any upgrades on your own. Usage diff --git a/tests/integrations/wix/test_WiX__verify.py b/tests/integrations/wix/test_WiX__verify.py index 2730b2a8b..9c912de18 100644 --- a/tests/integrations/wix/test_WiX__verify.py +++ b/tests/integrations/wix/test_WiX__verify.py @@ -135,7 +135,7 @@ def assert_download(mock_tools, wix_path): assert wix.wix_exe == wix_path / WIX_EXE_PATH -def test_dont_install(mock_tools, tmp_path): +def test_dont_install_missing(mock_tools, tmp_path): """If there's no existing managed WiX install, and install is not requested, verify fails.""" # Verify, but don't install. This will fail. @@ -146,6 +146,23 @@ def test_dont_install(mock_tools, tmp_path): mock_tools.file.download.assert_not_called() +def test_dont_install_version(mock_tools, wix_path): + """If the existing managed WiX install is the wrong version, and install is not + requested, verify fails.""" + wix_exe = wix_path / WIX_EXE_PATH + wix_exe.parent.mkdir(parents=True) + wix_exe.touch() + + mock_tools.subprocess.check_output.return_value = "5.0.1" + + # Verify, but don't install. This will fail. + with pytest.raises(MissingToolError): + WiX.verify(mock_tools, install=False) + + # No download was initiated + mock_tools.file.download.assert_not_called() + + def test_download_fail(mock_tools, tmp_path): """If the download doesn't complete, the validator fails.""" # Mock the download failure From 71fa9a809e41b93696ddde74821bb94cddeb942a Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Mon, 30 Jun 2025 21:52:41 +0100 Subject: [PATCH 5/8] Restore `-arch` option --- src/briefcase/platforms/windows/__init__.py | 2 ++ tests/platforms/windows/app/test_package.py | 8 ++++++++ tests/platforms/windows/visualstudio/test_package.py | 2 ++ 3 files changed, 12 insertions(+) diff --git a/src/briefcase/platforms/windows/__init__.py b/src/briefcase/platforms/windows/__init__.py index fdae087f7..cfd88772d 100644 --- a/src/briefcase/platforms/windows/__init__.py +++ b/src/briefcase/platforms/windows/__init__.py @@ -387,6 +387,8 @@ def _package_msi(self, app): "build", "-ext", self.tools.wix.ext_path("UI"), + "-arch", + "x64", # Default is x86, regardless of the build machine. f"{app.app_name}.wxs", "-loc", "unicode.wxl", diff --git a/tests/platforms/windows/app/test_package.py b/tests/platforms/windows/app/test_package.py index f9d943db3..78c5759d6 100644 --- a/tests/platforms/windows/app/test_package.py +++ b/tests/platforms/windows/app/test_package.py @@ -225,6 +225,8 @@ def test_package_msi( "build", "-ext", tmp_path / "wix" / WIX_UI_PATH, + "-arch", + "x64", "first-app.wxs", "-loc", "unicode.wxl", @@ -356,6 +358,8 @@ def test_package_msi_with_codesigning( "build", "-ext", tmp_path / "wix" / WIX_UI_PATH, + "-arch", + "x64", "first-app.wxs", "-loc", "unicode.wxl", @@ -536,6 +540,8 @@ def test_package_msi_failed_compile(package_command, first_app_config, tmp_path) "build", "-ext", tmp_path / "wix" / WIX_UI_PATH, + "-arch", + "x64", "first-app.wxs", "-loc", "unicode.wxl", @@ -603,6 +609,8 @@ def test_package_msi_failed_signing_msi(package_command, first_app_config, tmp_p "build", "-ext", tmp_path / "wix" / WIX_UI_PATH, + "-arch", + "x64", "first-app.wxs", "-loc", "unicode.wxl", diff --git a/tests/platforms/windows/visualstudio/test_package.py b/tests/platforms/windows/visualstudio/test_package.py index 7d8ddd965..ab880cf3a 100644 --- a/tests/platforms/windows/visualstudio/test_package.py +++ b/tests/platforms/windows/visualstudio/test_package.py @@ -38,6 +38,8 @@ def test_package_msi(package_command, first_app_config, tmp_path): "build", "-ext", tmp_path / "wix" / WIX_UI_PATH, + "-arch", + "x64", "first-app.wxs", "-loc", "unicode.wxl", From 67c992d67a8a361423fdcee1758f28e7bf25958c Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Mon, 30 Jun 2025 21:53:29 +0100 Subject: [PATCH 6/8] Add "perUserOrMachine" install_scope --- src/briefcase/integrations/wix.py | 3 ++- src/briefcase/platforms/windows/__init__.py | 2 +- tests/platforms/windows/app/test_create.py | 4 ++-- tests/platforms/windows/visualstudio/test_create.py | 3 ++- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/briefcase/integrations/wix.py b/src/briefcase/integrations/wix.py index 6c17b1f66..c903b0455 100644 --- a/src/briefcase/integrations/wix.py +++ b/src/briefcase/integrations/wix.py @@ -58,7 +58,8 @@ def verify_install(cls, tools: ToolCache, install: bool = True, **kwargs) -> WiX """Verify that there is a WiX install available. WiX is a small tool, and there's a close relationship between the WiX version - and the template syntax, so we always use a Briefcase-managed copy. + and the template syntax, so we always use a Briefcase-managed copy, and upgrade + it automatically. :param tools: ToolCache of available tools :param install: Should WiX be installed if it is not found? diff --git a/src/briefcase/platforms/windows/__init__.py b/src/briefcase/platforms/windows/__init__.py index cfd88772d..53ec37f9b 100644 --- a/src/briefcase/platforms/windows/__init__.py +++ b/src/briefcase/platforms/windows/__init__.py @@ -98,7 +98,7 @@ def output_format_template_context(self, app: AppConfig): install_scope = "perMachine" if app.system_installer else "perUser" except AttributeError: # system_installer not defined in config; default to asking the user - install_scope = None + install_scope = "perUserOrMachine" return { "version_triple": version_triple, diff --git a/tests/platforms/windows/app/test_create.py b/tests/platforms/windows/app/test_create.py index ab50bdc8a..a198120d7 100644 --- a/tests/platforms/windows/app/test_create.py +++ b/tests/platforms/windows/app/test_create.py @@ -134,9 +134,9 @@ def test_support_package_url(create_command, first_app_config, tmp_path): def test_default_install_scope(create_command, first_app_config, tmp_path): - """By default, app should be installed per user.""" + """By default, the installer gives a choice between per user and per machine.""" context = create_command.output_format_template_context(first_app_config) - assert context["install_scope"] is None + assert context["install_scope"] == "perUserOrMachine" def test_per_machine_install_scope(create_command, first_app_config, tmp_path): diff --git a/tests/platforms/windows/visualstudio/test_create.py b/tests/platforms/windows/visualstudio/test_create.py index ca07f0c09..6dace5f5b 100644 --- a/tests/platforms/windows/visualstudio/test_create.py +++ b/tests/platforms/windows/visualstudio/test_create.py @@ -3,7 +3,8 @@ from briefcase.console import Console from briefcase.platforms.windows.visualstudio import WindowsVisualStudioCreateCommand -# Most tests are the same for both "app" and "visualstudio". +# Most tests and fixtures are the same for both "app" and "visualstudio". This file only +# contains those that need to be overridden. from ..app.test_create import * # noqa: F403 From e63a3dae410b1b4609fa65bb4bb65e789d094e37 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Fri, 4 Jul 2025 22:34:32 +0100 Subject: [PATCH 7/8] Clarify document type documentation --- docs/reference/configuration.rst | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/reference/configuration.rst b/docs/reference/configuration.rst index 061c59766..31987c3a2 100644 --- a/docs/reference/configuration.rst +++ b/docs/reference/configuration.rst @@ -610,7 +610,7 @@ or, for a platform-specific definition: ``[tool.briefcase.app...document_type.]`` -The ``document type id`` is an identifier, in alphanumeric format. It is appended to the app id of an application to identify documents of the same type. +The ``document type id`` is an identifier, in alphanumeric format. The document type declaration requires the following settings: @@ -620,9 +620,7 @@ A short, one-line description of the document format. .. attribute:: extension -The :attr:`extension` is the file extension to register. For example, ``myapp`` -could register as a handler for PNG image files by defining the configuration -section ``[tool.briefcase.app.myapp.document_type.png]``. +The file extension to register, without a leading dot. .. attribute:: icon From 25273f5d0a3374c557ac82086a44d1d3f87a3fa7 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Wed, 9 Jul 2025 09:17:13 +0100 Subject: [PATCH 8/8] Remove reference to .NET Framework 3.5 --- docs/reference/platforms/windows/index.rst | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/docs/reference/platforms/windows/index.rst b/docs/reference/platforms/windows/index.rst index 64565bd0d..3e35ca7c8 100644 --- a/docs/reference/platforms/windows/index.rst +++ b/docs/reference/platforms/windows/index.rst @@ -40,13 +40,7 @@ Briefcase supports two packaging formats for a Windows app: package windows -p zip``). Briefcase uses the `WiX Toolset `__ to build an -MSI installer for a Windows App. WiX, in turn, requires that .NET Framework 3.5 is -enabled. To ensure .NET Framework 3.5 is enabled: - -1. Open the Windows Control Panel -2. Traverse to Programs -> Programs and Features -3. Select "Turn Windows features On or Off" -4. Ensure that ".NET framework 3.5 (includes .NET 2.0 and 3.0)" is selected. +MSI installer for a Windows app. Icon format ===========