Repository: beeware/briefcase
Pull request #2369 (2025-07-03 19:23:48)

Add `--forward-port` and `--reverse-port` via ADB on Android
This PR adds the possibility to forward/reverse a port to an Android app via ADB.

I needed the forwarding/reversing for adding debug capabilitys to Android in https://github.com/beeware/briefcase/pull/2351. But now I also have another use case, where I want to connect the android app to a REST-Server running on my host system. So I though it would be generally a useful feature for briefcase.

<!--- Describe your changes in detail -->
<!--- What problem does this change solve? -->
<!--- If this PR relates to an issue, include Refs #XXX or Fixes #XXX -->

## PR Checklist:
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
- [x] All new features have been tested
- [x] All new features have been documented
- [x] I have read the **CONTRIBUTING.md** file
- [x] I will abide by the code of conduct



Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3da0cf2d..217fdfa9 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -58,7 +58,7 @@ jobs:
           test $(briefcase --version) = $VERSION
 
       - name: Create release
-        uses: ncipollo/release-action@v1.17.0
+        uses: ncipollo/release-action@v1.18.0
         with:
           name: ${{ env.VERSION }}
           draft: true
diff --git a/.gitignore b/.gitignore
index 821046d5..a339015a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,6 +18,7 @@ coverage.xml
 .eggs/
 .tox/
 /local
+.ruff_cache/
 
 /pip-wheel-metadata
 # IntelliJ Idea family of suites
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 3fb9654d..0e270440 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -15,7 +15,7 @@ repos:
   #     - id: docformatter
   #       args: [--in-place, --black]
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.12.1
+    rev: v0.12.2
     hooks:
       - id: ruff-format
       - id: ruff-check
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
index 4206b35b..ef6979e6 100644
--- a/.readthedocs.yaml
+++ b/.readthedocs.yaml
@@ -30,6 +30,11 @@ formats:
 # Install extras for build - dev is needed to run tox
 python:
   install:
+  # Docs requirements are *mostly* handled by the `docs` extra; but we can't include
+  # the theme that way, so the theme is installed using a requirements.txt file,
+  # independent of the docs extra. Ideally, we'd use dependency groups for docs
+  # dependencies, but RTD doesn't support them yet.
+  - requirements: requirements-docs.txt
   - method: pip
     path: .
     extra_requirements:
diff --git a/changes/1099.bugfix.rst b/changes/1099.bugfix.rst
deleted file mode 100644
index 4886c19d..00000000
--- a/changes/1099.bugfix.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-A redundant signing phase has been removed when packaging an apps for macOS.
diff --git a/changes/1190.doc.rst b/changes/1190.doc.rst
deleted file mode 100644
index 0d3406a2..00000000
--- a/changes/1190.doc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Updated Documentation to Clarify iOS Physical vs. Simulated Devices Support
diff --git a/changes/1669.misc.rst b/changes/1669.misc.rst
deleted file mode 100644
index e482b7f2..00000000
--- a/changes/1669.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Warnings about outdated Python versions will now be shown.
diff --git a/changes/1696.doc.rst b/changes/1696.doc.rst
deleted file mode 100644
index 606ecaaf..00000000
--- a/changes/1696.doc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Contribution guides are now synced across projects, and OS platform documentation now has prerequisites.
diff --git a/changes/1808.bugfix.rst b/changes/1808.bugfix.rst
deleted file mode 100644
index f968bf29..00000000
--- a/changes/1808.bugfix.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-An error is now raised on macOS if Briefcase is used on an iCloud mounted drive. iCloud synchronization adds metadata to some folders, which is incompatible with the signing process.
diff --git a/changes/2151.bugfix.rst b/changes/2151.bugfix.rst
deleted file mode 100644
index d2b57280..00000000
--- a/changes/2151.bugfix.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-App 'formal_name' can now except names with '.' characters
diff --git a/changes/2188.bugfix.rst b/changes/2188.bugfix.rst
deleted file mode 100644
index 7f7c635c..00000000
--- a/changes/2188.bugfix.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Distribution-specific cumulative settings (such as `requires`) now correctly accumulate, rather than overwriting more general values.
diff --git a/changes/2230.feature.rst b/changes/2230.feature.rst
deleted file mode 100644
index 72cd612f..00000000
--- a/changes/2230.feature.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-When an iOS or macOS binary wheel cannot be found, the error message now provides more useful hints regarding the possible cause.
diff --git a/changes/2280.misc.rst b/changes/2280.misc.rst
deleted file mode 100644
index 6db1f10d..00000000
--- a/changes/2280.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Updated setuptools from 80.3.1 to 80.4.0 in /automation.
diff --git a/changes/2281.misc.rst b/changes/2281.misc.rst
deleted file mode 100644
index 101e818a..00000000
--- a/changes/2281.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Updated setuptools from 80.3.1 to 80.4.0.
diff --git a/changes/2284.feature.rst b/changes/2284.feature.rst
deleted file mode 100644
index 7fd38a45..00000000
--- a/changes/2284.feature.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Document type and file associations have been improved on macOS.
diff --git a/changes/2287.misc.rst b/changes/2287.misc.rst
deleted file mode 100644
index 94cfc4cf..00000000
--- a/changes/2287.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Updated tox from 4.25.0 to 4.26.0.
diff --git a/changes/2288.misc.rst b/changes/2288.misc.rst
deleted file mode 100644
index 5e67714a..00000000
--- a/changes/2288.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Updated setuptools from 80.4.0 to 80.7.1.
diff --git a/changes/2289.misc.rst b/changes/2289.misc.rst
deleted file mode 100644
index 39116c7a..00000000
--- a/changes/2289.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Updated setuptools from 80.4.0 to 80.7.1 in /automation.
diff --git a/changes/2291.misc.rst b/changes/2291.misc.rst
deleted file mode 100644
index cc4e7836..00000000
--- a/changes/2291.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Updates readme to state briefcase instead of toga
diff --git a/changes/2292.misc.rst b/changes/2292.misc.rst
deleted file mode 100644
index 96563ae4..00000000
--- a/changes/2292.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Updated from black and flake8 to ruff
diff --git a/changes/2295.misc.rst b/changes/2295.misc.rst
deleted file mode 100644
index 7e1e000f..00000000
--- a/changes/2295.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Update ditto unit test to account for recent changes to MacOS zip files.
diff --git a/changes/2296.bugfix.1.rst b/changes/2296.bugfix.1.rst
deleted file mode 100644
index 5b93c5d0..00000000
--- a/changes/2296.bugfix.1.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Briefcase will now default to using the system certificate store when performing HTTPS downloads.
diff --git a/changes/2296.bugfix.2.rst b/changes/2296.bugfix.2.rst
deleted file mode 100644
index 1b9b50de..00000000
--- a/changes/2296.bugfix.2.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Briefcase now provides richer error information when it is unable to complete a download. In particular, issues associated with SSL verification are now reported as such, rather than as a generic "are you offline?" message.
diff --git a/changes/2310.misc.rst b/changes/2310.misc.rst
deleted file mode 100644
index bd9b56a0..00000000
--- a/changes/2310.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Updated setuptools from 80.7.1 to 80.8.0.
diff --git a/changes/2311.misc.rst b/changes/2311.misc.rst
deleted file mode 100644
index f8236c94..00000000
--- a/changes/2311.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Updated coverage[toml] from 7.8.0 to 7.8.2.
diff --git a/changes/2312.misc.rst b/changes/2312.misc.rst
deleted file mode 100644
index dcf9d4f9..00000000
--- a/changes/2312.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Updated setuptools from 80.7.1 to 80.8.0 in /automation.
diff --git a/changes/2313.misc.rst b/changes/2313.misc.rst
deleted file mode 100644
index 0ac70ca9..00000000
--- a/changes/2313.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-The ``pre-commit`` hook for ``ruff-pre-commit`` was updated to its latest version.
diff --git a/changes/2319.misc.rst b/changes/2319.misc.rst
deleted file mode 100644
index cb31f321..00000000
--- a/changes/2319.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Align environment variable handling for flatpak with the other platforms
diff --git a/changes/2320.misc.rst b/changes/2320.misc.rst
deleted file mode 100644
index a9364f27..00000000
--- a/changes/2320.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Some small cleanups that make the code more readable.
diff --git a/changes/2321.misc.rst b/changes/2321.misc.rst
deleted file mode 100644
index 62d7f8c2..00000000
--- a/changes/2321.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Updated setuptools from 80.8.0 to 80.9.0 in /automation.
diff --git a/changes/2322.misc.rst b/changes/2322.misc.rst
deleted file mode 100644
index 0ac70ca9..00000000
--- a/changes/2322.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-The ``pre-commit`` hook for ``ruff-pre-commit`` was updated to its latest version.
diff --git a/changes/2323.misc.rst b/changes/2323.misc.rst
deleted file mode 100644
index bce32c77..00000000
--- a/changes/2323.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Updated pytest-xdist from 3.6.1 to 3.7.0.
diff --git a/changes/2324.misc.rst b/changes/2324.misc.rst
deleted file mode 100644
index bdcaeff6..00000000
--- a/changes/2324.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Updated setuptools from 80.8.0 to 80.9.0.
diff --git a/changes/2325.bugfix.rst b/changes/2325.bugfix.rst
deleted file mode 100644
index 3020f620..00000000
--- a/changes/2325.bugfix.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-The presence of a ``PIP_REQUIRE_VIRTUALENV`` environment marker no longer influences the operation of dependency installation on iOS. The iOS installation environments is inherently isolated, so this flag is redundant; but in some circumstances, it would prevent the installation of dependencies into a project.
diff --git a/changes/2326.feature.rst b/changes/2326.feature.rst
deleted file mode 100644
index 0519efcd..00000000
--- a/changes/2326.feature.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Briefcase can now be used to sign, package and notarize apps that Briefcase didn't create. You can now use another tool to create a macOS, Windows or Linux application, and then use Briefcase to complete the process of turning that app into a signed and notarized release artefact.
diff --git a/changes/2327.misc.rst b/changes/2327.misc.rst
deleted file mode 100644
index 36236f86..00000000
--- a/changes/2327.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Changed `test_mode` to an app property
diff --git a/changes/2330.misc.rst b/changes/2330.misc.rst
deleted file mode 100644
index 0ac70ca9..00000000
--- a/changes/2330.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-The ``pre-commit`` hook for ``ruff-pre-commit`` was updated to its latest version.
diff --git a/changes/2331.misc.rst b/changes/2331.misc.rst
deleted file mode 100644
index d96f4b0b..00000000
--- a/changes/2331.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Updated pytest from 8.3.5 to 8.4.0.
diff --git a/changes/2335.doc.rst b/changes/2335.doc.rst
deleted file mode 100644
index 6fa59cbe..00000000
--- a/changes/2335.doc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Restructured documentation to update ToC order and include Topic guides.
diff --git a/changes/2339.misc.rst b/changes/2339.misc.rst
deleted file mode 100644
index dd5c6ce8..00000000
--- a/changes/2339.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Updated coverage[toml] from 7.8.2 to 7.9.1.
diff --git a/changes/2341.doc.rst b/changes/2341.doc.rst
deleted file mode 100644
index d351d53d..00000000
--- a/changes/2341.doc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-References to Briefcase's settings are now hyperlinked for ease of cross-referencing.
diff --git a/changes/2343.misc.2.rst b/changes/2343.misc.2.rst
deleted file mode 100644
index 3d18ca78..00000000
--- a/changes/2343.misc.2.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-On Linux, fixed the grammar of the message pertaining to a missing changelog file.
diff --git a/changes/2343.misc.rst b/changes/2343.misc.rst
deleted file mode 100644
index 714fb93b..00000000
--- a/changes/2343.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Fix outdated link in the release notes.
diff --git a/changes/2345.misc.rst b/changes/2345.misc.rst
deleted file mode 100644
index 0ac70ca9..00000000
--- a/changes/2345.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-The ``pre-commit`` hook for ``ruff-pre-commit`` was updated to its latest version.
diff --git a/changes/2346.misc.rst b/changes/2346.misc.rst
deleted file mode 100644
index 5866a4f3..00000000
--- a/changes/2346.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Updated pytest from 8.4.0 to 8.4.1.
diff --git a/changes/2347.misc.rst b/changes/2347.misc.rst
deleted file mode 100644
index 2a46a5df..00000000
--- a/changes/2347.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Updated tox from 4.26.0 to 4.27.0.
diff --git a/changes/2349.misc.rst b/changes/2349.misc.rst
deleted file mode 100644
index 8c02eaa4..00000000
--- a/changes/2349.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-``tox`` command now runs all tests, and ``tox test`` runs on the current version of Python.
diff --git a/changes/2358.doc.rst b/changes/2358.doc.rst
deleted file mode 100644
index d5307247..00000000
--- a/changes/2358.doc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Towncrier release notes updated to About, and Topic guides now in "topics" directory.
diff --git a/changes/2359.doc.rst b/changes/2359.doc.rst
deleted file mode 100644
index 79f4b966..00000000
--- a/changes/2359.doc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Add link to Easy Media Server project which was built using Toga and Briefcase
diff --git a/changes/2361.misc.rst b/changes/2361.misc.rst
deleted file mode 100644
index 411b7064..00000000
--- a/changes/2361.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Updated ncipollo/release-action from 1.16.0 to 1.17.0.
diff --git a/changes/2362.misc.rst b/changes/2362.misc.rst
deleted file mode 100644
index 0ac70ca9..00000000
--- a/changes/2362.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-The ``pre-commit`` hook for ``ruff-pre-commit`` was updated to its latest version.
diff --git a/changes/2367.doc.rst b/changes/2367.doc.rst
deleted file mode 100644
index 29e3ce28..00000000
--- a/changes/2367.doc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-``venv`` commands for macOS and Linux now use ``python3` explicitly.
diff --git a/changes/2369.feature.rst b/changes/2369.feature.rst
new file mode 100644
index 00000000..43149ac6
--- /dev/null
+++ b/changes/2369.feature.rst
@@ -0,0 +1,1 @@
+Add ``--forward-port`` and ``--reverse-port`` via ADB when running an Android app
diff --git a/docs/about/releases.rst b/docs/about/releases.rst
index 39564f1b..938d2686 100644
--- a/docs/about/releases.rst
+++ b/docs/about/releases.rst
@@ -4,6 +4,45 @@ Release History
 
 .. towncrier release notes start
 
+0.3.24 (2025-07-10)
+===================
+
+Features
+--------
+
+* When an iOS or macOS binary wheel cannot be found, the error message now provides more useful hints regarding the possible cause. (`#2230 <https://github.com/beeware/briefcase/issues/2230>`__)
+* Document type and file associations have been improved on macOS. (`#2284 <https://github.com/beeware/briefcase/issues/2284>`__)
+* Briefcase can now be used to sign, package and notarize apps that Briefcase didn't create. You can now use another tool to create a macOS, Windows or Linux application, and then use Briefcase to complete the process of turning that app into a signed and notarized release artefact. (`#2326 <https://github.com/beeware/briefcase/issues/2326>`__)
+
+Bugfixes
+--------
+
+* A redundant signing phase has been removed when packaging an apps for macOS. (`#1099 <https://github.com/beeware/briefcase/issues/1099>`__)
+* An error is now raised on macOS if Briefcase is used on an iCloud mounted drive. iCloud synchronization adds metadata to some folders, which is incompatible with the signing process. (`#1808 <https://github.com/beeware/briefcase/issues/1808>`__)
+* An app's formal name can now include a ``.`` character. (`#2151 <https://github.com/beeware/briefcase/issues/2151>`__)
+* Distribution-specific cumulative settings (such as ``requires``) now correctly accumulate, rather than overwriting more general values. (`#2188 <https://github.com/beeware/briefcase/issues/2188>`__)
+* Briefcase will now default to using the system certificate store when performing HTTPS downloads. (`#2296 <https://github.com/beeware/briefcase/issues/2296>`__)
+* Briefcase now provides richer error information when it is unable to complete a download. In particular, issues associated with SSL verification are now reported as such, rather than as a generic "are you offline?" message. (`#2296 <https://github.com/beeware/briefcase/issues/2296>`__)
+* The presence of a ``PIP_REQUIRE_VIRTUALENV`` environment marker no longer influences the operation of dependency installation on iOS. The iOS installation environments is inherently isolated, so this flag is redundant; but in some circumstances, it would prevent the installation of dependencies into a project. (`#2325 <https://github.com/beeware/briefcase/issues/2325>`__)
+
+Backward Incompatible Changes
+-----------------------------
+
+* Briefcase now uses WiX version 5.0.2 to generate MSI installers on Windows. Any Windows apps created with previous versions of Briefcase will need to be re-generated by running ``briefcase create``. (`#1185 <https://github.com/beeware/briefcase/issues/1185>`__)
+
+Documentation
+-------------
+
+* Documentation about deploying to physical iOS devices has been added. (`#1190 <https://github.com/beeware/briefcase/issues/1190>`__)
+* References to Briefcase's settings are now hyperlinked for ease of cross-referencing. (`#2341 <https://github.com/beeware/briefcase/issues/2341>`__)
+* A guide for publishing apps to the macOS App Store has been added. (`#2360 <https://github.com/beeware/briefcase/issues/2360>`__)
+* Briefcase's documentation now uses a header and style consistent with the BeeWare website. (`#2375 <https://github.com/beeware/briefcase/issues/2375>`__)
+
+Misc
+----
+
+* `#1669 <https://github.com/beeware/briefcase/issues/1669>`__, `#1696 <https://github.com/beeware/briefcase/issues/1696>`__, `#2280 <https://github.com/beeware/briefcase/issues/2280>`__, `#2281 <https://github.com/beeware/briefcase/issues/2281>`__, `#2287 <https://github.com/beeware/briefcase/issues/2287>`__, `#2288 <https://github.com/beeware/briefcase/issues/2288>`__, `#2289 <https://github.com/beeware/briefcase/issues/2289>`__, `#2291 <https://github.com/beeware/briefcase/issues/2291>`__, `#2292 <https://github.com/beeware/briefcase/issues/2292>`__, `#2295 <https://github.com/beeware/briefcase/issues/2295>`__, `#2310 <https://github.com/beeware/briefcase/issues/2310>`__, `#2311 <https://github.com/beeware/briefcase/issues/2311>`__, `#2312 <https://github.com/beeware/briefcase/issues/2312>`__, `#2313 <https://github.com/beeware/briefcase/issues/2313>`__, `#2319 <https://github.com/beeware/briefcase/issues/2319>`__, `#2320 <https://github.com/beeware/briefcase/issues/2320>`__, `#2321 <https://github.com/beeware/briefcase/issues/2321>`__, `#2322 <https://github.com/beeware/briefcase/issues/2322>`__, `#2323 <https://github.com/beeware/briefcase/issues/2323>`__, `#2324 <https://github.com/beeware/briefcase/issues/2324>`__, `#2327 <https://github.com/beeware/briefcase/issues/2327>`__, `#2330 <https://github.com/beeware/briefcase/issues/2330>`__, `#2331 <https://github.com/beeware/briefcase/issues/2331>`__, `#2335 <https://github.com/beeware/briefcase/issues/2335>`__, `#2339 <https://github.com/beeware/briefcase/issues/2339>`__, `#2343 <https://github.com/beeware/briefcase/issues/2343>`__, `#2343 <https://github.com/beeware/briefcase/issues/2343>`__, `#2345 <https://github.com/beeware/briefcase/issues/2345>`__, `#2346 <https://github.com/beeware/briefcase/issues/2346>`__, `#2347 <https://github.com/beeware/briefcase/issues/2347>`__, `#2349 <https://github.com/beeware/briefcase/issues/2349>`__, `#2358 <https://github.com/beeware/briefcase/issues/2358>`__, `#2359 <https://github.com/beeware/briefcase/issues/2359>`__, `#2361 <https://github.com/beeware/briefcase/issues/2361>`__, `#2362 <https://github.com/beeware/briefcase/issues/2362>`__, `#2367 <https://github.com/beeware/briefcase/issues/2367>`__, `#2371 <https://github.com/beeware/briefcase/issues/2371>`__, `#2372 <https://github.com/beeware/briefcase/issues/2372>`__, `#2373 <https://github.com/beeware/briefcase/issues/2373>`__, `#2374 <https://github.com/beeware/briefcase/issues/2374>`__, `#2380 <https://github.com/beeware/briefcase/issues/2380>`__, `#2385 <https://github.com/beeware/briefcase/issues/2385>`__, `#2386 <https://github.com/beeware/briefcase/issues/2386>`__
+
 0.3.23 (2025-05-08)
 ===================
 
diff --git a/docs/conf.py b/docs/conf.py
index baabf8c7..41dbcf28 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -13,6 +13,24 @@ import os
 import sys
 from importlib.metadata import version as metadata_version
 
+import beeware_theme
+
+# BeeWare theme override for Furo Sphinx theme to add BeeWare features.
+templates_path = []
+html_static_path = []
+html_css_files = []
+html_context = {}
+html_theme_options = {}
+
+beeware_theme.init(
+    project_name="briefcase",
+    templates=templates_path,
+    context=html_context,
+    static=html_static_path,
+    css=html_css_files,
+    theme_options=html_theme_options,
+)
+
 # If extensions (or modules to document with autodoc) are in another directory,
 # add these directories to sys.path here. If the directory is relative to the
 # documentation root, use os.path.abspath to make it absolute, like shown here.
@@ -35,7 +53,7 @@ extensions = [
 ]
 
 # Add any paths that contain templates here, relative to this directory.
-templates_path = ["_templates"]
+# templates_path = ["_templates"]
 
 # The suffix of source filenames.
 source_suffix = ".rst"
@@ -196,7 +214,7 @@ html_logo = "_static/images/briefcase.png"
 # Add any paths that contain custom static files (such as style sheets) here,
 # relative to this directory. They are copied after the builtin static files,
 # so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ["_static"]
+html_static_path.append("_static")
 
 # If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
 # using the given strftime format.
diff --git a/docs/how-to/publishing/index.rst b/docs/how-to/publishing/index.rst
index 39a05857..09236aa8 100644
--- a/docs/how-to/publishing/index.rst
+++ b/docs/how-to/publishing/index.rst
@@ -10,3 +10,4 @@ covers how to publish your app to the appropriate distribution system.
 
    android
    iOS
+   macOS
diff --git a/docs/how-to/publishing/macOS.rst b/docs/how-to/publishing/macOS.rst
new file mode 100644
index 00000000..fcc5ba01
--- /dev/null
+++ b/docs/how-to/publishing/macOS.rst
@@ -0,0 +1,128 @@
+=====
+macOS
+=====
+
+This guide will walk you through the process of publishing a macOS app to the Apple App
+Store.
+
+.. admonition: App Stores are a moving target
+
+    The Apple App Store makes frequent changes to the workflows and nomenclature
+    associated with publishing apps. As a result, it's very difficult to keep a guide like
+    this one up to date. If you spot any problems, `let us know
+    <https://github.com/beeware/briefcase/issues/new?assignees=&labels=bug,documentation,apple&projects=&template=bug_report.yml>`__.
+
+To distribute an app on the macOS App Store, you'll need to :ref:`enroll in the Apple
+Developer Program <apple-dev-enroll>`. You don't need to generate any of the
+certificates described on that page - you just need an Apple ID registered in the
+developer program.
+
+Once you've signed up for an Apple ID account, open the Xcode Settings dialog, and
+add your account under the "Accounts" tab.
+
+Apps are submitted to the macOS App Store using Xcode. This requires that you use
+Briefcase's :doc:`macOS Xcode project packaging format
+</reference/platforms/macOS/xcode>` to build your app. To specify this format, add
+``macOS Xcode`` to any Briefcase app command you want to invoke - for example:
+
+.. code-block:: console
+
+    (venv) $ briefcase create macOS Xcode
+    (venv) $ briefcase run macOS Xcode
+
+Open the app in Xcode
+=====================
+
+To submit your app to the App Store, use Briefcase to open the Xcode project associated
+with your project.
+
+.. code-block:: console
+
+    (venv) $ briefcase open macOS Xcode
+
+Run the app via Xcode
+=====================
+
+Run the app in Xcode to ensure it builds correctly and works as expected.
+
+In order to submit your app to the App Store, you will need to provide at least one
+screenshot, in one of the following sizes:
+
+* 1280 x 800
+* 1440 x 900
+* 2560 x 1600
+* 2880 x 1800
+
+To capture a screenshot of a specific application window on macOS, use the key
+combination Shift-Command-4, then press the Space bar. The cursor will change to a
+camera icon. Hover over the desired window and click to capture it as an image.
+
+Produce an App archive
+======================
+
+Select the root node of the Xcode project browser (it should be the formal name of your
+app), then select the **Signing & Capabilities** tab from configuration options that are
+displayed. The "Team" option under "Signing" will be listed as "None"; select the name
+of the development team that will sign the app. If there's no team listed, select "Add
+an Account", and choose one of the teams that is associated with your Apple ID.
+
+In the top bar of the Xcode window, build an archive by selecting "Archive" from the
+Product menu.
+
+This will perform a clean build of your application, build an archive, and open a new
+window, called the Organizer. It should list a freshly created archive of your app, with
+the current version number.
+
+Select the archive, and click the "Distribute App" on the right side of the Organizer
+window. This will display a wizard that will ask details about your app; accept the
+default values; once the wizard completes, your app binary has been sent to the App
+Store for inclusion in a release.
+
+After a few minutes, you should receive an email notifying you that the binary has been
+processed.
+
+Create an App Store entry
+=========================
+
+Log into `App Store Connect <https://appstoreconnect.apple.com>`__, click on "My Apps",
+then on + to add an app.
+
+Fill out the form for a new app. If you've run the app in Xcode, the Bundle ID for your
+app should be listed; select it from the list. You must also create an SKU for your app
+- we suggest ``macos-<appname>``, substituting the short app name that you selected when
+you initially created your app. For example, if you've created an app with a formal name
+of "Hello World", with an app name of ``helloworld``, and a bundle of ``org.beeware``,
+you should have a Bundle ID of ``org.beeware.helloworld``; we'd suggest an SKU of
+``macos-helloworld``.
+
+You'll then be shown another page for app details, including:
+
+* Primary and Secondary Category
+* Screenshots
+* Promotional Text
+* Description
+* Keywords
+* Support URL
+* Marketing URL
+* A URL for your app's privacy policy
+* Version number
+* The name of the copyright holder
+
+Under the "Build" section, you'll be able to select the archive that you uploaded
+through Xcode.
+
+The "App Review Information" section allows you to provide contact details in case Apple
+has questions during the review process. If your app requires a login, you *must*
+provide a set of credentials so that Apple can log in. You can also provide any
+additional notes to assist the reviewer.
+
+Click on "Pricing and Availability" tab on the sidebar, and set up the pricing schedule
+and availability for your app.
+
+Then, click on "App Privacy", and click on "Get Started"; this will ask you a series of
+questions about the information about users that your app collects.
+
+Once these details have all been provided, click on the "1.0 Prepare for Submission" link
+in the sidebar. On the right of the screen, click on "Add for Review"; this will ask some
+final questions, and provide one more button "Submit for Review". Click that button, and
+you're done!
diff --git a/docs/reference/commands/upgrade.rst b/docs/reference/commands/upgrade.rst
index fbe28ae9..c642b608 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 061c5976..31987c3a 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/android/gradle.rst b/docs/reference/platforms/android/gradle.rst
index 43d63b84..ca97030f 100644
--- a/docs/reference/platforms/android/gradle.rst
+++ b/docs/reference/platforms/android/gradle.rst
@@ -159,7 +159,7 @@ You may specify multiple ``--Xemulator`` arguments; each one specifies a
 single argument to pass to the emulator, in the order they are specified.
 
 ``--shutdown-on-exit``
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+~~~~~~~~~~~~~~~~~~~~~~
 
 Instruct Briefcase to shut down the emulator when the run finishes. This is
 especially useful if you are running in headless mode, as the emulator will
@@ -167,6 +167,27 @@ continue to run in the background, but there will be no visual manifestation
 that it is running. It may also be useful as a cleanup mechanism when running
 in a CI configuration.
 
+``--forward-port=<port>``
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Forward a port via ADB from the host to the Android device. This is useful when
+a network service is running on the Android app that you want to connect to from
+the host.
+
+You may specify multiple ``--forward-port`` arguments; each one specifies a
+single port.
+
+``--reverse-port=<port>``
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Reverse a port via ADB from the Android device to the host. This is useful when
+a network service is running on the host that you want to connect to from the
+Android app.
+
+You may specify multiple ``--reverse-port`` arguments; each one specifies a
+single port.
+
+
 Application configuration
 =========================
 
diff --git a/docs/reference/platforms/windows/index.rst b/docs/reference/platforms/windows/index.rst
index 6738d361..71f0b46a 100644
--- a/docs/reference/platforms/windows/index.rst
+++ b/docs/reference/platforms/windows/index.rst
@@ -47,13 +47,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/pyproject.toml b/pyproject.toml
index 8ef4517c..df1cb659 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -99,17 +99,22 @@ dependencies = [
 # Extras used by developers *of* briefcase are pinned to specific versions to
 # ensure environment consistency.
 dev = [
-    "coverage[toml] == 7.9.1",
+    "coverage[toml] == 7.9.2",
     "coverage-conditional-plugin == 0.9.0",
     "pre-commit == 4.2.0",
     "pytest == 8.4.1",
-    "pytest-xdist == 3.7.0",
+    "pytest-xdist == 3.8.0",
     "setuptools_scm == 8.3.1",
     "tox == 4.27.0",
 ]
 # Docs are always built on a specific Python version; see RTD and tox config files,
 # and the docs contribution guide.
 docs = [
+    # Docs requirements are *mostly* handled by the `docs` extra; but we can't include
+    # the theme that way, so the theme is installed using a requirements.txt file,
+    # independent of the docs extra. Ideally, we'd use dependency groups for docs
+    # dependencies, but RTD doesn't support them yet. In addition to these dependencies,
+    # you should also run `pip install -r ../requirements-docs.txt`
     "furo == 2024.8.6",
     "pyenchant == 3.2.2",
     "sphinx == 8.2.3",
@@ -237,11 +242,14 @@ extend-select = [
     "F",      # pyflakes
     "UP",     # pyupgrade
     "B",      # flake8-bugbear
-    "SIM",    # flake8-simplify
     "YTT",    # flake8-2020
     "ASYNC",  # flake8-async
     "C4",     # flake8-comprehensions
     "I",      # isort
+    # The SIM rules are *very* opinionated, and don't necessarily make for better code.
+    # They may be worth occasionally turning on just to see if something could actually
+    # use improvement.
+    # "SIM",    # flake8-simplify
 ]
 
 # Below is a list of ignored rules.  Many (if not all) of these have been disabled
@@ -256,16 +264,6 @@ ignore = [
     "B024",    # abstract base class without abstract methods / properties
     "B027",    # empty method in abstract base class
     "B904",    # use raise ... from ...
-    "SIM102",  # combine if statements
-    "SIM103",  # return condition directly
-    "SIM105",  # use contextlib.suppress() instead of try-except-pass
-    "SIM108",  # use ternary operators
-    "SIM115",  # use context manager for opening files
-    "SIM117",  # combine with statements
-    "SIM118",  # use "key in dict" instead of "key in dict.keys()"
-    "SIM201",  # use "x != y" instead of "not x == y"
-    "SIM300",  # Yoda condition (0 != x)
-    "SIM905",  # use list literal instead of str.split()
     "C400",    # unnecessary generator
     "C408",    # use dict literal instead of dict()
     "C414",    # unnecessary list() use within sorted()
diff --git a/requirements-docs.txt b/requirements-docs.txt
new file mode 100644
index 00000000..6e09bb8d
--- /dev/null
+++ b/requirements-docs.txt
@@ -0,0 +1,1 @@
+beeware_theme @ git+https://github.com/beeware/beeware-theme
diff --git a/src/briefcase/config.py b/src/briefcase/config.py
index 7659880b..6970e791 100644
--- a/src/briefcase/config.py
+++ b/src/briefcase/config.py
@@ -279,6 +279,9 @@ class BaseConfig:
         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/android_sdk.py b/src/briefcase/integrations/android_sdk.py
index e0ea3fe4..5d894243 100644
--- a/src/briefcase/integrations/android_sdk.py
+++ b/src/briefcase/integrations/android_sdk.py
@@ -1628,6 +1628,86 @@ Activity class not found while starting app.
         except subprocess.CalledProcessError as e:
             raise BriefcaseCommandError("Error starting ADB logcat.") from e
 
+    def forward(self, host_port: int, device_port: int):
+        """Use the forward command to set up arbitrary port forwarding, which
+        forwards requests on a specific host port to a different port on a device.
+
+        :param host_port: The port on the host that should be forwarded to the device
+        :param device_port: The port on the device
+        """
+        try:
+            self.tools.subprocess.check_output(
+                [
+                    self.tools.android_sdk.adb_path,
+                    "-s",
+                    self.device,
+                    "forward",
+                    f"tcp:{host_port}",
+                    f"tcp:{device_port}",
+                ],
+            )
+        except subprocess.CalledProcessError as e:
+            raise BriefcaseCommandError("Error starting 'adb forward'.") from e
+
+    def forward_remove(self, host_port: int):
+        """Remove forwarded port.
+
+        :param host_port: The port on the host that should be removed
+        """
+        try:
+            self.tools.subprocess.check_output(
+                [
+                    self.tools.android_sdk.adb_path,
+                    "-s",
+                    self.device,
+                    "forward",
+                    "--remove",
+                    f"tcp:{host_port}",
+                ],
+            )
+        except subprocess.CalledProcessError as e:
+            raise BriefcaseCommandError("Error starting 'adb forward --remove'.") from e
+
+    def reverse(self, device_port: int, host_port: int):
+        """Use the reverse command to set up arbitrary port forwarding, which
+        forwards requests on a specific device port to a different port on the host.
+
+        :param device_port: The port on the device that should be forwarded to the host
+        :param host_port: The port on the host
+        """
+        try:
+            self.tools.subprocess.check_output(
+                [
+                    self.tools.android_sdk.adb_path,
+                    "-s",
+                    self.device,
+                    "reverse",
+                    f"tcp:{device_port}",
+                    f"tcp:{host_port}",
+                ],
+            )
+        except subprocess.CalledProcessError as e:
+            raise BriefcaseCommandError("Error starting 'adb reverse'.") from e
+
+    def reverse_remove(self, device_port: int):
+        """Remove reversed port.
+
+        :param device_port: The port on the device that should be removed
+        """
+        try:
+            self.tools.subprocess.check_output(
+                [
+                    self.tools.android_sdk.adb_path,
+                    "-s",
+                    self.device,
+                    "reverse",
+                    "--remove",
+                    f"tcp:{device_port}",
+                ],
+            )
+        except subprocess.CalledProcessError as e:
+            raise BriefcaseCommandError("Error starting 'adb reverse --remove'.") from e
+
     def pidof(self, package: str, **kwargs) -> str | None:
         """Obtain the PID of a running app by package name.
 
diff --git a/src/briefcase/integrations/wix.py b/src/briefcase/integrations/wix.py
index 91a5a287..c903b045 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 @@ class WiX(ManagedTool):
         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 @@ does not point to an install of the WiX Toolset.
         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 @@ does not point to an install of the WiX Toolset.
 
         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/android/gradle.py b/src/briefcase/platforms/android/gradle.py
index d32f30a1..cc34b8fb 100644
--- a/src/briefcase/platforms/android/gradle.py
+++ b/src/briefcase/platforms/android/gradle.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import contextlib
 import datetime
 import re
 import subprocess
@@ -18,7 +19,7 @@ from briefcase.commands import (
 from briefcase.config import AppConfig, parsed_version
 from briefcase.console import ANSI_ESC_SEQ_RE_DEF
 from briefcase.exceptions import BriefcaseCommandError
-from briefcase.integrations.android_sdk import AndroidSDK
+from briefcase.integrations.android_sdk import ADB, AndroidSDK
 from briefcase.integrations.subprocess import SubprocessArgT
 
 
@@ -358,6 +359,20 @@ class GradleRunCommand(GradleMixin, RunCommand):
             help="Shutdown the emulator on exit",
             required=False,
         )
+        parser.add_argument(
+            "--forward-port",
+            action="append",
+            dest="forward_ports",
+            type=int,
+            help="Forward the specified port from host to device.",
+        )
+        parser.add_argument(
+            "--reverse-port",
+            action="append",
+            dest="reverse_ports",
+            type=int,
+            help="Reverse the specified port from device to host.",
+        )
 
     def run_app(
         self,
@@ -366,6 +381,8 @@ class GradleRunCommand(GradleMixin, RunCommand):
         device_or_avd=None,
         extra_emulator_args=None,
         shutdown_on_exit=False,
+        forward_ports: list[int] | None = None,
+        reverse_ports: list[int] | None = None,
         **kwargs,
     ):
         """Start the application.
@@ -376,6 +393,8 @@ class GradleRunCommand(GradleMixin, RunCommand):
             be asked to re-run the command selecting a specific device.
         :param extra_emulator_args: Any additional arguments to pass to the emulator.
         :param shutdown_on_exit: Should the emulator be shut down on exit?
+        :param forward_ports: A list of ports to forward for the app.
+        :param reverse_ports: A list of ports to reversed for the app.
         """
         device, name, avd = self.tools.android_sdk.select_target_device(device_or_avd)
 
@@ -424,55 +443,89 @@ class GradleRunCommand(GradleMixin, RunCommand):
             with self.console.wait_bar("Installing new app version..."):
                 adb.install_apk(self.binary_path(app))
 
-            # To start the app, we launch `org.beeware.android.MainActivity`.
-            with self.console.wait_bar(f"Launching {label}..."):
-                # capture the earliest time for device logging in case PID not found
-                device_start_time = adb.datetime()
-
-                adb.start_app(package, "org.beeware.android.MainActivity", passthrough)
-
-                # Try to get the PID for 5 seconds.
-                pid = None
-                fail_time = datetime.datetime.now() + datetime.timedelta(seconds=5)
-                while not pid and datetime.datetime.now() < fail_time:
-                    # Try to get the PID; run in quiet mode because we may
-                    # need to do this a lot in the next 5 seconds.
-                    pid = adb.pidof(package, quiet=2)
-                    if not pid:
-                        time.sleep(0.01)
-
-            if pid:
-                self.console.info(
-                    "Following device log output (type CTRL-C to stop log)...",
-                    prefix=app.app_name,
-                )
-                # Start adb's logcat in a way that lets us stream the logs
-                log_popen = adb.logcat(pid=pid)
-
-                # Stream the app logs.
-                self._stream_app_logs(
-                    app,
-                    popen=log_popen,
-                    clean_filter=android_log_clean_filter,
-                    clean_output=False,
-                    # Check for the PID in quiet mode so logs aren't corrupted.
-                    stop_func=lambda: not adb.pid_exists(pid=pid, quiet=2),
-                    log_stream=True,
-                )
-            else:
-                self.console.error("Unable to find PID for app", prefix=app.app_name)
-                self.console.error("Logs for launch attempt follow...")
-                self.console.error("=" * 75)
-
-                # Show the log from the start time of the app
-                adb.logcat_tail(since=device_start_time)
+            forward_ports = forward_ports or []
+            reverse_ports = reverse_ports or []
+
+            # Forward/Reverse requested ports
+            with self.forward_ports(adb, forward_ports, reverse_ports):
+                # To start the app, we launch `org.beeware.android.MainActivity`.
+                with self.console.wait_bar(f"Launching {label}..."):
+                    # capture the earliest time for device logging in case PID not found
+                    device_start_time = adb.datetime()
+
+                    adb.start_app(
+                        package, "org.beeware.android.MainActivity", passthrough
+                    )
+
+                    # Try to get the PID for 5 seconds.
+                    pid = None
+                    fail_time = datetime.datetime.now() + datetime.timedelta(seconds=5)
+                    while not pid and datetime.datetime.now() < fail_time:
+                        # Try to get the PID; run in quiet mode because we may
+                        # need to do this a lot in the next 5 seconds.
+                        pid = adb.pidof(package, quiet=2)
+                        if not pid:
+                            time.sleep(0.01)
+
+                if pid:
+                    self.console.info(
+                        "Following device log output (type CTRL-C to stop log)...",
+                        prefix=app.app_name,
+                    )
+                    # Start adb's logcat in a way that lets us stream the logs
+                    log_popen = adb.logcat(pid=pid)
+
+                    # Stream the app logs.
+                    self._stream_app_logs(
+                        app,
+                        popen=log_popen,
+                        clean_filter=android_log_clean_filter,
+                        clean_output=False,
+                        # Check for the PID in quiet mode so logs aren't corrupted.
+                        stop_func=lambda: not adb.pid_exists(pid=pid, quiet=2),
+                        log_stream=True,
+                    )
+                else:
+                    self.console.error(
+                        "Unable to find PID for app", prefix=app.app_name
+                    )
+                    self.console.error("Logs for launch attempt follow...")
+                    self.console.error("=" * 75)
+
+                    # Show the log from the start time of the app
+                    adb.logcat_tail(since=device_start_time)
+
+                    raise BriefcaseCommandError(
+                        f"Problem starting app {app.app_name!r}"
+                    )
 
-                raise BriefcaseCommandError(f"Problem starting app {app.app_name!r}")
         finally:
             if shutdown_on_exit:
                 with self.tools.console.wait_bar("Stopping emulator..."):
                     adb.kill()
 
+    @contextlib.contextmanager
+    def forward_ports(
+        self, adb: ADB, forward_ports: list[int], reverse_ports: list[int]
+    ):
+        """Establish a port forwarding/reversion.
+
+        :param adb: The ADB wrapper for the device
+        :param forward_ports: Ports to forward via ADB
+        :param reverse_ports: Ports to reverse via ADB
+        """
+        for port in forward_ports:
+            adb.forward(port, port)
+        for port in reverse_ports:
+            adb.reverse(port, port)
+
+        yield
+
+        for port in forward_ports:
+            adb.forward_remove(port)
+        for port in reverse_ports:
+            adb.reverse_remove(port)
+
 
 class GradlePackageCommand(GradleMixin, PackageCommand):
     description = "Create an Android App Bundle and APK in release mode."
diff --git a/src/briefcase/platforms/windows/__init__.py b/src/briefcase/platforms/windows/__init__.py
index c0d821f7..53ec37f9 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 @@ class WindowsCreateCommand(CreateCommand):
             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 @@ class WindowsPackageCommand(PackageCommand):
 
     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."""
diff --git a/tox.ini b/tox.ini
index adcfe2cd..b7c974de 100644
--- a/tox.ini
+++ b/tox.ini
@@ -87,6 +87,12 @@ base_python = py312
 suicide_timeout = 1
 package = wheel
 wheel_build_env = .pkg
+deps =
+    # Docs requirements are *mostly* handled by the `docs` extra; but we can't include
+    # the theme that way, so the theme is installed using a requirements.txt file,
+    # independent of the docs extra. Ideally, we'd use dependency groups for docs
+    # dependencies, but RTD doesn't support them yet.
+    -r {tox_root}/requirements-docs.txt
 extras = docs
 passenv =
     # On macOS Apple Silicon, you need to manually set the location of the PyEnchant


Test patch:
diff --git a/tests/integrations/android_sdk/ADB/test_forward_reverse.py b/tests/integrations/android_sdk/ADB/test_forward_reverse.py
new file mode 100644
index 00000000..c2418a68
--- /dev/null
+++ b/tests/integrations/android_sdk/ADB/test_forward_reverse.py
@@ -0,0 +1,117 @@
+import subprocess
+
+import pytest
+
+from briefcase.exceptions import BriefcaseCommandError
+
+
+def test_forward(mock_tools, adb):
+    """A port forwarding"""
+    # Invoke forward
+    adb.forward(5555, 6666)
+
+    # Validate call parameters.
+    mock_tools.subprocess.check_output.assert_called_once_with(
+        [
+            mock_tools.android_sdk.adb_path,
+            "-s",
+            "exampleDevice",
+            "forward",
+            "tcp:5555",
+            "tcp:6666",
+        ],
+    )
+
+
+def test_forward_failure(adb, mock_tools):
+    """If port forwarding fails, the error is caught."""
+    # Mock out the run command on an adb instance
+    mock_tools.subprocess.check_output.side_effect = subprocess.CalledProcessError(
+        returncode=1, cmd=""
+    )
+    with pytest.raises(BriefcaseCommandError):
+        adb.forward(5555, 6666)
+
+
+def test_forward_remove(mock_tools, adb):
+    """A port forwarding removal."""
+    # Invoke forward remove
+    adb.forward_remove(5555)
+
+    # Validate call parameters.
+    mock_tools.subprocess.check_output.assert_called_once_with(
+        [
+            mock_tools.android_sdk.adb_path,
+            "-s",
+            "exampleDevice",
+            "forward",
+            "--remove",
+            "tcp:5555",
+        ],
+    )
+
+
+def test_forward_remove_failure(adb, mock_tools):
+    """If port forwarding removal fails, the error is caught."""
+    # Mock out the run command on an adb instance
+    mock_tools.subprocess.check_output.side_effect = subprocess.CalledProcessError(
+        returncode=1, cmd=""
+    )
+    with pytest.raises(BriefcaseCommandError):
+        adb.forward_remove(5555)
+
+
+def test_reverse(mock_tools, adb):
+    """A port reversing."""
+    # Invoke reverse
+    adb.reverse(5555, 6666)
+
+    # Validate call parameters.
+    mock_tools.subprocess.check_output.assert_called_once_with(
+        [
+            mock_tools.android_sdk.adb_path,
+            "-s",
+            "exampleDevice",
+            "reverse",
+            "tcp:5555",
+            "tcp:6666",
+        ],
+    )
+
+
+def test_reverse_failure(adb, mock_tools):
+    """If port reversing fails, the error is caught."""
+    # Mock out the run command on an adb instance
+    mock_tools.subprocess.check_output.side_effect = subprocess.CalledProcessError(
+        returncode=1, cmd=""
+    )
+    with pytest.raises(BriefcaseCommandError):
+        adb.reverse(5555, 6666)
+
+
+def test_reverse_remove(mock_tools, adb):
+    """A port reversing removal."""
+    # Invoke reverse remove
+    adb.reverse_remove(5555)
+
+    # Validate call parameters.
+    mock_tools.subprocess.check_output.assert_called_once_with(
+        [
+            mock_tools.android_sdk.adb_path,
+            "-s",
+            "exampleDevice",
+            "reverse",
+            "--remove",
+            "tcp:5555",
+        ],
+    )
+
+
+def test_reverse_remove_failure(adb, mock_tools):
+    """If port reversing removal fails, the error is caught."""
+    # Mock out the run command on an adb instance
+    mock_tools.subprocess.check_output.side_effect = subprocess.CalledProcessError(
+        returncode=1, cmd=""
+    )
+    with pytest.raises(BriefcaseCommandError):
+        adb.reverse_remove(5555)
diff --git a/tests/integrations/wix/conftest.py b/tests/integrations/wix/conftest.py
index f1feb2b3..a3f2de86 100644
--- a/tests/integrations/wix/conftest.py
+++ b/tests/integrations/wix/conftest.py
@@ -7,7 +7,14 @@ from briefcase.integrations.base import ToolCache
 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 51d7b929..5281cf30 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 231461ab..9c912de1 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.exceptions import (
 )
 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/android/gradle/test_run.py b/tests/platforms/android/gradle/test_run.py
index 2f6fb648..c6aacea5 100644
--- a/tests/platforms/android/gradle/test_run.py
+++ b/tests/platforms/android/gradle/test_run.py
@@ -93,6 +93,8 @@ def test_device_option(run_command):
         "passthrough": [],
         "extra_emulator_args": None,
         "shutdown_on_exit": False,
+        "forward_ports": None,
+        "reverse_ports": None,
     }
     assert overrides == {}
 
@@ -116,6 +118,8 @@ def test_extra_emulator_args_option(run_command):
         "passthrough": [],
         "extra_emulator_args": ["-no-window", "-no-audio"],
         "shutdown_on_exit": False,
+        "forward_ports": None,
+        "reverse_ports": None,
     }
     assert overrides == {}
 
@@ -137,6 +141,58 @@ def test_shutdown_on_exit_option(run_command):
         "passthrough": [],
         "extra_emulator_args": None,
         "shutdown_on_exit": True,
+        "forward_ports": None,
+        "reverse_ports": None,
+    }
+    assert overrides == {}
+
+
+def test_forward_ports_option(run_command):
+    """The --forward-port option can be parsed."""
+    options, overrides = run_command.parse_options(
+        ["--forward-port", "80", "--forward-port", "81"]
+    )
+
+    assert options == {
+        "device_or_avd": None,
+        "appname": None,
+        "update": False,
+        "update_requirements": False,
+        "update_resources": False,
+        "update_support": False,
+        "update_stub": False,
+        "no_update": False,
+        "test_mode": False,
+        "passthrough": [],
+        "extra_emulator_args": None,
+        "shutdown_on_exit": False,
+        "forward_ports": [80, 81],
+        "reverse_ports": None,
+    }
+    assert overrides == {}
+
+
+def test_reverse_ports_option(run_command):
+    """The --reverse-port option can be parsed."""
+    options, overrides = run_command.parse_options(
+        ["--reverse-port", "78", "--reverse-port", "79"]
+    )
+
+    assert options == {
+        "device_or_avd": None,
+        "appname": None,
+        "update": False,
+        "update_requirements": False,
+        "update_resources": False,
+        "update_support": False,
+        "update_stub": False,
+        "no_update": False,
+        "test_mode": False,
+        "passthrough": [],
+        "extra_emulator_args": None,
+        "shutdown_on_exit": False,
+        "forward_ports": None,
+        "reverse_ports": [78, 79],
     }
     assert overrides == {}
 
@@ -211,12 +267,18 @@ def test_run_existing_device(run_command, first_app_config):
         f"{first_app_config.package_name}.{first_app_config.module_name}",
     )
 
+    run_command.tools.mock_adb.forward.assert_not_called()
+    run_command.tools.mock_adb.reverse.assert_not_called()
+
     run_command.tools.mock_adb.start_app.assert_called_once_with(
         f"{first_app_config.package_name}.{first_app_config.module_name}",
         "org.beeware.android.MainActivity",
         [],
     )
 
+    run_command.tools.mock_adb.forward_remove.assert_not_called()
+    run_command.tools.mock_adb.reverse_remove.assert_not_called()
+
     run_command.tools.mock_adb.pidof.assert_called_once_with(
         f"{first_app_config.package_name}.{first_app_config.module_name}",
         quiet=2,
@@ -306,6 +368,46 @@ def test_run_with_passthrough(run_command, first_app_config):
     run_command.tools.mock_adb.kill.assert_not_called()
 
 
+def test_run_forward_reverse_ports(run_command, first_app_config):
+    """An app can be run with port forwarding and reversing."""
+    # Set up device selection to return a running physical device.
+    run_command.tools.android_sdk.select_target_device = mock.MagicMock(
+        return_value=("exampleDevice", "ExampleDevice", None)
+    )
+
+    # Invoke run_app with args.
+    run_command.run_app(
+        first_app_config,
+        passthrough=[],
+        forward_ports=[80, 81],
+        reverse_ports=[78, 79],
+    )
+
+    assert run_command.tools.mock_adb.forward.mock_calls == [
+        mock.call(80, 80),
+        mock.call(81, 81),
+    ]
+    assert run_command.tools.mock_adb.reverse.mock_calls == [
+        mock.call(78, 78),
+        mock.call(79, 79),
+    ]
+
+    run_command.tools.mock_adb.start_app.assert_called_once_with(
+        f"{first_app_config.package_name}.{first_app_config.module_name}",
+        "org.beeware.android.MainActivity",
+        [],
+    )
+
+    assert run_command.tools.mock_adb.forward_remove.mock_calls == [
+        mock.call(80),
+        mock.call(81),
+    ]
+    assert run_command.tools.mock_adb.reverse_remove.mock_calls == [
+        mock.call(78),
+        mock.call(79),
+    ]
+
+
 def test_run_slow_start(run_command, first_app_config, monkeypatch):
     """If the app is slow to start, multiple calls to pidof will be made."""
     run_command.tools.android_sdk.select_target_device = mock.MagicMock(
diff --git a/tests/platforms/windows/app/conftest.py b/tests/platforms/windows/app/conftest.py
index 27f79c48..6cb6517e 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 4cbfff99..6d397069 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 750e24aa..a198120d 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 e7763974..78c5759d 100644
--- a/tests/platforms/windows/app/test_package.py
+++ b/tests/platforms/windows/app/test_package.py
@@ -12,6 +12,7 @@ from briefcase.integrations.windows_sdk import WindowsSDK
 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 edf30cbf..d7e1924a 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 00000000..6dace5f5
--- /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 3db8425f..ab880cf3 100644
--- a/tests/platforms/windows/visualstudio/test_package.py
+++ b/tests/platforms/windows/visualstudio/test_package.py
@@ -10,6 +10,8 @@ from briefcase.integrations.subprocess import Subprocess
 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",
+        ),
+    ]