Repository: beeware/briefcase

Issue:
Upgrade to a current WiX version
### What is the problem or limitation you are having?

Windows packaging currently uses WiX Toolset 3. 

### Describe the solution you'd like

Briefcase should be updated to use a recent (supported) version of WiX.

### Describe alternatives you've considered

Stay on WiX 3.

### Additional context

[WiX 4 release details](https://github.com/wixtoolset/wix4/releases/tag/v4.0.0). 

It is unclear how much the format for WiX configuration files has changed - this could be as straightforward as changing the path to the WiX binary that is downloaded.


Discussion:
Figured this would be an easy win....but it's a bit more involved. Here are some notes I made.

WiX 4
- Requires .NET SDK version 6 or later is installed
  - Pretty easy to check with `dotnet --list-sdks`
- WiX 4 is now a [.NET Tool](https://learn.microsoft.com/en-us/dotnet/core/tools/global-tools)
- Install methods
  - [Global install](https://learn.microsoft.com/en-us/dotnet/core/tools/global-tools#install-a-global-tool)
    - `dotnet tool install --global wix --version 4.0.0`
    - This installs the tool to `%USERPROFILE%\.dotnet\tools` and is available via `$PATH`
    - Since NuGET is involved, the tool is really installed to `%USERPROFILE%\.nuget\packages\wix\4.0.0`
    - This means if a user (or another app) runs `dotnet tool install --global wix --version 4.1.0`, Briefcase would start using WiX 4.1.0
    - There's also a `--tool-path` version of `--global` that allows a custom install location but otherwise the same semantics
  - [Local install](https://learn.microsoft.com/en-us/dotnet/core/tools/global-tools#install-a-local-tool)
    - `dotnet tool install --local wix --version 4.0.0`
      - I think this requires calling `dotnet new tool-manifest` first for the directory
    - Really, the tool is installed normally to the dot directories...it just isn't added to `$PATH` or anything
    - As long as you're in the same directory as the `dotnet tool install` command, you can run this install with `dotnot tool run wix` (or `dotnet wix`)
- Install considerations
  - To maintain some level of isolation from the user's environment, the "local install" seems desirable
  - So, I think that would mean WiX 4 could be installed in to Briefcase's `AppData` directory and all uses of `subprocess` for WiX would require `cwd` set appropriately
  - Alternatively, we could just run the `dotnet tool install` command in the base directory of the app...however, that'll likely complicate the story for `briefcase upgrade wix`
- Template changes
  - WiX 4 provides a `wix convert` [command](https://wixtoolset.org/docs/fourthree/) to update `wxs` files
  - This'll presumably be necessary for both the app and VisualStudio templates
- WiX usage changes
  - The executables for `light`, `candle`, and `heat` do not exist anymore
  - It seems they have been made sub-commands of the primary `wix` executable

So, it seems this affects pretty much anything that could've been affected 🙃 I also think this means supporting both version 3 and 4 of WiX concurrently would be prohibitive....not that we'd necessarily want to...
Thanks for that audit - a list of annoying changes, to be sure; but nothing that sounds like an unsurmountable challenge. 

In terms of supporting both - unless there's a feature of WiX 3 that WiX 4 doesn't (or won't) support, I don't think we need to support both. WiX 3 has at least 2 major missing features (>64 char path name support, and ARM64 support); so IMHO we should migrate as soon as practical.
I made an attempt at this upgrade....but I think the gap in my knowledge about where and how WiX is expecting to fit in to a larger Windows development and packaging workflow is too large.

I converted the WiX files provided by the app template in beeware/briefcase-windows-app-template#14; there are likely some necessary tweaks and optimizations but that should be most of the work.

When I focused my attention on updating the calls to `heat.exe`, `candle.exe`, and `light.exe`, I basically hit a brick wall despite spending hours trying to understand the larger picture. The WiX 4 docs for [heat](https://wixtoolset.org/docs/tools/heat/) say to "add a reference to the new heat nuget package"....but where exactly? Presumably in to some larger project configuration files. Maybe an [MSBuild](https://wixtoolset.org/docs/tools/msbuild/) config? It seems like much more of an assumed project structure is necessary that was not needed for WiX 3.

At any rate, I thought I could power through and figure it out relatively quickly....but I don't think so. I may return to this later if someone else doesn't knock it out.
Good to know WiX is maintaining its long tradition of near useless documentation... 🙄 

FYI; looks like WiX is moving to an annual release schedule.

https://www.firegiant.com/blog/2024/3/8/wix-v5.0.0-rc.1-is-calling/
Ugh... we really do need to address this so we don't get stuck on v3 forever...
So... as of February 6 2025, Wix 4 is no longer supported either.

Wix 5, and an RC of Wix 6 is now available.

So - upgrading our Wix support would seem to be of increasing urgency.
I'm picking this up now; thanks to @rmartin16 for the information above.

> Requires .NET SDK version 6 or later is installed

If it isn't installed, I think Briefcase should install it as a managed tool. I've done some experimentation, and this can be automated with the [dotnet-install script](https://learn.microsoft.com/en-us/dotnet/core/install/windows#install-with-powershell), which produces a standalone installation which is independent of any other SDKs on the machine.

> To maintain some level of isolation from the user's environment, the "local install" seems desirable. So, I think that would mean WiX 4 could be installed in to Briefcase's AppData directory and all uses of subprocess for WiX would require cwd set appropriately

I agree. I've tested this, and as described above, the actual WiX installation would be in the NuGet packages directory. The Briefcase tools subdirectory would contain nothing except the tool manifest file (in a hidden .config directory).

So my proposal is the following:

* If there's a `dotnet` command on the PATH which points at a new enough .NET SDK, then use it.
* Otherwise, install .NET as a managed tool. As with Java, the exact version is unlikely to be critical, but we might as well make it a fixed version to reduce the number of variables in the system.
  * The obvious version to choose is the LTS version, which is [currently 8](https://dotnet.microsoft.com/en-us/download/dotnet). 
  * For future reference: when installing with the dotnet-install script mentioned above, I couldn't get .NET 9 to work on Parallels on ARM64 – the `dotnet` tool exited with no output when given anything except an empty command line. .NET 8 worked fine on Parallels, as did .NET 10, which will become the next LTS version in November 2025. All three versions worked on my x86-64 machine. 
* WiX is much more likely than .NET to be sensitive to the exact version, so we should never use a user-provided version, and always install it as a managed tool with a fixed version. Environment variables and PATH items will have no effect on this.
> > Requires .NET SDK version 6 or later is installed
> 
> If it isn't installed, I think Briefcase should install it as a managed tool. I've done some experimentation, and this can be automated with the [dotnet-install script](https://learn.microsoft.com/en-us/dotnet/core/install/windows#install-with-powershell), which produces a standalone installation which is independent of any other SDKs on the machine.

That sounds reasonable to me. The only question/concern is whether this needs admin permissions, or whether .NET can be installed as a regular user. If it requires admin access, I guess there's not much we can do - but it's definitely something to be aware of for documentation purposes.

> > To maintain some level of isolation from the user's environment, the "local install" seems desirable. So, I think that would mean WiX 4 could be installed in to Briefcase's AppData directory and all uses of subprocess for WiX would require cwd set appropriately
> 
> I agree. I've tested this, and as described above, the actual WiX installation would be in the NuGet packages directory. The Briefcase tools subdirectory would contain nothing except the tool manifest file (in a hidden .config directory).

I'm not sure I'm completely following here - but that may be because of my understanding of NuGet. Is the NuGet packages directory something that would be Briefcase specific, or is it at the level of a user account? How do we guarantee "Briefcase isolation" of the installed WiX version? 

Is this a case where the NuGet packages folder is a big archive of multiple installed versions (none of which are on the path), and then the Briefcase tools folder contains the configuration that shows which one is active (or should be activated)?
 
> The only question/concern is whether this needs admin permissions, or whether .NET can be installed as a regular user.

As I understand it, in this installation method it's literally just unpacking a ZIP. It doesn't even write to the registry.

> Is the NuGet packages directory something that would be Briefcase specific, or is it at the level of a user account?

It's at the user account level.

> Is this a case where the NuGet packages folder is a big archive of multiple installed versions (none of which are on the path), and then the Briefcase tools folder contains the configuration that shows which one is active (or should be activated)?

That's correct. The packages folder is like a cache, and once a version is installed into it, it's effectively read-only, which is why the folder doesn't need to be Briefcase-specific. But there are various ways of creating entry points elsewhere on the filesystem. For the "local" install mode, the `dotnet tool` command creates the configuration file in a directory, and then when you run `dotnet wix` while inside that directory, it reads the configuration file and decides which version to run. 
In which case: +1 to the plan you've described; that all makes sense to me.
@mhsmith @freakboy3742 Some thoughts that could be totally wrong:

- I see that wix (version 6) is now distributed as a cli too independent of dotnet and nuget (https://github.com/wixtoolset/wix/releases/tag/v6.0.1). This is much simpler to deal with. I don't think briefcase has to deal with nuget. Just grab the MSI from github and extract the bin folder from it and you are good to go.

- I checked if i can run the command in a Windows Sandbox (i.e., without dotnet installed) and it works on the surface. I think it is published as self contained as some dotnet dlls are there in the bin folder. I have not actually ran any packaging workflow with it yet though but it would make sense that it would be published as a self contained package.
Thanks, I'll keep this in mind, but it doesn't appear to be one of the [supported ways of installing WiX](https://docs.firegiant.com/wix/using-wix/), so I'd be cautious about using it.

How would you extract the bin folder from the MSI? Can you do that with the Python standard library, or would you need a third-party package or tool?
@mhsmith 

I think it was introduced in v5 to simplify using wixtoolset in pipelines and such.

For extraction, I suggest to use the Windows included `msiexec` tool. It is already included with the OS so you don't have to worry about it. The command would be something like this ([link](https://superuser.com/questions/307678/how-do-i-extract-files-from-an-msi-package)):

`msiexec /a drive:\filepath\to\MSI\file /qb TARGETDIR=drive:\filepath\to\target\folder`

Now, this assumes that you are running this on Windows. If you want to run this on other OSs, you can grab one of the python packages I guess. For example, just two days ago, this package [(pymsi)](https://github.com/nightlark/pymsi) was posted in reddit and it is pure python rather than a wrapper around something else. There are other ones too. 

p.s. `msilib` used to be part of the standard library but it was removed in 3.13 (https://docs.python.org/3/library/msilib.html). 

==================

Now, while reading around today, I discovered something more concerning:

It looks like the wixtoolset is not free anymore (as of v6) in the sense that customer who use it and generate revenue are required to pay the Open Source Maintenece Fee (GitHub sponsorship). I am not sure if it is a direct revenue from the sale software that uses Wix, revenue from the use of the internal software that uses Wix, or any revenue from the company. From the wording, it looks like it is the last option.  

1. https://github.com/wixtoolset/issues/issues/8974
2. https://github.com/sponsors/wixtoolset/
3. https://opensourcemaintenancefee.org/

So the option:
1. Briefcase uses v6 but put a warning for the users or something.
2. Briefcase uses v5 and get stuck at v5.
3. Briefcase uses something else (best alternative is MSIX, which has its own issues too).

I am starting to like option 3.


That's great about the license and usage. It seems convoluted but it it looks like there is leeway.

Yah, that is the major thing with MSIX that I don't like. I think Microsoft shot themselves in the leg by not allowing self-signed MSIX to install. They could always put a warning/question to user during installation (do you trust this application) and that's it.
I am not a lawyer, but... this looks like a great example of why you don't get non-lawyers to write licenses.

Based on my very much non-expert reading: 

1. If we update the code so that Briefcase invokes `dotnet tool install --global wix`, our users would have acquired a licensing requirement without knowing it. So - we'd need to ask the user to confirm that they've got a license before running that command.

2. If we self-hosted the binaries using clause 4 of the OSMFEULA, it looks like we get around the legal problem - but we won't be able to use `dotnet tool install`. But even if that is legal under the *letter* of the agreement, it's definitely against the *spirit* of the law IMHO.

Of those 2 options, (1) seems more practical as an immediate solution... but it makes me wonder if we should be looking for an option other than WiX... 

For completeness, there are 2 more options:

3. Use WiX 5, which was the stable version until just a couple of months ago, and is [not affected by the EULA](https://github.com/wixtoolset/issues/issues/8974#issuecomment-2716432149). There are [no major changes](https://docs.firegiant.com/wix/whatsnew/#whats-new-in-wix-v6) betweeen versions 5 and 6 as far as I can see. But obviously we don't want to get ourselves into a dead end.

4. Build WiX from source and distribute our own binaries. From [the README](https://github.com/wixtoolset/wix/blob/main/README.md#developing-wix), this doesn't sound particularly difficult, but I don't have enough .NET development experience to be certain about this.

My suggestion is to use WiX 5 for now, ideally downloaded from a GitHub release as [discussed above](https://github.com/beeware/briefcase/issues/1185#issuecomment-2991629311), to give an easier transition to using our own binaries later.
I took a quick look at the ReadMe. Building it looks very stratightforward, especially if done manually. You just install VS 2022 Community Edition (free version), make sure you select the workflows and individual components, and run the command from the VS PowerShell or VS command line.

Doing this in a pipeline would require more work as each component are seperated in individual packages so you will have a seperate command to them one by one (you don't install VS 2022 in the pipeline). But I don't see a real reason for including this in a pipeline as you can just host the binary somewhere and update them once in a while.

WiX 5 is [EOL on Feb 5 next year](https://docs.firegiant.com/wix/#servicing-updates), so it's not a great long term solution, but it's certainly good enough for now. WiX 3 is already EOL, and aside from the known bugs/limitations, it still *works*, so it seems reasonable to assume that WiX 5 won't be any worse in that regard.

Assuming that our in-house legal analysis determines that we can't re-distribute the official binary, developing a whole release infrastructure to do an end-run around an open source project's licensing terms feels... icky. It would seem to be entirely legal by the terms of their license... but definitely not in the spirit of what is, fundamentally, a reasonable request - pay for what you use. If we went down this path, at the very least I'd want to put prominent messaging encouraging "revenue generating users" to sign up for a WiX license.

Yes, that would be a good gesture, as long as we word it in a way that doesn't cause the confusion surrounding WiX to infect Briefcase as well. 



Patch:
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,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/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/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.<app name>.<platform>.document_type.<document type id>]``
 
-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
 
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 <https://www.firegiant.com/wixtoolset/>`__ 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
 ===========
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 91a5a287a..c903b0455 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,51 @@ 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, and upgrade
+        it automatically.
 
         :param tools: ToolCache of available tools
         :param install: Should WiX be installed if it is not found?
@@ -77,36 +68,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 +108,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 +124,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/src/briefcase/platforms/windows/__init__.py b/src/briefcase/platforms/windows/__init__.py
index c0d821f71..53ec37f9b 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:
@@ -97,12 +98,13 @@ 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,
             "guid": str(guid),
             "install_scope": install_scope,
+            "package_path": str(self.package_path(app)),
             "binary_path": self.package_executable_path(app),
         }
 
@@ -377,85 +379,27 @@ 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",
+                        self.tools.wix.ext_path("UI"),
                         "-arch",
-                        "x64",
-                        f"-dSourceDir={self.package_path(app)}",
+                        "x64",  # Default is x86, regardless of the build machine.
                         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."""


Test patch:
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..9c912de18 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,121 +37,127 @@ 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
+def test_dont_install_missing(mock_tools, tmp_path):
+    """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_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()
@@ -159,9 +165,6 @@ def test_dont_install(mock_tools, tmp_path):
 
 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 +172,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 +180,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_zip_path = create_zip_file(tmp_path / "tools/wix.zip", content=[("wix", "wix")])
-
-    mock_tools.file.download = MagicMock(return_value=wix_zip_path)
+    wix_msi_path = wix_path.parent / "wix.msi"
+    wix_msi_path.touch()
+    mock_tools.file.download = MagicMock(return_value=wix_msi_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()
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..a198120d7 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",
     [
@@ -123,40 +134,41 @@ 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 == {
-        "binary_path": "First App.exe",
-        "guid": "d666a4f1-c7b7-52cc-888a-3a35a7cc97e5",
-        "version_triple": "0.0.1",
-        "install_scope": None,
-    }
+    assert context["install_scope"] == "perUserOrMachine"
 
 
 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..78c5759d6 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,42 @@ 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",
+                tmp_path / "wix" / WIX_UI_PATH,
                 "-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",
@@ -345,7 +312,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 +351,20 @@ 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",
-                "-ext",
-                "WixUtilExtension",
+                tmp_path / "wix" / WIX_EXE_PATH,
+                "build",
                 "-ext",
-                "WixUIExtension",
+                tmp_path / "wix" / WIX_UI_PATH,
                 "-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",
@@ -596,183 +519,34 @@ 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
-    package_command.tools.subprocess.run.side_effect = [
-        None,  # heat.exe
-        CalledProcessError(cmd=["candle.exe"], returncode=1),
-    ]
-
-    with pytest.raises(
-        BriefcaseCommandError,
-        match=r"Unable to compile 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
+    # Mock a failure in the call to wix.exe
     package_command.tools.subprocess.run.side_effect = [
-        None,  # heat.exe
-        None,  # candle.exe
-        CalledProcessError(cmd=["link.exe"], returncode=1),
+        CalledProcessError(cmd=["wix.exe"], returncode=1),
     ]
 
     with pytest.raises(
         BriefcaseCommandError,
-        match=r"Unable to link 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",
+                tmp_path / "wix" / WIX_EXE_PATH,
+                "build",
                 "-ext",
-                "WixUtilExtension",
-                "-ext",
-                "WixUIExtension",
+                tmp_path / "wix" / WIX_UI_PATH,
                 "-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",
@@ -782,12 +556,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 +573,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 +602,20 @@ 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",
+                tmp_path / "wix" / WIX_UI_PATH,
                 "-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",
@@ -923,145 +651,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..6dace5f5b
--- /dev/null
+++ b/tests/platforms/windows/visualstudio/test_create.py
@@ -0,0 +1,25 @@
+import pytest
+
+from briefcase.console import Console
+from briefcase.platforms.windows.visualstudio import WindowsVisualStudioCreateCommand
+
+# 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
+
+
+@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..ab880cf3a 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,23 @@ 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,
+                "-arch",
+                "x64",
+                "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",
+        ),
+    ]