Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/1185.removal.rst
Original file line number Diff line number Diff line change
@@ -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``.
2 changes: 1 addition & 1 deletion docs/reference/commands/upgrade.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/briefcase/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
154 changes: 70 additions & 84 deletions src/briefcase/integrations/wix.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -13,62 +15,50 @@ class WiX(ManagedTool):
full_name = "WiX"
supported_host_os = {"Windows"}

def __init__(
self,
tools: ToolCache,
wix_home: Path | None = None,
bin_install: bool = False,
):
# 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, 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.
: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.
"""
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

@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?
Expand All @@ -77,36 +67,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")

Expand All @@ -115,49 +107,43 @@ 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",
)

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",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we certain we can rely on msiexec existing? And that it can be executed as a non-privileged user?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed on my daughter's school-provided (and thus locked-down) windows device - msiexec is available, and can be executed by non-admin users.

"/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."""
Expand Down
72 changes: 7 additions & 65 deletions src/briefcase/platforms/windows/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -103,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),
}

Expand Down Expand Up @@ -377,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."""
Expand Down
14 changes: 13 additions & 1 deletion tests/integrations/wix/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Loading
Loading