Repository: beeware/briefcase
Pull request #1093 (2023-02-11 02:49:11)

Implement `Tool` base class
## Changes
- Implements `Tool` and `ManagedTool` base classes for all tools
- Implements a "tool registry" that contains all defined Tools
  - This is different than the `ToolCache` that contains verified Tool instances
- Promotes git and xcode to proper Tool classes
- Refactors `UpgradeCommand` to abstract out Tool implementation details
- Completes typing for the tools' interfaces

## Notes
- The motivation for the typing changes is to be helpful and provide better IDE autocomplete; not to pass `mypy`.
  - In fact, running `mypy` returns a torrent of issues...but it was very useful to verify my typing additions.
- Therefore, I ended up removing nearly all of the more "eccentric" changes that were purely for typing accuracy but added little to dev ergonomics.
- That said, feel free to call out any of the typing shenanigans you do not think tip the scales towards helpful.

## 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

rmartin16: @freakboy3742

Finalizing the `upgrade` command...at a decision point.

In order to blindly loop over all the tools to identify the ones that can be upgraded, each Tool needs to actually be verified. This presents challenges because 1) certain tools are only supported on certain platforms and 2) some require parameters for initialization that won't be available to the upgrade command.

I don't think I'm too keen to potentially over-engineer this and add a whole layer of "host verification" like we did for the Commands themselves. Although, this is an option.

Alternatively, I can leave it like I have it now....where errors in trying to verify a Tool are interpreted as "the tool cannot be upgraded". I'm not a big fan of this since it isn't being explicit at all...but it works.

Some middle ground perhaps? like a class-level flag on each Tool to signify whether a Tool _could_ qualify for being managed; if it's set `False`, just skip verification altogether.

Any thoughts/insight would be appreciated.
freakboy3742: > I don't think I'm too keen to potentially over-engineer this and add a whole layer of "host verification" like we did for the Commands themselves. Although, this is an option.

I agree - this isn't a feature that needs to be overly complicated.
 
> Alternatively, I can leave it like I have it now....where errors in trying to verify a Tool are interpreted as "the tool cannot be upgraded". I'm not a big fan of this since it isn't being explicit at all...but it works.
> 
> Some middle ground perhaps? like a class-level flag on each Tool to signify whether a Tool _could_ qualify for being managed; if it's set `False`, just skip verification (and any attempt to upgrade) altogether.

That was my first thought. A bare-bones check with a class attribute to perform a simple OS check (so you don't try to verify Xcode on Linux, or Linuxdeploy on macOS) avoids needless work; if it meets that criterion, try to upgrade, and report the problem if it didn't work.
rmartin16: > That was my first thought. A bare-bones check with a class attribute to perform a simple OS check (so you don't try to verify Xcode on Linux, or Linuxdeploy on macOS) avoids needless work; if it meets that criterion, try to upgrade, and report the problem if it didn't work.

Ok; a simple host OS check will be simple enough.

However, there are still other issues as long as introspecting whether a Tool is managed is gated behind verification. Some tools require a bit of contextual information to verify (e.g. `LinuxDeployURLPlugin`, `DockerAppContext`). That's why I'm thinking of implementing a class attribute for `can_be_managed` as hint that a Tool cannot ever be managed thereby avoiding the need to verify at all to only then see that `tool.managed_install is False`.
freakboy3742: > > That was my first thought. A bare-bones check with a class attribute to perform a simple OS check (so you don't try to verify Xcode on Linux, or Linuxdeploy on macOS) avoids needless work; if it meets that criterion, try to upgrade, and report the problem if it didn't work.
> 
> Ok; a simple host OS check will be simple enough.
> 
> However, there are still other issues as long as introspecting whether a Tool is managed is gated behind verification. Some tools require a bit of contextual information to verify (e.g. `LinuxDeployURLPlugin`, `DockerAppContext`). That's why I'm thinking of implementing a class attribute for `can_be_managed` as hint that a Tool cannot ever be managed thereby avoiding the need to verify at all to only then see that `tool.managed_install is False` (or to get an exception from verification because of missing information).

While there may be benefit for DockerAppContext to extend Tool, it's not a Tool in the sense of any of the others - it's a binding of Docker to an App. There's not really an "upgrade" process for these, unless you count purging/rebuilding the Docker image - but we don't have any way to track "all images that have been built", I think it's safe to put on the "can't be managed" list.

LinuxDeployURLPlugin is a weird one, because it's not a single plug-in. It's a wrapper allowing end-users to specify a plugin that is available at a URL. However, we don't have any specific details of that URL - we only have a unique hash. The same goes with LinuxDeployFilePlugin. 

I think the best we can do for LinuxDeployPlugins is to ensure that the plugins folder has been purged, so that the next time a plugin is requested, we are certain to have a clean download. 

Are there any other tools that are problematic?
rmartin16: > Are there any other tools that are problematic?

Just these since I implemented Host OS verification:
```
<class 'briefcase.integrations.linuxdeploy.LinuxDeployLocalFilePlugin'>
LinuxDeployLocalFilePlugin.__init__() missing 2 required positional arguments: 'plugin_path' and 'bundle_path'

<class 'briefcase.integrations.docker.DockerAppContext'>
DockerAppContext.verify() missing 6 required positional arguments: 'image_tag', 'dockerfile_path', 'app_base_path', 'host_bundle_path', 'host_data_path', and 'python_version'

<class 'briefcase.integrations.linuxdeploy.LinuxDeployURLPlugin'>
LinuxDeployURLPlugin.__init__() missing 1 required positional argument: 'url'
```

> While there may be benefit for DockerAppContext to extend Tool, it's not a Tool in the sense of any of the others 

This brings up something that's been periodically coming to mind: what is a tool exactly?

For instance, `ADB` is not currently treated like a first class tool....but I think there are arguments to do so.

I don't have fully formed thoughts on this yet...

> I think the best we can do for LinuxDeployPlugins is to ensure that the plugins folder has been purged, so that the next time a plugin is requested, we are certain to have a clean download.

hmm...at first thought, this starts to go outside of the scope of the Upgrade command...I'll have to think about it more...
rmartin16: Spent a little more time thinking about the more overarching ideas.

Please let me know your thoughts; I've pushed a prototype implementation of this.

## Delineating Managed Tools
I've created a dedicated `ManagedTool` subclass of `Tool` for tools that can be managed; I say "can" because WiX, Android SDK, etc. can be either managed or unmanaged per user. This provides the hint the Upgrade command needs to avoid complications with Tools that cannot be easily verified and are always unmanaged anyway.

## What is a tool?
The more relevant pragmatic question is simply why subclass `Tool` or `ManagedTool`:
- Is added to Tool registry
- Implements Tool interface
  - Verification
  - Installation management

The Tool registry is immediately applicable to the Upgrade command (obviously) to iterate the tools; but anything that would need to arbitrarily to be considered a part of Briefcase's toolset should be a `Tool`. For instance, a hypothetical future Briefcase Command to verify the readiness of the host system for running an arbitrary Briefcase command.

If something needs to implement its own verification before it can be used or if its install needs to be managed, it should be a `Tool`.

So, with that in mind, something like `ADB` can reasonably be considered _not_ a `Tool` for Briefcase's purposes. It is provided as part of `AndroidSDK` and piggybacks on that SDK's verification and install management. As such, being a part of the Tool registry would be redundant (and probably problematic).

This categorization may present some ideological inconsistencies, though. The `Download`, `NativeAppContext`, and `Subprocess` tools would not actually be considered a `Tool` under this definition. They do not need the Tool interface and probably don't need to be a part of the Tool registry any more than anything else in the `stdlib`. Pragmatically, though, I think it's fine...

## `DockerAppContext`
In some ways, `DockerAppContext` is similar to `ADB` in that verification of `Docker` _should_ confirm `DockerAppContext` will work. However, as you point out, `DockerAppContext` is really about managing Docker images...and that's why it requires its own verification (but doesn't require any installation management...unless perhaps support for image management is added). So, I think its status as a `Tool` is appropriate.

## linuxdeploy Plugins
The status quo for linuxdeploy plugins is they are not as well integrated; for instance, the Upgrade command does not work for upgrading the GTK and Qt plugins...much less arbitrary local file and URL plugins.

I have added support to upgrade the GTK and Qt plugins in this PR.

As for support for upgrading local file and URL plugins, I'm inclined to make that out of scope for this PR. I may look at it a bit but I think it deserves more a specific PR.

<details>
<summary>Tool Audit</summary>

| Name                       | Is a Tool? | Managable? | FS Footprint? |
|----------------------------|:----------:|:----------:|:-------------:|
| Android SDK                |      X     |      X     |       X       |
| ADB                        |            |            |               |
| Docker                     |      X     |            |       X       |
| DockerAppContext           |      X     |            |       X       |
| Download                   |      X     |            |               |
| Flatpak                    |      X     |            |       X       |
| Git                        |      X     |            |       X       |
| JDK                        |      X     |      X     |       X       |
| LinuxDeployBase            |            |            |               |
| LinuxDeployPluginBase      |            |            |               |
| LinuxDeployGtkPlugin       |      X     |      X     |       X       |
| LinuxDeployQtPlugin        |      X     |      X     |       X       |
| LinuxDeployLocalFilePlugin |      X     |            |               |
| LinuxDeployURLPlugin       |      X     |            |       X       |
| LinuxDeploy                |      X     |      X     |       X       |
| RCEdit                     |      X     |      X     |       X       |
| Subprocess                 |      X     |            |               |
| NativeAppContext           |      X     |            |               |
| VisualStudio               |      X     |            |       X       |
| WindowsSDK                 |      X     |            |       X       |
| WiX                        |      X     |      X     |       X       |
| Xcode                      |      X     |            |       X       |
| XcodeCliTools              |      X     |            |       X       |

</details>
freakboy3742: > The Tool registry is immediately applicable to the Upgrade command (obviously) to iterate the tools; but anything that would need to arbitrarily to be considered a part of Briefcase's toolset should be a `Tool`. For instance, a hypothetical future Briefcase Command to verify the readiness of the host system for running an arbitrary Briefcase command.
> 
> If something needs to implement its own verification before it can be used or if its install needs to be managed, it should be a `Tool`.

That sounds like a really solid baseline definition to me.

> This categorization may present some ideological inconsistencies, though. The `Download`, `NativeAppContext`, and `Subprocess` tools would not actually be considered a `Tool` under this definition. They do not need the Tool interface and probably don't need to be a part of the Tool registry any more than anything else in the `stdlib`. Pragmatically, though, I think it's fine...

I think this is the key insight. We need to differentiate between something:

1. being a Tool (in the sense that it is a subclass of Tool)
2. having a `verify()` API
3. being an attribute on the Tool Registry 

Most of the Tools are case (1). `NativeAppContext` is case (2) - it has a verify API, but just because it has that API doesn't mean it's inherently manageable. `Subprocess` and `Download` are in case (3) really only using `tool` as a convenient namespace to gather content; they're not tools, but API wrappers around native capabilities.

> In some ways, `DockerAppContext` is similar to `ADB` in that verification of `Docker` _should_ confirm `DockerAppContext` will work. However, as you point out, `DockerAppContext` is really about managing Docker images...and that's why it requires its own verification (but doesn't require any installation management...unless perhaps support for image management is added). So, I think its status as a `Tool` is appropriate.

From a purely theoretical perspective, I'm sure this could be argued either way; ultimately, I think it's the practicalities of whether the abstraction is more help or harm that matters here.

> As for support for upgrading local file and URL plugins, I'm inclined to make that out of scope for this PR. I may look at it a bit but I think it deserves more a specific PR.

Totally agreed on this one - although I'd also be completely comfortable with "delete all existing plugins" as a viable 
upgrade" strategy, as the verification process already managed downloading plugins on demand.


Patch:
diff --git a/changes/1093.misc.rst b/changes/1093.misc.rst
new file mode 100644
index 00000000..cb993973
--- /dev/null
+++ b/changes/1093.misc.rst
@@ -0,0 +1,1 @@
+The ``Tool`` and ``ManagedTool`` base classes were implemented to unify tool functionality.
diff --git a/src/briefcase/commands/create.py b/src/briefcase/commands/create.py
index ceb9d38d..40e30cc7 100644
--- a/src/briefcase/commands/create.py
+++ b/src/briefcase/commands/create.py
@@ -22,7 +22,7 @@ from briefcase.exceptions import (
     TemplateUnsupportedVersion,
     UnsupportedPlatform,
 )
-from briefcase.integrations import git
+from briefcase.integrations.git import Git
 from briefcase.integrations.subprocess import NativeAppContext
 
 from .base import BaseCommand, full_options
@@ -811,7 +811,7 @@ class CreateCommand(BaseCommand):
         Raises MissingToolException if a required system tool is missing.
         """
         super().verify_tools()
-        git.verify_git_is_installed(tools=self.tools)
+        Git.verify(tools=self.tools)
 
     def verify_app_tools(self, app: BaseConfig):
         """Verify that tools needed to run the command for this app exist."""
diff --git a/src/briefcase/commands/new.py b/src/briefcase/commands/new.py
index 41fea96e..fb0c965c 100644
--- a/src/briefcase/commands/new.py
+++ b/src/briefcase/commands/new.py
@@ -13,7 +13,7 @@ from briefcase.config import (
     make_class_name,
 )
 from briefcase.exceptions import BriefcaseCommandError, TemplateUnsupportedVersion
-from briefcase.integrations import git
+from briefcase.integrations.git import Git
 
 from .base import BaseCommand
 
@@ -519,7 +519,7 @@ Application '{context['formal_name']}' has been generated. To run your applicati
         Raises MissingToolException if a required system tool is missing.
         """
         super().verify_tools()
-        git.verify_git_is_installed(tools=self.tools)
+        Git.verify(tools=self.tools)
 
     def __call__(
         self,
diff --git a/src/briefcase/commands/upgrade.py b/src/briefcase/commands/upgrade.py
index 64105e56..c130d384 100644
--- a/src/briefcase/commands/upgrade.py
+++ b/src/briefcase/commands/upgrade.py
@@ -1,12 +1,13 @@
 import sys
-from typing import List
+from operator import attrgetter
+from typing import List, Set, Type
 
-from briefcase.exceptions import BriefcaseCommandError
-from briefcase.integrations.android_sdk import AndroidSDK
-from briefcase.integrations.java import JDK
-from briefcase.integrations.linuxdeploy import LinuxDeploy
-from briefcase.integrations.rcedit import RCEdit
-from briefcase.integrations.wix import WiX
+from briefcase.exceptions import (
+    BriefcaseCommandError,
+    UnsupportedHostError,
+    UpgradeToolError,
+)
+from briefcase.integrations.base import ManagedTool, Tool, tool_registry
 
 from .base import BaseCommand
 
@@ -17,16 +18,6 @@ class UpgradeCommand(BaseCommand):
     output_format = None
     description = "Upgrade Briefcase-managed tools."
 
-    def __init__(self, *args, **options):
-        super().__init__(*args, **options)
-        self.sdks = [
-            AndroidSDK,
-            LinuxDeploy,
-            JDK,
-            WiX,
-            RCEdit,
-        ]
-
     @property
     def platform(self):
         """The upgrade command always reports as the local platform."""
@@ -59,62 +50,67 @@ class UpgradeCommand(BaseCommand):
             help="The Briefcase-managed tool to upgrade. If no tool is named, all tools will be upgraded.",
         )
 
-    def __call__(self, tool_list: List[str], list_tools=False, **options):
-        # Verify all the managed SDKs and plugins to see which are present.
-        managed_tools = {}
-        non_managed_tools = set()
-
-        for klass in self.sdks:
-            try:
-                tool = klass.verify(self.tools, install=False)
-                if tool.managed_install:
-                    managed_tools[klass.name] = tool
-                    try:
-                        for plugin_klass in tool.plugins.values():
-                            try:
-                                plugin = plugin_klass.verify(self.tools, install=False)
-                                # All plugins are managed
-                                managed_tools[plugin.name] = plugin
-                            except BriefcaseCommandError:
-                                # Plugin doesn't exist
-                                non_managed_tools.add(klass.name)
-                    except AttributeError:
-                        # Tool doesn't have plugins
-                        pass
+    def get_tools_to_upgrade(self, tool_list: Set[str]) -> List[ManagedTool]:
+        """Returns set of managed Tools that can be upgraded.
+
+        Raises ``BriefcaseCommandError`` if user list contains any invalid tool names.
+        """
+        upgrade_list: set[Type[Tool]]
+        tools_to_upgrade: set[ManagedTool] = set()
+
+        # Validate user tool list against tool registry
+        if tool_list:
+            if invalid_tools := tool_list - set(tool_registry):
+                raise UpgradeToolError(
+                    f"Briefcase does not know how to manage {', '.join(sorted(invalid_tools))}."
+                )
+            upgrade_list = {
+                tool for name, tool in tool_registry.items() if name in tool_list
+            }
+        else:
+            upgrade_list = set(tool_registry.values())
+
+        # Filter list of tools to those that are being managed
+        for tool_klass in upgrade_list:
+            if issubclass(tool_klass, ManagedTool):
+                try:
+                    tool = tool_klass.verify(tools=self.tools, install=False)
+                except (BriefcaseCommandError, UnsupportedHostError):
+                    pass
                 else:
-                    non_managed_tools.add(klass.name)
-            except BriefcaseCommandError:
-                # Tool doesn't exist
-                non_managed_tools.add(klass.name)
-
-        # If a tool list wasn't provided, use the list of installed tools
-        if not tool_list:
-            tool_list = sorted(managed_tools.keys())
-
-        # Build a list of requested tools that are managed.
-        found_tools = []
-        for name in tool_list:
-            if name in managed_tools:
-                found_tools.append(name)
-            elif name not in non_managed_tools:
-                raise BriefcaseCommandError(
-                    f"Briefcase doesn't know how to manage the tool '{name}'"
+                    if tool.managed_install:
+                        tools_to_upgrade.add(tool)
+
+        # Let the user know if any requested tools are not being managed
+        if tool_list:
+            if unmanaged_tools := tool_list - {tool.name for tool in tools_to_upgrade}:
+                error_msg = (
+                    f"Briefcase is not managing {', '.join(sorted(unmanaged_tools))}."
                 )
-
-        if found_tools:
-            if list_tools:
-                self.logger.info("Briefcase is managing the following tools:")
-                for name in found_tools:
-                    self.logger.info(f" - {name}")
-            else:
-                self.logger.info("Briefcase will upgrade the following tools:")
-                for name in found_tools:
-                    self.logger.info(f" - {name}")
-
-                for name in found_tools:
-                    tool = managed_tools[name]
+                if not tools_to_upgrade:
+                    raise UpgradeToolError(error_msg)
+                else:
+                    self.logger.warning(error_msg)
+
+        return sorted(list(tools_to_upgrade), key=attrgetter("name"))
+
+    def __call__(self, tool_list: List[str], list_tools: bool = False, **options):
+        """Perform tool upgrades or list tools qualifying for upgrade.
+
+        :param tool_list: List of tool names from user to upgrade.
+        :param list_tools: Boolean to only list upgradeable tools (default False).
+        """
+        if tools_to_upgrade := self.get_tools_to_upgrade(set(tool_list)):
+            self.logger.info(
+                f"Briefcase {'is managing' if list_tools else 'will upgrade'} the following tools:",
+                prefix=self.command,
+            )
+            for tool in tools_to_upgrade:
+                self.logger.info(f" - {tool.full_name} ({tool.name})")
+
+            if not list_tools:
+                for tool in tools_to_upgrade:
                     self.logger.info(f"Upgrading {tool.full_name}...", prefix=tool.name)
                     tool.upgrade()
-
         else:
             self.logger.info("Briefcase is not managing any tools.")
diff --git a/src/briefcase/exceptions.py b/src/briefcase/exceptions.py
index 1abaab80..e36f120e 100644
--- a/src/briefcase/exceptions.py
+++ b/src/briefcase/exceptions.py
@@ -70,8 +70,8 @@ class UnsupportedHostError(BriefcaseError):
 
 
 class BriefcaseCommandError(BriefcaseError):
-    def __init__(self, msg):
-        super().__init__(error_code=200)
+    def __init__(self, msg, skip_logfile=False):
+        super().__init__(error_code=200, skip_logfile=skip_logfile)
         self.msg = msg
 
     def __str__(self):
@@ -109,6 +109,11 @@ class NonManagedToolError(BriefcaseCommandError):
         super().__init__(msg=f"{tool!r} is using an install that is user managed.")
 
 
+class UpgradeToolError(BriefcaseCommandError):
+    def __init__(self, error_msg):
+        super().__init__(msg=error_msg, skip_logfile=True)
+
+
 class TemplateUnsupportedVersion(BriefcaseCommandError):
     def __init__(self, briefcase_version):
         self.briefcase_version = briefcase_version
diff --git a/src/briefcase/integrations/android_sdk.py b/src/briefcase/integrations/android_sdk.py
index 0d090ef2..fe2c1f77 100644
--- a/src/briefcase/integrations/android_sdk.py
+++ b/src/briefcase/integrations/android_sdk.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 import json
 import os
 import re
@@ -6,6 +8,7 @@ import shutil
 import subprocess
 import time
 from contextlib import suppress
+from datetime import datetime
 from pathlib import Path
 
 from briefcase.config import PEP508_NAME_RE
@@ -15,8 +18,9 @@ from briefcase.exceptions import (
     InvalidDeviceError,
     MissingToolError,
 )
-from briefcase.integrations.base import Tool, ToolCache
+from briefcase.integrations.base import ManagedTool, ToolCache
 from briefcase.integrations.java import JDK
+from briefcase.integrations.subprocess import SubprocessArgT
 
 DEVICE_NOT_FOUND = re.compile(r"^error: device '[^']*' not found")
 
@@ -39,12 +43,12 @@ this device as a deployment target.
         )
 
 
-class AndroidSDK(Tool):
+class AndroidSDK(ManagedTool):
     name = "android_sdk"
     full_name = "Android SDK"
 
     def __init__(self, tools: ToolCache, root_path: Path):
-        self.tools = tools
+        super().__init__(tools=tools)
         self.dot_android_path = self.tools.home_path / ".android"
         self.root_path = root_path
 
@@ -52,7 +56,7 @@ class AndroidSDK(Tool):
         self.sleep = time.sleep
 
     @property
-    def cmdline_tools_url(self):
+    def cmdline_tools_url(self) -> str:
         """The Android SDK Command-Line Tools URL appropriate to the current operating
         system."""
         platform_name = self.tools.host_os.lower()
@@ -64,11 +68,11 @@ class AndroidSDK(Tool):
         return f"https://dl.google.com/android/repository/commandlinetools-{platform_name}-{self.cmdline_tools_version}_latest.zip"  # noqa: E501
 
     @property
-    def cmdline_tools_path(self):
+    def cmdline_tools_path(self) -> Path:
         return self.root_path / "cmdline-tools" / "latest"
 
     @property
-    def cmdline_tools_version(self):
+    def cmdline_tools_version(self) -> str:
         # This is the version of the Android SDK Command-line tools that
         # are current as of May 2022. These tools can generally self-update,
         # so using a fixed download URL isn't a problem.
@@ -77,49 +81,49 @@ class AndroidSDK(Tool):
         return "8092744"
 
     @property
-    def cmdline_tools_version_path(self):
+    def cmdline_tools_version_path(self) -> Path:
         return self.root_path / "cmdline-tools" / self.cmdline_tools_version
 
     @property
-    def sdkmanager_path(self):
+    def sdkmanager_path(self) -> Path:
         sdkmanager = (
             "sdkmanager.bat" if self.tools.host_os == "Windows" else "sdkmanager"
         )
         return self.cmdline_tools_path / "bin" / sdkmanager
 
     @property
-    def adb_path(self):
+    def adb_path(self) -> Path:
         adb = "adb.exe" if self.tools.host_os == "Windows" else "adb"
         return self.root_path / "platform-tools" / adb
 
     @property
-    def avdmanager_path(self):
+    def avdmanager_path(self) -> Path:
         avdmanager = (
             "avdmanager.bat" if self.tools.host_os == "Windows" else "avdmanager"
         )
         return self.cmdline_tools_path / "bin" / avdmanager
 
     @property
-    def emulator_path(self):
+    def emulator_path(self) -> Path:
         emulator = "emulator.exe" if self.tools.host_os == "Windows" else "emulator"
         return self.root_path / "emulator" / emulator
 
     @property
-    def avd_path(self):
+    def avd_path(self) -> Path:
         return self.dot_android_path / "avd"
 
-    def avd_config_filename(self, avd):
+    def avd_config_filename(self, avd: str) -> Path:
         return self.avd_path / f"{avd}.avd" / "config.ini"
 
     @property
-    def env(self):
+    def env(self) -> dict[str, str]:
         return {
             "ANDROID_SDK_ROOT": os.fsdecode(self.root_path),
             "JAVA_HOME": str(self.tools.java.java_home),
         }
 
     @property
-    def emulator_abi(self):
+    def emulator_abi(self) -> str:
         """The ABI to use for the Android emulator."""
         if self.tools.host_arch == "arm64" and self.tools.host_os == "Darwin":
             return "arm64-v8a"
@@ -132,19 +136,24 @@ class AndroidSDK(Tool):
         )
 
     @property
-    def DEFAULT_DEVICE_TYPE(self):
+    def DEFAULT_DEVICE_TYPE(self) -> str:
         return "pixel"
 
     @property
-    def DEFAULT_DEVICE_SKIN(self):
+    def DEFAULT_DEVICE_SKIN(self) -> str:
         return "pixel_3a"
 
     @property
-    def DEFAULT_SYSTEM_IMAGE(self):
+    def DEFAULT_SYSTEM_IMAGE(self) -> str:
         return f"system-images;android-31;default;{self.emulator_abi}"
 
     @classmethod
-    def verify(cls, tools: ToolCache, install=True):
+    def verify_install(
+        cls,
+        tools: ToolCache,
+        install: bool = True,
+        **kwargs,
+    ) -> AndroidSDK:
         """Verify an Android SDK is available.
 
         If the ANDROID_SDK_ROOT environment variable is set, that location will
@@ -237,7 +246,7 @@ class AndroidSDK(Tool):
         tools.android_sdk = sdk
         return sdk
 
-    def exists(self):
+    def exists(self) -> bool:
         """Confirm that the SDK actually exists.
 
         Look for the sdkmanager; and, if necessary, confirm that it is executable.
@@ -248,13 +257,17 @@ class AndroidSDK(Tool):
         )
 
     @property
-    def managed_install(self):
+    def managed_install(self) -> bool:
         """Is the Android SDK install managed by Briefcase?"""
         # Although the end-user can provide their own SDK, the SDK also
         # provides a built-in upgrade mechanism. Therefore, all Android SDKs
         # are managed installs.
         return True
 
+    def uninstall(self):
+        """The Android SDK is upgraded in-place instead of being reinstalled."""
+        pass
+
     def install(self):
         """Download and install the Android SDK."""
         cmdline_tools_zip_path = self.tools.download.file(
@@ -353,12 +366,12 @@ its output for errors.
                 "Unable to invoke the Android SDK manager"
             ) from e
 
-    def adb(self, device):
+    def adb(self, device: str) -> ADB:
         """Obtain an ADB instance for managing a specific device.
 
         :param device: The device ID to manage.
         """
-        return ADB(self.tools, device=device)
+        return ADB(tools=self.tools, device=device)
 
     def verify_license(self):
         """Verify that all necessary licenses have been accepted.
@@ -443,7 +456,7 @@ connection.
                 "Error while installing Android emulator."
             ) from e
 
-    def verify_avd(self, avd):
+    def verify_avd(self, avd: str):
         """Verify that the AVD has the necessary system components to launch.
 
         This includes:
@@ -509,7 +522,7 @@ connection.
         except KeyError:
             self.tools.logger.debug(f"Device {avd!r} doesn't define a skin.")
 
-    def verify_system_image(self, system_image):
+    def verify_system_image(self, system_image: str):
         """Verify that the required system image is installed.
 
         :param system_image: The SDKManager identifier for the system
@@ -572,7 +585,7 @@ connection.
                 f"Error while installing the {system_image!r} Android system image."
             ) from e
 
-    def verify_emulator_skin(self, skin):
+    def verify_emulator_skin(self, skin: str):
         """Verify that an emulator skin is available.
 
         A human-readable list of available skins can be found here:
@@ -613,7 +626,7 @@ connection.
             # Delete the downloaded file.
             skin_tgz_path.unlink()
 
-    def emulators(self):
+    def emulators(self) -> list[str]:
         """Find the list of emulators that are available."""
         try:
             # Capture `stderr` so that if the process exits with failure, the
@@ -629,11 +642,9 @@ connection.
         except subprocess.CalledProcessError as e:
             raise BriefcaseCommandError("Unable to obtain Android emulator list") from e
 
-    def devices(self):
+    def devices(self) -> dict[str, dict[str, str | bool]]:
         """Find the devices that are attached and available to ADB."""
         try:
-            # Capture `stderr` so that if the process exits with failure, the
-            # stderr data is in `e.output`.
             output = self.tools.subprocess.check_output(
                 [os.fsdecode(self.adb_path), "devices", "-l"]
             ).strip()
@@ -676,7 +687,10 @@ connection.
         except subprocess.CalledProcessError as e:
             raise BriefcaseCommandError("Unable to obtain Android device list") from e
 
-    def select_target_device(self, device_or_avd):
+    def select_target_device(
+        self,
+        device_or_avd: str | None,
+    ) -> tuple[str | None, str | None, str | None]:
         """Select a device to be the target for actions.
 
         Interrogates the system to get the list of available devices.
@@ -693,7 +707,7 @@ connection.
             available.
         :returns: A tuple containing ``(device, name, avd)``. ``avd`` will only
             be provided if an emulator with that AVD is not currently running.
-            If ``device`` is null, a new emulator should be created.
+            If ``device`` is None, a new emulator should be created.
         """
         # If the device_or_avd starts with "{", it's a definition for a new
         # emulator to be created.
@@ -770,8 +784,8 @@ connection.
 
                 if device_or_avd.startswith("@"):
                     # specifier is an AVD
+                    avd = device_or_avd[1:]
                     try:
-                        avd = device_or_avd[1:]
                         device = running_avds[avd]
                     except KeyError:
                         # device_or_avd isn't in the list of running avds;
@@ -779,7 +793,6 @@ connection.
                         return None, name, avd
                 else:
                     # Specifier is a direct device ID
-                    avd = None
                     device = device_or_avd
 
                 details = running_devices[device]
@@ -834,7 +847,7 @@ Use the -d/--device option to explicitly specify the device to use.
         else:
             # Either a running emulator, or a physical device. Regardless,
             # we need to check if the device is developer enabled.
-            # Functionally, we know the the device *must* be in the list of
+            # Functionally, we know the device *must* be in the list of
             # choices; which means it's also in the list of running devices
             # and the list of device choices, so any KeyError on those lookups
             # indicates a deeper problem.
@@ -867,10 +880,9 @@ In future, you can specify this device by running:
 
         return device, name, avd
 
-    def create_emulator(self, use_defaults=False):
+    def create_emulator(self) -> str:
         """Create a new Android emulator.
 
-        :param use_defaults: Should the emulator be created with defaults?
         :returns: The AVD of the newly created emulator.
         """
         # Get the list of existing emulators
@@ -948,10 +960,10 @@ In future, you can specify this device by running:
 
     def _create_emulator(
         self,
-        avd,
-        device_type=None,
-        skin=None,
-        system_image=None,
+        avd: str,
+        device_type: str | None = None,
+        skin: str | None = None,
+        system_image: str | None = None,
     ):
         """Internal method that does the actual work of creating the emulator.
 
@@ -1014,7 +1026,7 @@ In future, you can specify this device by running:
                 },
             )
 
-    def avd_config(self, avd):
+    def avd_config(self, avd: str) -> dict[str, str]:
         """Obtain the AVD configuration as key-value pairs.
 
         :params avd: The AVD whose config will be retrieved
@@ -1036,7 +1048,7 @@ In future, you can specify this device by running:
 
         return avd_config
 
-    def update_emulator_config(self, avd, updates):
+    def update_emulator_config(self, avd: str, updates: dict[str, str]):
         """Update the AVD configuration with specific values.
 
         :params avd: The AVD whose config will be updated
@@ -1053,7 +1065,11 @@ In future, you can specify this device by running:
             for key, value in avd_config.items():
                 f.write(f"{key}={value}\n")
 
-    def start_emulator(self, avd, extra_args=None):
+    def start_emulator(
+        self,
+        avd: str,
+        extra_args: list[str] | None = None,
+    ) -> tuple[str, str]:
         """Start an existing Android emulator.
 
         Returns when the emulator is booted and ready to accept apps.
@@ -1188,7 +1204,7 @@ class ADB:
         self.tools = tools
         self.device = device
 
-    def avd_name(self):
+    def avd_name(self) -> str | None:
         """Get the AVD name for the device.
 
         :returns: The AVD name for the device; or ``None`` if the device isn't
@@ -1222,7 +1238,7 @@ class ADB:
                 f"Unable to determine if emulator {self.device} has booted."
             ) from e
 
-    def run(self, *arguments, quiet=False):
+    def run(self, *arguments: SubprocessArgT, quiet: bool = False) -> str:
         """Run a command on a device using Android debug bridge, `adb`. The device name
         is mandatory to ensure clarity in the case of multiple attached devices.
 
@@ -1258,7 +1274,7 @@ class ADB:
                 raise InvalidDeviceError("device id", self.device) from e
             raise
 
-    def install_apk(self, apk_path):
+    def install_apk(self, apk_path: str | Path):
         """Install an APK file on an Android device.
 
         :param apk_path: The path of the Android APK file to install.
@@ -1272,7 +1288,7 @@ class ADB:
                 f"Unable to install APK {apk_path} on {self.device}"
             ) from e
 
-    def force_stop_app(self, package):
+    def force_stop_app(self, package: str):
         """Force-stop an app, specified as a package name.
 
         :param package: The name of the Android package, e.g., com.username.myapp.
@@ -1289,7 +1305,7 @@ class ADB:
                 f"Unable to force stop app {package} on {self.device}"
             ) from e
 
-    def start_app(self, package, activity, passthrough):
+    def start_app(self, package: str, activity: str, passthrough: list[str]):
         """Start an app, specified as a package name & activity name.
 
         :param package: The name of the Android package, e.g., com.username.myapp.
@@ -1342,7 +1358,7 @@ Activity class not found while starting app.
                 f"Unable to start {package}/{activity} on {self.device}"
             ) from e
 
-    def logcat(self, pid):
+    def logcat(self, pid: str):
         """Start following the adb log for the device.
 
         :param pid: The PID whose logs you want to display.
@@ -1366,7 +1382,7 @@ Activity class not found while starting app.
             bufsize=1,
         )
 
-    def logcat_tail(self, since=None):
+    def logcat_tail(self, since: datetime):
         """Show the tail of the logs for Python-like apps, starting from a given
         timestamp.
 
@@ -1396,7 +1412,7 @@ Activity class not found while starting app.
         except subprocess.CalledProcessError as e:
             raise BriefcaseCommandError("Error starting ADB logcat.") from e
 
-    def pidof(self, package, **kwargs):
+    def pidof(self, package: str, **kwargs) -> str | None:
         """Obtain the PID of a running app by package name.
 
         :param package: The package ID for the application (e.g.,
@@ -1413,7 +1429,7 @@ Activity class not found while starting app.
         except subprocess.CalledProcessError:
             return None
 
-    def pid_exists(self, pid, **kwargs):
+    def pid_exists(self, pid: str, **kwargs) -> bool:
         """Confirm if the PID exists on the emulator.
 
         :param pid: The PID to check
diff --git a/src/briefcase/integrations/base.py b/src/briefcase/integrations/base.py
index 08a2bdb9..e054c02a 100644
--- a/src/briefcase/integrations/base.py
+++ b/src/briefcase/integrations/base.py
@@ -4,17 +4,22 @@ import os
 import platform
 import shutil
 import sys
-from abc import ABC
+from abc import ABC, abstractmethod
 from collections import defaultdict
 from collections.abc import Mapping
 from pathlib import Path
-from typing import TYPE_CHECKING, DefaultDict
+from typing import TYPE_CHECKING, DefaultDict, TypeVar
 
 import requests
 from cookiecutter.main import cookiecutter
 
 from briefcase.config import AppConfig
 from briefcase.console import Console, Log
+from briefcase.exceptions import (
+    MissingToolError,
+    NonManagedToolError,
+    UnsupportedHostError,
+)
 
 if TYPE_CHECKING:
     # Tools are imported only for type checking
@@ -32,11 +37,103 @@ if TYPE_CHECKING:
     from briefcase.integrations.visualstudio import VisualStudio
     from briefcase.integrations.windows_sdk import WindowsSDK
     from briefcase.integrations.wix import WiX
+    from briefcase.integrations.xcode import Xcode, XcodeCliTools
+
+ToolT = TypeVar("ToolT", bound="Tool")
+ManagedToolT = TypeVar("ManagedToolT", bound="ManagedTool")
+
+# Registry of all defined Tools
+tool_registry: dict[str, type[Tool | ManagedTool]] = dict()
 
 
-# TODO: Implement Tool base class
 class Tool(ABC):
-    """Tool Base."""  # pragma: no cover
+    """Tool Base."""
+
+    name: str
+    full_name: str
+    supported_host_os: set[str] = {"Darwin", "Linux", "Windows"}
+
+    def __init__(self, tools: ToolCache, **kwargs):
+        self.tools = tools
+
+    def __init_subclass__(cls, **kwargs):
+        """Register each tool when it is defined."""
+        if cls.name != "managed_tool_base":
+            tool_registry[cls.name] = cls
+
+    @classmethod
+    def verify(
+        cls: type[ToolT],
+        tools: ToolCache,
+        app: AppConfig | None = None,
+        **kwargs,
+    ) -> ToolT:
+        """Confirm the tool is available and usable on the host platform."""
+        cls.verify_host(tools=tools)
+        tool = cls.verify_install(tools=tools, app=app, **kwargs)
+        return tool
+
+    @classmethod
+    def verify_host(cls, tools: ToolCache):
+        """Confirm the tool is supported on the platform."""
+        if tools.host_os not in cls.supported_host_os:
+            raise UnsupportedHostError(
+                f"{cls.name} is not supported on {tools.host_os}"
+            )
+
+    @classmethod
+    @abstractmethod
+    def verify_install(cls: type[ToolT], tools: ToolCache, **kwargs) -> ToolT:
+        """Confirm the tool is installed and available."""
+
+    @property
+    def managed_install(self) -> bool:
+        """Is Briefcase managing the installation of this tool?"""
+        return False
+
+
+class ManagedTool(Tool):
+    """Tool that can be managed by Briefcase."""
+
+    name = "managed_tool_base"
+
+    @classmethod
+    def verify(
+        cls: type[ManagedToolT],
+        tools: ToolCache,
+        app: AppConfig | None = None,
+        install: bool = True,
+        **kwargs,
+    ) -> ManagedToolT:
+        """Confirm the managed tool is installed and available."""
+        return super().verify(tools=tools, app=app, install=install, **kwargs)
+
+    @abstractmethod
+    def exists(self) -> bool:
+        """Is the tool currently installed?"""
+
+    @abstractmethod
+    def install(self):
+        """Install the tool as managed by Briefcase."""
+
+    @abstractmethod
+    def uninstall(self):
+        """Uninstall a Briefcase managed tool."""
+
+    @property
+    def managed_install(self) -> bool:
+        """Is Briefcase managing the installation of this tool?"""
+        return True
+
+    def upgrade(self):
+        """Upgrade a managed tool."""
+        if self.managed_install:
+            if not self.exists():
+                raise MissingToolError(self.full_name)
+            self.uninstall()
+            self.install()
+        else:
+            raise NonManagedToolError(self.full_name)
 
 
 class ToolCache(Mapping):
@@ -57,8 +154,8 @@ class ToolCache(Mapping):
     visualstudio: VisualStudio
     windows_sdk: WindowsSDK
     wix: WiX
-    xcode: bool
-    xcode_cli: bool
+    xcode: Xcode
+    xcode_cli: XcodeCliTools
 
     # Python stdlib tools
     platform = platform
@@ -75,7 +172,7 @@ class ToolCache(Mapping):
         logger: Log,
         console: Console,
         base_path: Path,
-        home_path: Path = None,
+        home_path: Path | None = None,
     ):
         """Cache for managing tool access and verification.
 
diff --git a/src/briefcase/integrations/docker.py b/src/briefcase/integrations/docker.py
index cb05c1f4..57078e74 100644
--- a/src/briefcase/integrations/docker.py
+++ b/src/briefcase/integrations/docker.py
@@ -1,11 +1,14 @@
+from __future__ import annotations
+
 import os
 import subprocess
 import sys
-from pathlib import Path
+from pathlib import Path, PurePosixPath
 
 from briefcase.config import AppConfig
 from briefcase.exceptions import BriefcaseCommandError
 from briefcase.integrations.base import Tool, ToolCache
+from briefcase.integrations.subprocess import SubprocessArgsT, SubprocessArgT
 
 
 class Docker(Tool):
@@ -108,11 +111,8 @@ See https://docs.docker.com/go/buildx/ to install the buildx plugin.
         "Linux": "https://docs.docker.com/engine/install/#server",
     }
 
-    def __init__(self, tools: ToolCache):
-        self.tools = tools
-
     @classmethod
-    def verify(cls, tools: ToolCache):
+    def verify_install(cls, tools: ToolCache, **kwargs) -> Docker:
         """Verify Docker is installed and operational."""
         # short circuit since already verified and available
         if hasattr(tools, "docker"):
@@ -126,7 +126,7 @@ See https://docs.docker.com/go/buildx/ to install the buildx plugin.
         return tools.docker
 
     @classmethod
-    def _version_compat(cls, tools):
+    def _version_compat(cls, tools: ToolCache):
         """Verify Docker version is compatible."""
         try:
             # Try to get the version of docker that is installed.
@@ -160,7 +160,7 @@ See https://docs.docker.com/go/buildx/ to install the buildx plugin.
             ) from e
 
     @classmethod
-    def _user_access(cls, tools):
+    def _user_access(cls, tools: ToolCache):
         """Verify Docker is operational for user."""
         try:
             # Invoke a docker command to check if the daemon is running,
@@ -182,19 +182,19 @@ See https://docs.docker.com/go/buildx/ to install the buildx plugin.
                 raise BriefcaseCommandError(cls.GENERIC_DOCKER_ERROR) from e
 
     @classmethod
-    def _buildx_installed(cls, tools):
+    def _buildx_installed(cls, tools: ToolCache):
         """Verify the buildx plugin is installed."""
         try:
             tools.subprocess.check_output(["docker", "buildx", "version"])
         except subprocess.CalledProcessError:
             raise BriefcaseCommandError(cls.BUILDX_PLUGIN_MISSING)
 
-    def check_output(self, args, image_tag):
+    def check_output(self, args: list[SubprocessArgT], image_tag: str) -> str:
         """Run a process inside a Docker container, capturing output.
 
         This is a bare Docker invocation; it's really only useful for running
         simple commands on an image, ensuring that the container is destroyed
-        afterwards. In most cases, you'll want to use an app context, rather
+        afterward. In most cases, you'll want to use an app context, rather
         than this.
 
         :param args: The list of arguments to pass to the Docker instance.
@@ -213,13 +213,13 @@ See https://docs.docker.com/go/buildx/ to install the buildx plugin.
             + args,
         )
 
-    def prepare(self, image_tag):
+    def prepare(self, image_tag: str):
         """Ensure that the given image exists, and is cached locally.
 
         This is achieved by trying to run a no-op command (echo) on the image;
         if it succeeds, the image exists locally.
 
-        A pull is forced, so you can be certain that the image is up to date.
+        A pull is forced, so you can be certain that the image is up-to-date.
 
         :param image_tag: The Docker image to prepare
         """
@@ -237,23 +237,25 @@ See https://docs.docker.com/go/buildx/ to install the buildx plugin.
 
 
 class DockerAppContext(Tool):
-    def __init__(self, tools: ToolCache, app: AppConfig):
-        self.tools = tools
-        self.app = app
+    name = "docker_app_context"
+    full_name = "Docker"
 
-        self.app_base_path = None
-        self.host_bundle_path = None
-        self.host_data_path = None
-        self.image_tag = None
-        self.python_version = None
+    def __init__(self, tools: ToolCache, app: AppConfig):
+        super().__init__(tools=tools)
+        self.app: AppConfig = app
+        self.app_base_path: Path
+        self.host_bundle_path: Path
+        self.host_data_path: Path
+        self.image_tag: str
+        self.python_version: str
 
     @property
-    def docker_data_path(self):
+    def docker_data_path(self) -> PurePosixPath:
         """The briefcase data directory used inside container."""
-        return "/home/brutus/.cache/briefcase"
+        return PurePosixPath("/home/brutus/.cache/briefcase")
 
     @classmethod
-    def verify(
+    def verify_install(
         cls,
         tools: ToolCache,
         app: AppConfig,
@@ -263,7 +265,8 @@ class DockerAppContext(Tool):
         host_bundle_path: Path,
         host_data_path: Path,
         python_version: str,
-    ):
+        **kwargs,
+    ) -> DockerAppContext:
         """Verify that docker is available as an app-bound tool.
 
         Creates or updates the Docker image for the app to run
@@ -285,7 +288,7 @@ class DockerAppContext(Tool):
 
         Docker.verify(tools=tools)
 
-        tools[app].app_context = DockerAppContext(tools, app)
+        tools[app].app_context = DockerAppContext(tools=tools, app=app)
         tools[app].app_context.prepare(
             image_tag=image_tag,
             dockerfile_path=dockerfile_path,
@@ -347,7 +350,7 @@ class DockerAppContext(Tool):
                         f"Error building Docker container image for {self.app.app_name}."
                     ) from e
 
-    def _dockerize_path(self, arg: str):  # pragma: no-cover-if-is-windows
+    def _dockerize_path(self, arg: str) -> str:  # pragma: no-cover-if-is-windows
         """Relocate any local path into the equivalent location on the docker
         filesystem.
 
@@ -362,18 +365,18 @@ class DockerAppContext(Tool):
         if arg == sys.executable:
             return f"python{self.python_version}"
         arg = arg.replace(os.fsdecode(self.host_bundle_path), "/app")
-        arg = arg.replace(os.fsdecode(self.host_data_path), self.docker_data_path)
+        arg = arg.replace(os.fsdecode(self.host_data_path), str(self.docker_data_path))
 
         return arg
 
     def _dockerize_args(
         self,
-        args,
-        interactive=False,
-        mounts=None,
-        env=None,
-        cwd=None,
-    ):  # pragma: no-cover-if-is-windows
+        args: SubprocessArgsT,
+        interactive: bool = False,
+        mounts: list[tuple[str | Path, str | Path]] | None = None,
+        env: dict[str, str] | None = None,
+        cwd: Path | None = None,
+    ) -> list[str]:  # pragma: no-cover-if-is-windows
         """Convert arguments and environment into a Docker-compatible form. Convert an
         argument and environment specification into a form that can be used as arguments
         to invoke Docker. This involves:
@@ -432,7 +435,15 @@ class DockerAppContext(Tool):
 
         return docker_args
 
-    def run(self, args, env=None, cwd=None, interactive=False, mounts=None, **kwargs):
+    def run(
+        self,
+        args: SubprocessArgsT,
+        env: dict[str, str] | None = None,
+        cwd: Path | None = None,
+        interactive: bool = False,
+        mounts: list[tuple[str | Path, str | Path]] | None = None,
+        **kwargs,
+    ):
         """Run a process inside a Docker container."""
         # Any exceptions from running the process are *not* caught.
         # This ensures that "docker.run()" behaves as closely to
@@ -452,7 +463,14 @@ class DockerAppContext(Tool):
                 **kwargs,
             )
 
-    def check_output(self, args, env=None, cwd=None, mounts=None, **kwargs):
+    def check_output(
+        self,
+        args: SubprocessArgsT,
+        env: dict[str, str] | None = None,
+        cwd: Path | None = None,
+        mounts: list[tuple[str | Path, str | Path]] | None = None,
+        **kwargs,
+    ) -> str:
         """Run a process inside a Docker container, capturing output."""
         # Any exceptions from running the process are *not* caught.
         # This ensures that "docker.check_output()" behaves as closely to
diff --git a/src/briefcase/integrations/download.py b/src/briefcase/integrations/download.py
index 4063b0ed..970ee92c 100644
--- a/src/briefcase/integrations/download.py
+++ b/src/briefcase/integrations/download.py
@@ -1,10 +1,14 @@
+from __future__ import annotations
+
 import os
 import tempfile
 from contextlib import suppress
 from email.message import Message
+from pathlib import Path
 from urllib.parse import urlparse
 
 import requests.exceptions as requests_exceptions
+from requests import Response
 
 from briefcase.exceptions import (
     BadNetworkResourceError,
@@ -18,11 +22,8 @@ class Download(Tool):
     name = "download"
     full_name = "Download"
 
-    def __init__(self, tools: ToolCache):
-        self.tools = tools
-
     @classmethod
-    def verify(cls, tools: ToolCache):
+    def verify_install(cls, tools: ToolCache, **kwargs) -> Download:
         """Make downloader available in tool cache."""
         # short circuit since already verified and available
         if hasattr(tools, "download"):
@@ -31,7 +32,7 @@ class Download(Tool):
         tools.download = Download(tools=tools)
         return tools.download
 
-    def file(self, url, download_path, role=None):
+    def file(self, url: str, download_path: Path, role: str | None = None) -> Path:
         """Download a given URL, caching it. If it has already been downloaded, return
         the value that has been cached.
 
@@ -48,7 +49,7 @@ class Download(Tool):
         :returns: The filename of the downloaded (or cached) file.
         """
         download_path.mkdir(parents=True, exist_ok=True)
-        filename = None
+        filename: Path = None
         try:
             response = self.tools.requests.get(url, stream=True)
             if response.status_code == 404:
@@ -92,7 +93,7 @@ class Download(Tool):
 
         return filename
 
-    def _fetch_and_write_content(self, response, filename):
+    def _fetch_and_write_content(self, response: Response, filename: Path):
         """Write the content from the requests Response to file.
 
         The data is initially written in to a temporary file in the Briefcase
@@ -128,7 +129,7 @@ class Download(Tool):
             self.tools.shutil.move(temp_file.name, filename)
             # Temporary files are created with only read/write permissions for the
             # file's owner (i.e. 600); to match the behavior of file creation using
-            # ``open(..., w)``, the downloaded file's permissions are updated for
+            # ``open(..., "w")``, the downloaded file's permissions are updated for
             # the group and world to have read/write permissions as well. Finally,
             # (as with ``open()``) the system's umask is respected. The current
             # umask is only available as the return value to updating the umask...
diff --git a/src/briefcase/integrations/flatpak.py b/src/briefcase/integrations/flatpak.py
index 20c5a2eb..656de893 100644
--- a/src/briefcase/integrations/flatpak.py
+++ b/src/briefcase/integrations/flatpak.py
@@ -1,21 +1,23 @@
+from __future__ import annotations
+
 import subprocess
+from pathlib import Path
 
 from briefcase.exceptions import BriefcaseCommandError
 from briefcase.integrations.base import Tool, ToolCache
+from briefcase.integrations.subprocess import SubprocessArgT
 
 
 class Flatpak(Tool):
     name = "flatpak"
     full_name = "Flatpak"
+    supported_host_os = {"Linux"}
 
     DEFAULT_REPO_ALIAS = "flathub"
     DEFAULT_REPO_URL = "https://flathub.org/repo/flathub.flatpakrepo"
 
-    def __init__(self, tools: ToolCache):
-        self.tools = tools
-
     @classmethod
-    def verify(cls, tools: ToolCache):
+    def verify_install(cls, tools: ToolCache, **kwargs) -> Flatpak:
         """Verify that the Flatpak toolchain is available.
 
         :param tools: ToolCache of available tools
@@ -137,7 +139,7 @@ You must install both flatpak and flatpak-builder.
         tools.flatpak = flatpak
         return flatpak
 
-    def verify_repo(self, repo_alias, url):
+    def verify_repo(self, repo_alias: str, url: str):
         """Verify that the Flatpak repository has been registered.
 
         :param repo_alias: The alias to use when registering the repo.
@@ -160,7 +162,13 @@ You must install both flatpak and flatpak-builder.
                 f"Unable to add Flatpak repo {url} with alias {repo_alias}."
             ) from e
 
-    def verify_runtime(self, repo_alias, runtime, runtime_version, sdk):
+    def verify_runtime(
+        self,
+        repo_alias: str,
+        runtime: str,
+        runtime_version: str,
+        sdk: str,
+    ):
         """Verify that a specific Flatpak runtime and SDK are available.
 
         :param repo_alias: The alias of the repo where the runtime and SDK are
@@ -190,7 +198,7 @@ You must install both flatpak and flatpak-builder.
                 f"and SDK {sdk}/{self.tools.host_arch}/{runtime_version} from repo {repo_alias}."
             ) from e
 
-    def build(self, bundle_identifier, app_name, path):
+    def build(self, bundle_identifier: str, app_name: str, path: Path):
         """Build a Flatpak manifest.
 
         On success, the app is installed into the user's local Flatpak install,
@@ -238,11 +246,15 @@ flatpak run {bundle_identifier}
         except subprocess.CalledProcessError as e:
             raise BriefcaseCommandError(f"Error while building app {app_name}.") from e
 
-    def run(self, bundle_identifier, app_name, args=None, main_module=None):
+    def run(
+        self,
+        bundle_identifier: str,
+        args: list[SubprocessArgT] | None = None,
+        main_module: str | None = None,
+    ) -> subprocess.Popen[str]:
         """Run a Flatpak in a way that allows for log streaming.
 
         :param bundle_identifier: The bundle identifier for the app being built.
-        :param app_name: The app name.
         :param args: (Optional) The list of arguments to pass to the app
         :param main_module: (Optional) The main module to run. Only required if you
             want to override the default main module for the app.
@@ -273,7 +285,13 @@ flatpak run {bundle_identifier}
         )
 
     def bundle(
-        self, repo_url, bundle_identifier, app_name, version, build_path, output_path
+        self,
+        repo_url: str,
+        bundle_identifier: str,
+        app_name: str,
+        version: str,
+        build_path: Path,
+        output_path: Path,
     ):
         """Bundle a Flatpak for distribution.
 
diff --git a/src/briefcase/integrations/git.py b/src/briefcase/integrations/git.py
index 7f2e4b43..2374a6d3 100644
--- a/src/briefcase/integrations/git.py
+++ b/src/briefcase/integrations/git.py
@@ -1,39 +1,48 @@
+from __future__ import annotations
+
+from types import ModuleType
+
 from briefcase.exceptions import BriefcaseCommandError
-from briefcase.integrations.base import ToolCache
-
-
-def verify_git_is_installed(tools: ToolCache):
-    """Verify if git is installed.
-
-    Unfortunately, `import git` triggers a call on the operating system
-    to run the git executable. On some platforms (notably macOS), the git
-    binary has been instrumented such that if git *isn't* installed,
-    running git triggers a prompt to install Xcode. However, that messes
-    with the UX workflow.
-
-    So - we defer importing git until we actually know we need it. This
-    enables Briefcase to start to do other Xcode checks as part of the macOS
-    workflows, and ensures that "briefcase --help" works on other platforms
-    without raising an error.
-
-    :param tools: ToolCache of available tools
-    :returns: The git module, if `git` is installed and available.
-    """
-    # short circuit since already verified and available
-    if hasattr(tools, "git"):
-        return tools.git
-
-    # Check whether the git executable could be imported.
-    try:
-        import git
-    except ImportError as e:  # pragma: no cover
-        # macOS provides git as part of the Xcode command line tools,
-        # and also hijacks /usr/bin/git with a trigger that prompts the
-        # installation of those tools. Customize the message to account
-        # for this.
-        if tools.host_os == "Darwin":
-            raise BriefcaseCommandError(
-                """\
+from briefcase.integrations.base import Tool, ToolCache
+
+
+class Git(Tool):
+    name = "git"
+    full_name = "Git"
+
+    @classmethod
+    def verify_install(cls, tools: ToolCache, **kwargs) -> ModuleType:
+        """Verify if git is installed.
+
+        Unfortunately, `import git` triggers a call on the operating system
+        to run the git executable. On some platforms (notably macOS), the git
+        binary has been instrumented such that if git *isn't* installed,
+        running git triggers a prompt to install Xcode. However, that messes
+        with the UX workflow.
+
+        So - we defer importing git until we actually know we need it. This
+        enables Briefcase to start to do other Xcode checks as part of the macOS
+        workflows, and ensures that "briefcase --help" works on other platforms
+        without raising an error.
+
+        :param tools: ToolCache of available tools
+        :returns: The git module, if `git` is installed and available.
+        """
+        # short circuit since already verified and available
+        if hasattr(tools, "git"):
+            return tools.git
+
+        # Check whether the git executable could be imported.
+        try:
+            import git
+        except ImportError as e:  # pragma: no cover
+            # macOS provides git as part of the Xcode command line tools,
+            # and also hijacks /usr/bin/git with a trigger that prompts the
+            # installation of those tools. Customize the message to account
+            # for this.
+            if tools.host_os == "Darwin":
+                raise BriefcaseCommandError(
+                    """\
 Briefcase requires git, but it is not installed. Xcode provides git; you should
 be shown a dialog prompting you to install Xcode and the Command Line Developer
 Tools. Select "Install" to install the Command Line Developer Tools.
@@ -47,11 +56,11 @@ to download and install git manually.
 If you have installed git recently and are still getting this error, you may
 need to restart your terminal session.
 """
-            ) from e
+                ) from e
 
-        else:
-            raise BriefcaseCommandError(
-                """\
+            else:
+                raise BriefcaseCommandError(
+                    """\
 Briefcase requires git, but it is not installed (or is not on your PATH). Visit:
 
     https://git-scm.com/
@@ -61,7 +70,7 @@ to download and install git manually.
 If you have installed git recently and are still getting this error, you may
 need to restart your terminal session.
 """
-            ) from e
+                ) from e
 
-    tools.git = git
-    return git
+        tools.git = git
+        return git
diff --git a/src/briefcase/integrations/java.py b/src/briefcase/integrations/java.py
index 3ab30e97..1da9f35e 100644
--- a/src/briefcase/integrations/java.py
+++ b/src/briefcase/integrations/java.py
@@ -1,22 +1,20 @@
+from __future__ import annotations
+
 import os
 import shutil
 import subprocess
 from pathlib import Path
 
-from briefcase.exceptions import (
-    BriefcaseCommandError,
-    MissingToolError,
-    NonManagedToolError,
-)
-from briefcase.integrations.base import Tool, ToolCache
+from briefcase.exceptions import BriefcaseCommandError, MissingToolError
+from briefcase.integrations.base import ManagedTool, ToolCache
 
 
-class JDK(Tool):
+class JDK(ManagedTool):
     name = "java"
     full_name = "Java JDK"
 
     def __init__(self, tools: ToolCache, java_home: Path):
-        self.tools = tools
+        super().__init__(tools=tools)
 
         # As of April 10 2020, 8u242-b08 is the current AdoptOpenJDK
         # https://adoptopenjdk.net/releases.html
@@ -26,7 +24,7 @@ class JDK(Tool):
         self.java_home = java_home
 
     @property
-    def adoptOpenJDK_download_url(self):
+    def adoptOpenJDK_download_url(self) -> str:
         platform = {
             "Darwin": "mac",
             "Windows": "windows",
@@ -44,7 +42,7 @@ class JDK(Tool):
         )
 
     @classmethod
-    def verify(cls, tools: ToolCache, install=True):
+    def verify_install(cls, tools: ToolCache, install: bool = True, **kwargs) -> JDK:
         """Verify that a Java 8 JDK exists.
 
         If ``JAVA_HOME`` is set, try that version. If it is a JRE, or its *not*
@@ -71,7 +69,7 @@ class JDK(Tool):
 
         if tools.host_arch == "arm64" and tools.host_os == "Darwin":
             # Java 8 is not available for macOS on ARM64, so we will require Rosetta.
-            cls.verify_rosetta(tools)
+            cls.verify_rosetta(tools=tools)
 
         # macOS has a helpful system utility to determine JAVA_HOME. Try it.
         if not java_home and tools.host_os == "Darwin":
@@ -100,7 +98,7 @@ class JDK(Tool):
                 vparts = version_str.split(".")
                 if len(vparts) == 3 and vparts[:2] == ["1", "8"]:
                     # It appears to be a Java 8 JDK.
-                    java = JDK(tools, java_home=Path(java_home))
+                    java = JDK(tools=tools, java_home=Path(java_home))
                 else:
                     # It's not a Java 8 JDK.
                     install_message = f"""
@@ -203,7 +201,7 @@ class JDK(Tool):
             if tools.host_os == "Darwin":
                 java_home = java_home / "Contents" / "Home"
 
-            java = JDK(tools, java_home=java_home)
+            java = JDK(tools=tools, java_home=java_home)
 
             if not java.exists():
                 if install:
@@ -222,11 +220,11 @@ class JDK(Tool):
         tools.java = java
         return java
 
-    def exists(self):
+    def exists(self) -> bool:
         return (self.java_home / "bin").exists()
 
     @property
-    def managed_install(self):
+    def managed_install(self) -> bool:
         try:
             # Determine if java_home is relative to the briefcase data directory.
             # If java_home isn't inside this directory, this will raise a ValueError,
@@ -277,18 +275,8 @@ Delete {jdk_zip_path} and run briefcase again.
             else:
                 self.tools.shutil.rmtree(self.java_home)
 
-    def upgrade(self):
-        """Upgrade an existing JDK install."""
-        if not self.managed_install:
-            raise NonManagedToolError("Java")
-        if not self.exists():
-            raise MissingToolError("Java")
-
-        self.uninstall()
-        self.install()
-
     @classmethod
-    def verify_rosetta(cls, tools):
+    def verify_rosetta(cls, tools: ToolCache):
         try:
             tools.subprocess.check_output(["arch", "-x86_64", "true"])
         except subprocess.CalledProcessError:
diff --git a/src/briefcase/integrations/linuxdeploy.py b/src/briefcase/integrations/linuxdeploy.py
index 784fb998..1e51f7b3 100644
--- a/src/briefcase/integrations/linuxdeploy.py
+++ b/src/briefcase/integrations/linuxdeploy.py
@@ -1,7 +1,10 @@
+from __future__ import annotations
+
 import hashlib
 import shlex
-from abc import abstractmethod
+from abc import ABC, abstractmethod
 from pathlib import Path
+from typing import TypeVar
 from urllib.parse import urlparse
 
 from briefcase.exceptions import (
@@ -9,7 +12,9 @@ from briefcase.exceptions import (
     CorruptToolError,
     MissingToolError,
 )
-from briefcase.integrations.base import Tool, ToolCache
+from briefcase.integrations.base import ManagedTool, Tool, ToolCache
+
+LinuxDeployT = TypeVar("LinuxDeployT", bound="LinuxDeployBase")
 
 ELF_HEADER_IDENT = bytes.fromhex("7F454C46")
 ELF_PATCH_OFFSET = 0x08
@@ -17,34 +22,30 @@ ELF_PATCH_ORIGINAL_BYTES = bytes.fromhex("414902")
 ELF_PATCH_PATCHED_BYTES = bytes.fromhex("000000")
 
 
-class LinuxDeployBase:
+class LinuxDeployBase(ABC):
     name: str
     full_name: str
     install_msg: str
-
-    def __init__(self, tools: ToolCache, **kwargs):
-        self.tools = tools
+    tools: ToolCache
+    supported_host_os = {"Linux"}
 
     @property
     @abstractmethod
-    def file_name(self):
+    def file_name(self) -> str:
         """The name of the executable file for the tool/plugin, excluding the path."""
-        ...
 
     @property
     @abstractmethod
-    def download_url(self):
+    def download_url(self) -> str:
         """The URL where the tool/plugin can be downloaded."""
-        ...
 
     @property
     @abstractmethod
-    def file_path(self):
+    def file_path(self) -> Path:
         """The folder on the local filesystem that contains the file_name."""
-        ...
 
-    def exists(self):
-        return (self.file_path / self.file_name).exists()
+    def exists(self) -> bool:
+        return (self.file_path / self.file_name).is_file()
 
     def install(self):
         """Download and install linuxdeploy or plugin."""
@@ -53,7 +54,6 @@ class LinuxDeployBase:
             download_path=self.file_path,
             role=self.full_name,
         )
-
         self.prepare_executable()
 
     def prepare_executable(self):
@@ -69,7 +69,12 @@ class LinuxDeployBase:
                 self.patch_elf_header()
 
     @classmethod
-    def verify(cls, tools: ToolCache, install=True, **kwargs):
+    def verify_install(
+        cls: type[LinuxDeployT],
+        tools: ToolCache,
+        install: bool = True,
+        **kwargs,
+    ) -> LinuxDeployT:
         """Verify that linuxdeploy tool or plugin is available.
 
         :param tools: ToolCache of available tools
@@ -85,7 +90,7 @@ class LinuxDeployBase:
         if not is_plugin and hasattr(tools, "linuxdeploy"):
             return tools.linuxdeploy
 
-        tool = cls(tools, **kwargs)
+        tool: LinuxDeployT = cls(tools=tools, **kwargs)
         if not tool.exists():
             if install:
                 tools.logger.info(
@@ -106,15 +111,7 @@ class LinuxDeployBase:
         with self.tools.input.wait_bar(f"Removing old {self.full_name} install..."):
             (self.file_path / self.file_name).unlink()
 
-    def upgrade(self):
-        """Upgrade an existing linuxdeploy install."""
-        if not self.exists():
-            raise MissingToolError(self.name)
-
-        self.uninstall()
-        self.install()
-
-    def is_elf_file(self):
+    def is_elf_file(self) -> bool:
         """Returns True if the file is an ELF object file.
 
         The header for an ELF object file always starts with 0x7F454C46; this is 0x7fELF
@@ -164,7 +161,7 @@ class LinuxDeployBase:
                 raise CorruptToolError(self.name)
 
 
-class LinuxDeployPluginBase(LinuxDeployBase):
+class LinuxDeployPluginBase(LinuxDeployBase, ABC):
     """Base class for linuxdeploy plugins."""
 
     install_msg = "{full_name} was not found; downloading and installing..."
@@ -177,66 +174,75 @@ class LinuxDeployPluginBase(LinuxDeployBase):
             raise BriefcaseCommandError(f"{self.file_name} is not a linuxdeploy plugin")
 
     @property
-    def plugin_id(self):
+    def plugin_id(self) -> str:
         return self.file_name.split(".")[0].split("-")[2]
 
     @property
-    def file_path(self):
+    def file_path(self) -> Path:
         return self.tools.base_path / "linuxdeploy_plugins" / self.plugin_id
 
 
-class LinuxDeployGtkPlugin(LinuxDeployPluginBase):
+class LinuxDeployGtkPlugin(LinuxDeployPluginBase, ManagedTool):
+    name = "linuxdeploy_gtk_plugin"
     full_name = "linuxdeploy GTK plugin"
 
     @property
-    def file_name(self):
+    def file_name(self) -> str:
         return "linuxdeploy-plugin-gtk.sh"
 
     @property
-    def download_url(self):
+    def download_url(self) -> str:
         return (
             "https://raw.githubusercontent.com/linuxdeploy/linuxdeploy-plugin-gtk/"
             f"master/{self.file_name}"
         )
 
 
-class LinuxDeployQtPlugin(LinuxDeployPluginBase):
+class LinuxDeployQtPlugin(LinuxDeployPluginBase, ManagedTool):
+    name = "linuxdeploy_qt_plugin"
     full_name = "linuxdeploy Qt plugin"
 
     @property
-    def file_name(self):
+    def file_name(self) -> str:
         return f"linuxdeploy-plugin-qt-{self.tools.host_arch}.AppImage"
 
     @property
-    def download_url(self):
+    def download_url(self) -> str:
         return (
             "https://github.com/linuxdeploy/linuxdeploy-plugin-qt/"
             f"releases/download/continuous/{self.file_name}"
         )
 
 
-class LinuxDeployLocalFilePlugin(LinuxDeployPluginBase):
+class LinuxDeployLocalFilePlugin(LinuxDeployPluginBase, Tool):
+    name = "linuxdeploy_user_file_plugin"
     full_name = "user-provided linuxdeploy plugin from local file"
     install_msg = "Copying user-provided plugin into project"
 
-    def __init__(self, tools, plugin_path, bundle_path):
+    def __init__(
+        self,
+        tools: ToolCache,
+        plugin_path: Path,
+        bundle_path: Path,
+        **kwargs,
+    ):
         self._file_name = plugin_path.name
         self.local_path = plugin_path.parent
         self._file_path = bundle_path
 
         # Call the super last to ensure validation of the filename
-        super().__init__(tools)
+        super().__init__(tools=tools)
 
     @property
-    def file_name(self):
+    def file_name(self) -> str:
         return self._file_name
 
     @property
-    def file_path(self):
+    def file_path(self) -> Path:
         return self._file_path
 
     @property
-    def download_url(self):
+    def download_url(self) -> str:
         raise RuntimeError("Shouldn't be trying to download a local file plugin")
 
     def install(self):
@@ -257,10 +263,11 @@ class LinuxDeployLocalFilePlugin(LinuxDeployPluginBase):
         self.prepare_executable()
 
 
-class LinuxDeployURLPlugin(LinuxDeployPluginBase):
+class LinuxDeployURLPlugin(LinuxDeployPluginBase, Tool):
+    name = "linuxdeploy_user_url_plugin"
     full_name = "user-provided linuxdeploy plugin from URL"
 
-    def __init__(self, tools, url):
+    def __init__(self, tools: ToolCache, url: str, **kwargs):
         self._download_url = url
 
         url_parts = urlparse(url)
@@ -277,14 +284,14 @@ class LinuxDeployURLPlugin(LinuxDeployPluginBase):
         self.hash = hashlib.sha256(url.encode("utf-8"))
 
         # Call the super last to ensure validation of the filename
-        super().__init__(tools)
+        super().__init__(tools=tools)
 
     @property
-    def file_name(self):
+    def file_name(self) -> str:
         return self._file_name
 
     @property
-    def file_path(self):
+    def file_path(self) -> Path:
         return (
             self.tools.base_path
             / "linuxdeploy_plugins"
@@ -293,43 +300,43 @@ class LinuxDeployURLPlugin(LinuxDeployPluginBase):
         )
 
     @property
-    def download_url(self):
+    def download_url(self) -> str:
         return self._download_url
 
 
-class LinuxDeploy(LinuxDeployBase, Tool):
+class LinuxDeploy(LinuxDeployBase, ManagedTool):
     name = "linuxdeploy"
     full_name = "linuxdeploy"
     install_msg = "linuxdeploy was not found; downloading and installing..."
 
     @property
-    def managed_install(self):
-        return True
-
-    @property
-    def file_path(self):
+    def file_path(self) -> Path:
         return self.tools.base_path
 
     @property
-    def file_name(self):
+    def file_name(self) -> str:
         return f"linuxdeploy-{self.tools.host_arch}.AppImage"
 
     @property
-    def download_url(self):
+    def download_url(self) -> str:
         return (
             "https://github.com/linuxdeploy/linuxdeploy/"
             f"releases/download/continuous/{self.file_name}"
         )
 
     @property
-    def plugins(self):
+    def plugins(self) -> dict[str, type[LinuxDeployPluginBase]]:
         """The known linuxdeploy plugins."""
         return {
             "gtk": LinuxDeployGtkPlugin,
             "qt": LinuxDeployQtPlugin,
         }
 
-    def verify_plugins(self, plugin_definitions, bundle_path):
+    def verify_plugins(
+        self,
+        plugin_definitions: list[str],
+        bundle_path: Path,
+    ) -> dict[str, LinuxDeployPluginBase]:
         """Verify that all the declared plugin dependencies are available.
 
         Each plugin definition is a string, and can be:
@@ -346,10 +353,8 @@ class LinuxDeploy(LinuxDeployBase, Tool):
         `FOO` in the environment. The definition will be split the same way as
         shell arguments, so spaces should be escaped.
 
-        :param plugin_definitions: A list of strings defining the required
-            plugins.
-        :param bundle_path: The location of the app bundle that requires the
-            plugins.
+        :param plugin_definitions: A list of strings defining the required plugins.
+        :param bundle_path: The location of the app bundle that requires the plugins.
         :returns: A dictionary of plugin ID->instantiated plugin instances.
         """
         plugins = {}
@@ -362,7 +367,6 @@ class LinuxDeploy(LinuxDeployBase, Tool):
             try:
                 plugin_klass = self.plugins[plugin_name]
                 self.tools.logger.info(f"Using default {plugin_name} plugin")
-
                 plugin = plugin_klass.verify(self.tools)
             except KeyError:
                 if plugin_name.startswith(("https://", "http://")):
diff --git a/src/briefcase/integrations/rcedit.py b/src/briefcase/integrations/rcedit.py
index 60b59b6c..df6b931d 100644
--- a/src/briefcase/integrations/rcedit.py
+++ b/src/briefcase/integrations/rcedit.py
@@ -1,26 +1,28 @@
+from __future__ import annotations
+
+from pathlib import Path
+
 from briefcase.exceptions import MissingToolError
-from briefcase.integrations.base import Tool, ToolCache
+from briefcase.integrations.base import ManagedTool, ToolCache
 
 
-class RCEdit(Tool):
+class RCEdit(ManagedTool):
     name = "rcedit"
     full_name = "RCEdit"
-
-    def __init__(self, tools: ToolCache):
-        self.tools = tools
+    supported_host_os = {"Windows"}
 
     @property
-    def download_url(self):
+    def download_url(self) -> str:
         return (
             "https://github.com/electron/rcedit/releases/download/v1.1.1/rcedit-x64.exe"
         )
 
     @property
-    def rcedit_path(self):
+    def rcedit_path(self) -> Path:
         return self.tools.base_path / "rcedit-x64.exe"
 
     @classmethod
-    def verify(cls, tools: ToolCache, install=True):
+    def verify_install(cls, tools: ToolCache, install: bool = True, **kwargs) -> RCEdit:
         """Verify that rcedit is available.
 
         :param tools: ToolCache of available tools
@@ -32,7 +34,7 @@ class RCEdit(Tool):
         if hasattr(tools, "rcedit"):
             return tools.rcedit
 
-        rcedit = RCEdit(tools)
+        rcedit = RCEdit(tools=tools)
 
         if not rcedit.exists():
             if install:
@@ -47,13 +49,9 @@ class RCEdit(Tool):
         tools.rcedit = rcedit
         return rcedit
 
-    def exists(self):
+    def exists(self) -> bool:
         return self.rcedit_path.exists()
 
-    @property
-    def managed_install(self):
-        return True
-
     def install(self):
         """Download and install RCEdit."""
         self.tools.download.file(
@@ -66,11 +64,3 @@ class RCEdit(Tool):
         """Uninstall RCEdit."""
         with self.tools.input.wait_bar("Removing old RCEdit install..."):
             self.rcedit_path.unlink()
-
-    def upgrade(self):
-        """Upgrade an existing RCEdit install."""
-        if not self.exists():
-            raise MissingToolError("RCEdit")
-
-        self.uninstall()
-        self.install()
diff --git a/src/briefcase/integrations/subprocess.py b/src/briefcase/integrations/subprocess.py
index 1ce686ee..f6f589df 100644
--- a/src/briefcase/integrations/subprocess.py
+++ b/src/briefcase/integrations/subprocess.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 import json
 import operator
 import os
@@ -6,8 +8,11 @@ import subprocess
 import sys
 import threading
 import time
+from collections.abc import Callable
 from functools import wraps
 from pathlib import Path
+from subprocess import CompletedProcess
+from typing import Iterator, Mapping, Sequence, TypeVar, Union
 
 import psutil
 
@@ -16,17 +21,22 @@ from briefcase.console import Log
 from briefcase.exceptions import CommandOutputParseError, ParseError
 from briefcase.integrations.base import Tool, ToolCache
 
+SubprocessArgT = Union[str, Path]
+SubprocessArgsT = Sequence[SubprocessArgT]
+JsonT = Union[Mapping[str, "JsonT"], Sequence["JsonT"], str, int, float, bool, None]
+ParserOutputT = TypeVar("ParserOutputT")
+
 
 class StopStreaming(Exception):
     """Raised by streaming filters to terminate the stream."""
 
 
-def ensure_str(text):
+def ensure_str(text: str | bytes) -> str:
     """Returns input text as a string."""
     return text.decode() if isinstance(text, bytes) else str(text)
 
 
-def json_parser(json_output):
+def json_parser(json_output: str) -> JsonT:
     """Wrapper to parse command output as JSON via parse_output.
 
     :param json_output: command output to parse as JSON
@@ -37,7 +47,7 @@ def json_parser(json_output):
         raise ParseError(f"Failed to parse output as JSON: {e}") from e
 
 
-def is_process_dead(pid: int):
+def is_process_dead(pid: int) -> bool:
     """Returns True if a PID is not assigned to a process.
 
     Checking if a PID exists is only a semi-safe proxy to determine
@@ -52,10 +62,10 @@ def is_process_dead(pid: int):
 
 
 def get_process_id_by_command(
-    command_list: list = None,
+    command_list: list[str] | None = None,
     command: str = "",
-    logger: Log = None,
-):
+    logger: Log | None = None,
+) -> int | None:
     """Find a Process ID (PID) a by its command. If multiple processes are found, then
     the most recently created process ID is returned.
 
@@ -72,7 +82,7 @@ def get_process_id_by_command(
     # note: psutil returns None for a process attribute if it is unavailable;
     #   this is most likely to happen for restricted or zombie processes.
     for proc in psutil.process_iter(["cmdline", "create_time", "pid"]):
-        proc_cmdline = proc.info["cmdline"]
+        proc_cmdline: list[str] = proc.info["cmdline"]
         if command_list and proc_cmdline == command_list:
             matching_procs.append(proc.info)
         if command and proc_cmdline and proc_cmdline[0].startswith(command):
@@ -100,17 +110,18 @@ def ensure_console_is_safe(sub_method):
     """
 
     @wraps(sub_method)
-    def inner(sub, args, **kwargs):
+    def inner(sub: Subprocess, sub_args: SubprocessArgsT, *args, **kwargs):
         """Evaluate whether conditions are met to remove any dynamic elements in the
         console before returning control to Subprocess.
 
-        :param sub: Subprocess object
+        :param sub: Bound Subprocess object
+        :param sub_args: command line to run in subprocess
         :param args: list of implicit strings that will be run as subprocess command
         :return: the return value for the Subprocess method
         """
         # Just run the command if no dynamic elements are active
         if not sub.tools.input.is_console_controlled:
-            return sub_method(sub, args, **kwargs)
+            return sub_method(sub, sub_args, *args, **kwargs)
 
         remove_dynamic_elements = False
 
@@ -119,7 +130,7 @@ def ensure_console_is_safe(sub_method):
         # it may prompt the user to abort the script and dynamic elements
         # such as the Wait Bar can hide this message from the user.
         if sub.tools.host_os == "Windows":
-            executable = str(args[0]).strip() if args else ""
+            executable = str(sub_args[0]).strip() if sub_args else ""
             remove_dynamic_elements |= executable.lower().endswith(".bat")
 
         # Release control for commands that cannot be streamed.
@@ -128,9 +139,9 @@ def ensure_console_is_safe(sub_method):
         # Run subprocess command with or without console control
         if remove_dynamic_elements:
             with sub.tools.input.release_console_control():
-                return sub_method(sub, args, **kwargs)
+                return sub_method(sub, sub_args, *args, **kwargs)
         else:
-            return sub_method(sub, args, **kwargs)
+            return sub_method(sub, sub_args, *args, **kwargs)
 
     return inner
 
@@ -138,8 +149,10 @@ def ensure_console_is_safe(sub_method):
 class NativeAppContext(Tool):
     """A wrapper around subprocess for use as an app-bound tool."""
 
+    name = "app_context_subprocess"
+
     @classmethod
-    def verify(cls, tools: ToolCache, app: AppConfig):
+    def verify_install(cls, tools: ToolCache, app: AppConfig, **kwargs) -> Subprocess:
         """Make subprocess available as app-bound tool."""
         # short circuit since already verified and available
         if hasattr(tools[app], "app_context"):
@@ -157,7 +170,7 @@ class Subprocess(Tool):
     full_name = "Subprocess"
 
     def __init__(self, tools: ToolCache):
-        self.tools = tools
+        super().__init__(tools=tools)
         self._subprocess = subprocess
 
     def prepare(self):
@@ -165,7 +178,7 @@ class Subprocess(Tool):
         # This is a no-op; the native subprocess environment is ready-to-use.
         pass
 
-    def full_env(self, overrides):
+    def full_env(self, overrides: dict[str, str]) -> dict[str, str]:
         """Generate the full environment in which the command will run.
 
         :param overrides: The environment passed to the subprocess call;
@@ -176,7 +189,7 @@ class Subprocess(Tool):
             env.update(overrides)
         return env
 
-    def final_kwargs(self, **kwargs):
+    def final_kwargs(self, **kwargs) -> dict[str, str]:
         """Convert subprocess keyword arguments into their final form.
 
         This involves:
@@ -265,17 +278,22 @@ class Subprocess(Tool):
         return kwargs
 
     @classmethod
-    def verify(cls, tools: ToolCache):
+    def verify_install(cls, tools: ToolCache, **kwargs) -> Subprocess:
         """Make subprocess available in tool cache."""
         # short circuit since already verified and available
         if hasattr(tools, "subprocess"):
             return tools.subprocess
 
-        tools.subprocess = Subprocess(tools)
+        tools.subprocess = Subprocess(tools=tools)
         return tools.subprocess
 
     @ensure_console_is_safe
-    def run(self, args, stream_output=True, **kwargs):
+    def run(
+        self,
+        args: SubprocessArgsT,
+        stream_output: bool = True,
+        **kwargs,
+    ) -> CompletedProcess:
         """A wrapper for subprocess.run().
 
         This method implements the behavior of subprocess.run() with some
@@ -407,7 +425,12 @@ class Subprocess(Tool):
 
         return command_result
 
-    def _run_and_stream_output(self, args, check=False, **kwargs):
+    def _run_and_stream_output(
+        self,
+        args: SubprocessArgsT,
+        check: bool = False,
+        **kwargs,
+    ) -> CompletedProcess:
         """Simulate subprocess.run() while streaming output to the console.
 
         This is useful when dynamic screen content is active or output should
@@ -460,7 +483,7 @@ class Subprocess(Tool):
         return subprocess.CompletedProcess(args, return_code, stderr=stderr)
 
     @ensure_console_is_safe
-    def check_output(self, args, quiet=False, **kwargs):
+    def check_output(self, args: SubprocessArgsT, quiet: bool = False, **kwargs) -> str:
         """A wrapper for subprocess.check_output()
 
         The behavior of this method is identical to
@@ -473,6 +496,7 @@ class Subprocess(Tool):
          - The `stderr` argument is defaulted to `stdout` so _all_ output is
            returned and `stderr` isn't unexpectedly printed to the console.
 
+        :param args: commands and its arguments to run via subprocess
         :param quiet: Should the invocation of this command be silent, and
             *not* appear in the logs? This should almost always be False;
             however, for some calls (most notably, calls that are called
@@ -503,7 +527,12 @@ class Subprocess(Tool):
             self._log_return_code(0)
         return cmd_output
 
-    def parse_output(self, output_parser, args, **kwargs):
+    def parse_output(
+        self,
+        output_parser: Callable[[str], ParserOutputT],
+        args: SubprocessArgsT,
+        **kwargs,
+    ) -> ParserOutputT:
         """A wrapper for check_output() where the command output is processed through
         the supplied parser function.
 
@@ -540,7 +569,7 @@ class Subprocess(Tool):
                 self.tools.logger.error(f"    {line}")
             raise CommandOutputParseError(error_reason) from e
 
-    def Popen(self, args, **kwargs):
+    def Popen(self, args: SubprocessArgsT, **kwargs) -> subprocess.Popen:
         """A wrapper for subprocess.Popen()
 
         The behavior of this method is identical to
@@ -561,10 +590,10 @@ class Subprocess(Tool):
 
     def stream_output(
         self,
-        label,
-        popen_process,
-        stop_func=lambda: False,
-        filter_func=None,
+        label: str,
+        popen_process: subprocess.Popen,
+        stop_func: Callable[[], bool] = lambda: False,
+        filter_func: Callable[[str], Iterator[str]] | None = None,
     ):
         """Stream the output of a Popen process until the process exits. If the user
         sends CTRL+C, the process will be terminated.
@@ -612,7 +641,11 @@ class Subprocess(Tool):
                     "Log stream hasn't terminated; log output may be corrupted."
                 )
 
-    def _stream_output_thread(self, popen_process, filter_func):
+    def _stream_output_thread(
+        self,
+        popen_process: subprocess.Popen,
+        filter_func: Callable[[str], Iterator[str]],
+    ):
         """Stream output for a Popen process in a Thread.
 
         :param popen_process: popen process to stream stdout
@@ -637,7 +670,7 @@ class Subprocess(Tool):
                 # readline should always return at least a newline (ie \n) UNLESS
                 # the underlying process is exiting/gone; then "" is returned.
                 if output_line:
-                    if filter_func:
+                    if filter_func is not None:
                         try:
                             for filtered_output in filter_func(
                                 output_line.rstrip("\n")
@@ -655,7 +688,7 @@ class Subprocess(Tool):
             )
             self.tools.logger.capture_stacktrace("Output thread")
 
-    def cleanup(self, label, popen_process):
+    def cleanup(self, label: str, popen_process: subprocess.Popen):
         """Clean up after a Popen process, gracefully terminating if possible; forcibly
         if not.
 
@@ -670,7 +703,7 @@ class Subprocess(Tool):
             self.tools.logger.warning(f"Forcibly killing {label}...")
             popen_process.kill()
 
-    def _log_command(self, args):
+    def _log_command(self, args: SubprocessArgsT):
         """Log the entire console command being executed."""
         self.tools.logger.debug()
         self.tools.logger.debug("Running Command:")
@@ -678,13 +711,13 @@ class Subprocess(Tool):
             f"    {' '.join(shlex.quote(str(arg)) for arg in args)}"
         )
 
-    def _log_cwd(self, cwd):
+    def _log_cwd(self, cwd: str | Path | None):
         """Log the working directory for the  command being executed."""
         effective_cwd = Path.cwd() if cwd is None else cwd
         self.tools.logger.debug("Working Directory:")
         self.tools.logger.debug(f"    {effective_cwd}")
 
-    def _log_environment(self, overrides):
+    def _log_environment(self, overrides: dict[str, str] | None):
         """Log the environment variables overrides prior to command execution.
 
         :param overrides: The explicit environment passed to the subprocess call;
@@ -695,7 +728,7 @@ class Subprocess(Tool):
             for env_var, value in overrides.items():
                 self.tools.logger.debug(f"    {env_var}={value}")
 
-    def _log_output(self, output, stderr=None):
+    def _log_output(self, output: str, stderr: str | None = None):
         """Log the output of the executed command."""
         if output:
             self.tools.logger.debug("Command Output:")
@@ -707,6 +740,6 @@ class Subprocess(Tool):
             for line in ensure_str(stderr).splitlines():
                 self.tools.logger.debug(f"    {line}")
 
-    def _log_return_code(self, return_code):
+    def _log_return_code(self, return_code: int | str):
         """Log the output value of the executed command."""
         self.tools.logger.debug(f"Return code: {return_code}")
diff --git a/src/briefcase/integrations/visualstudio.py b/src/briefcase/integrations/visualstudio.py
index b7493c27..602ae7aa 100644
--- a/src/briefcase/integrations/visualstudio.py
+++ b/src/briefcase/integrations/visualstudio.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 import subprocess
 from pathlib import Path
 
@@ -9,6 +11,7 @@ from briefcase.integrations.subprocess import json_parser
 class VisualStudio(Tool):
     name = "visualstudio"
     full_name = "Visual Studio"
+    supported_host_os = {"Windows"}
     VSCODE_REQUIRED_COMPONENTS = """
     * .NET Desktop Development
       - Default packages
@@ -21,19 +24,19 @@ class VisualStudio(Tool):
         self,
         tools: ToolCache,
         msbuild_path: Path,
-        install_metadata: dict = None,
+        install_metadata: dict[str, str | int | bool] = None,
     ):
-        self.tools = tools
+        super().__init__(tools=tools)
         self._msbuild_path = msbuild_path
         self._install_metadata = install_metadata
 
     @property
-    def msbuild_path(self):
+    def msbuild_path(self) -> Path:
         """The path to the MSBuild executable."""
         return self._msbuild_path
 
     @property
-    def install_metadata(self):
+    def install_metadata(self) -> dict[str, str | int | bool] | None:
         """Metadata about the Visual Studio install.
 
         Will be ``None`` if MSBuild the path to MSBuild has been provided
@@ -44,7 +47,7 @@ class VisualStudio(Tool):
         return self._install_metadata
 
     @classmethod
-    def verify(cls, tools: ToolCache):
+    def verify_install(cls, tools: ToolCache, **kwargs) -> VisualStudio:
         """Verify that Visual Studio is available.
 
         :param tools: ToolCache of available tools
@@ -57,12 +60,9 @@ class VisualStudio(Tool):
 
         visualstudio = None
 
-        # Try running MSBuild, assuming it is on the PATH.
         try:
+            # Try running MSBuild, assuming it is on the PATH.
             tools.subprocess.check_output(["MSBuild.exe", "--version"])
-
-            # Create an explicit VisualStudio, with no install metadata
-            visualstudio = VisualStudio(tools, msbuild_path=Path("MSBuild.exe"))
         except FileNotFoundError:
             # MSBuild isn't on the path
             pass
@@ -70,13 +70,16 @@ class VisualStudio(Tool):
             raise BriefcaseCommandError(
                 "MSBuild is on the path, but Briefcase cannot start it."
             ) from e
+        else:
+            # Create an explicit VisualStudio, with no install metadata
+            visualstudio = VisualStudio(tools=tools, msbuild_path=Path("MSBuild.exe"))
 
         # try to find Visual Studio
         if visualstudio is None:
             # Look for an %MSBUILD% environment variable
             try:
                 msbuild_path = Path(tools.os.environ["MSBUILD"])
-                install_metadata = None
+                install_metadata: dict[str, str | int | bool] = None
 
                 if not msbuild_path.exists():
                     # The location referenced by %MSBUILD% doesn't exist
@@ -94,13 +97,15 @@ or unset the environment variable; then re-run Briefcase.
 
             except KeyError:
                 # No %MSBUILD% environment variable. Look for vswhere.exe
-                vswhere_path = (
-                    Path(tools.os.environ["ProgramFiles(x86)"])
-                    / "Microsoft Visual Studio"
-                    / "Installer"
-                    / "vswhere.exe"
-                )
-                if not vswhere_path.exists():
+                vswhere_path = None
+                if program_files := tools.os.environ.get("ProgramFiles(x86)"):
+                    vswhere_path = (
+                        Path(program_files)
+                        / "Microsoft Visual Studio"
+                        / "Installer"
+                        / "vswhere.exe"
+                    )
+                if vswhere_path is None or not vswhere_path.exists():
                     raise BriefcaseCommandError(
                         f"""\
 Visual Studio does not appear to be installed. Visual Studio 2022 Community
@@ -181,14 +186,10 @@ Then restart Briefcase.
                 )
 
             visualstudio = VisualStudio(
-                tools,
+                tools=tools,
                 msbuild_path=msbuild_path,
                 install_metadata=install_metadata,
             )
 
         tools.visualstudio = visualstudio
         return visualstudio
-
-    @property
-    def managed_install(self):
-        return False
diff --git a/src/briefcase/integrations/windows_sdk.py b/src/briefcase/integrations/windows_sdk.py
index f2176d3c..da920e25 100644
--- a/src/briefcase/integrations/windows_sdk.py
+++ b/src/briefcase/integrations/windows_sdk.py
@@ -2,7 +2,7 @@ from __future__ import annotations
 
 import subprocess
 from pathlib import Path
-from typing import Generator
+from typing import Iterator
 
 # winreg can only be imported on Windows
 try:
@@ -17,6 +17,8 @@ from briefcase.integrations.base import Tool, ToolCache
 class WindowsSDK(Tool):
     name = "windows_sdk"
     full_name = "Windows SDK"
+    supported_host_os = {"Windows"}
+
     SDK_VERSION = "10.0"
     # Oldest supported SDK version is 10.0.15063.0
     SDK_MIN_VERSION = 15063
@@ -47,7 +49,7 @@ class WindowsSDK(Tool):
         :param version: Full SDK version, e.g. 10.0.22621.0
         :param arch: Host architecture for SDK, e.g. x64, arm64, etc
         """
-        self.tools = tools
+        super().__init__(tools=tools)
         self.root_path = root_path
         self.version = version
         self.arch = arch
@@ -69,7 +71,7 @@ class WindowsSDK(Tool):
         return [d.name for d in version_dirs]
 
     @classmethod
-    def _windows_sdks(cls, tools: ToolCache) -> Generator[tuple[Path, str], None, None]:
+    def _windows_sdks(cls, tools: ToolCache) -> Iterator[tuple[Path, str]]:
         """Generator of (path, version) for instances of Windows SDK.
 
         All instances of Windows SDK should reside in the same base directory; this is
@@ -180,7 +182,7 @@ WindowsSDKVersion: {environ_sdk_version}
         return True
 
     @classmethod
-    def verify(cls, tools: ToolCache):
+    def verify_install(cls, tools: ToolCache, **kwargs) -> WindowsSDK:
         """Verify the Windows SDK is installed with needed components.
 
         :param tools: ToolCache of available tools
@@ -192,8 +194,13 @@ WindowsSDKVersion: {environ_sdk_version}
         arch = {"AMD64": "x64", "ARM64": "arm64"}.get(tools.host_arch, tools.host_arch)
 
         sdk = None
-        for sdk_dir, sdk_version in cls._windows_sdks(tools):
-            sdk = WindowsSDK(tools, root_path=sdk_dir, version=sdk_version, arch=arch)
+        for sdk_dir, sdk_version in cls._windows_sdks(tools=tools):
+            sdk = WindowsSDK(
+                tools=tools,
+                root_path=sdk_dir,
+                version=sdk_version,
+                arch=arch,
+            )
 
             if not cls._is_supported_version(sdk):
                 sdk = None
@@ -219,7 +226,3 @@ See https://developer.microsoft.com/en-us/windows/downloads/windows-sdk/ to inst
         tools.logger.debug(f"Using Windows SDK v{sdk.version} at {sdk.root_path}")
         tools.windows_sdk = sdk
         return sdk
-
-    @property
-    def managed_install(self):
-        return False
diff --git a/src/briefcase/integrations/wix.py b/src/briefcase/integrations/wix.py
index c6dd4ac6..5c64d13a 100644
--- a/src/briefcase/integrations/wix.py
+++ b/src/briefcase/integrations/wix.py
@@ -1,22 +1,26 @@
+from __future__ import annotations
+
 import os
 import shutil
 from pathlib import Path
 
-from briefcase.exceptions import (
-    BriefcaseCommandError,
-    MissingToolError,
-    NonManagedToolError,
-)
-from briefcase.integrations.base import Tool, ToolCache
+from briefcase.exceptions import BriefcaseCommandError, MissingToolError
+from briefcase.integrations.base import ManagedTool, ToolCache
 
 WIX_DOWNLOAD_URL = "https://github.com/wixtoolset/wix3/releases/download/wix3112rtm/wix311-binaries.zip"
 
 
-class WiX(Tool):
+class WiX(ManagedTool):
     name = "wix"
     full_name = "WiX"
-
-    def __init__(self, tools: ToolCache, wix_home: Path = None, bin_install=False):
+    supported_host_os = {"Windows"}
+
+    def __init__(
+        self,
+        tools: ToolCache,
+        wix_home: Path | None = None,
+        bin_install: bool = False,
+    ):
         """Create a wrapper around a WiX install.
 
         :param tools: ToolCache of available tools.
@@ -27,7 +31,7 @@ class WiX(Tool):
         :returns: A valid WiX SDK wrapper. If WiX is not available, and was
             not installed, raises MissingToolError.
         """
-        self.tools = tools
+        super().__init__(tools=tools)
         if wix_home:
             self.wix_home = wix_home
         else:
@@ -35,28 +39,28 @@ class WiX(Tool):
         self.bin_install = bin_install
 
     @property
-    def heat_exe(self):
+    def heat_exe(self) -> Path:
         if self.bin_install:
             return self.wix_home / "heat.exe"
         else:
             return self.wix_home / "bin" / "heat.exe"
 
     @property
-    def light_exe(self):
+    def light_exe(self) -> Path:
         if self.bin_install:
             return self.wix_home / "light.exe"
         else:
             return self.wix_home / "bin" / "light.exe"
 
     @property
-    def candle_exe(self):
+    def candle_exe(self) -> Path:
         if self.bin_install:
             return self.wix_home / "candle.exe"
         else:
             return self.wix_home / "bin" / "candle.exe"
 
     @classmethod
-    def verify(cls, tools: ToolCache, install=True):
+    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
@@ -107,7 +111,7 @@ does not point to an install of the WiX Toolset.
         tools.wix = wix
         return wix
 
-    def exists(self):
+    def exists(self) -> bool:
         return (
             self.heat_exe.is_file()
             and self.light_exe.is_file()
@@ -115,7 +119,7 @@ does not point to an install of the WiX Toolset.
         )
 
     @property
-    def managed_install(self):
+    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,
@@ -157,13 +161,3 @@ Delete {wix_zip_path} and run briefcase again.
         """Uninstall WiX."""
         with self.tools.input.wait_bar("Removing old WiX install..."):
             self.tools.shutil.rmtree(self.wix_home)
-
-    def upgrade(self):
-        """Upgrade an existing WiX install."""
-        if not self.managed_install:
-            raise NonManagedToolError("WiX")
-        if not self.exists():
-            raise MissingToolError("WiX")
-
-        self.uninstall()
-        self.install()
diff --git a/src/briefcase/integrations/xcode.py b/src/briefcase/integrations/xcode.py
index eda0c573..9a6ba8a8 100644
--- a/src/briefcase/integrations/xcode.py
+++ b/src/briefcase/integrations/xcode.py
@@ -1,10 +1,12 @@
+from __future__ import annotations
+
 import enum
 import re
 import subprocess
 from pathlib import Path
 
 from briefcase.exceptions import BriefcaseCommandError, CommandOutputParseError
-from briefcase.integrations.base import ToolCache
+from briefcase.integrations.base import Tool, ToolCache
 from briefcase.integrations.subprocess import json_parser
 
 
@@ -15,136 +17,75 @@ class DeviceState(enum.Enum):
     UNKNOWN = 99
 
 
-def verify_xcode_install(tools: ToolCache, min_version: tuple = None):
-    """Verify that Xcode and the command line developer tools are installed and ready
-    for use.
-
-    We need Xcode, *and* the Xcode Command Line Tools. A completely clean
-    machine will have neither Xcode *nor* the Command Line Tools. However,
-    it's possible to install Xcode and *not* install the command line tools,
-    and vice versa.
-
-    We also need to ensure that an adequate version of Xcode is available.
-
-    Then, there is a license that needs to be accepted.
-
-    Lastly, we ensure that the iOS simulator is installed.
-
-    :param tools: ToolCache of available tools
-    :param min_version: The minimum allowed version of Xcode, specified as a
-        tuple of integers (e.g., (11, 2, 1)). Default: ``None``, meaning there
-        is no minimum version.
-    """
-    # short circuit since already verified and available
-    if hasattr(tools, "xcode"):
-        return
-
-    ensure_xcode_is_installed(tools, min_version=min_version)
-    verify_command_line_tools_install(tools)
-    tools.xcode = True
-
-
-def verify_command_line_tools_install(tools: ToolCache):
-    """Verify that command line developer tools are installed and ready for use.
-
-    A completely clean machine will have neither Xcode *nor* the Command Line
-    Tools. However, it's possible to install Xcode and *not* install the command
-    line tools, and vice versa.
-
-    Lastly, there is a license that needs to be accepted.
-
-    :param tools: ToolCache of available tools
-    """
-    # short circuit since already verified and available
-    if hasattr(tools, "xcode_cli"):
-        return
-
-    ensure_command_line_tools_are_installed(tools)
-    confirm_xcode_license_accepted(tools)
-    tools.xcode_cli = True
-
-
-def ensure_command_line_tools_are_installed(tools: ToolCache):
-    """Determine if the Xcode command line tools are installed.
-
-    If they are not installed, an exception is raised; in addition, an OS dialog
-    will be displayed prompting the user to install Xcode.
-
-    :param tools: ToolCache of available tools
-    """
-    # We determine if the command line tools are installed by running:
-    #
-    #   xcode-select --install
-    #
-    # If that command exits with status 0, it means the tools are *not*
-    # installed; but a dialog will be displayed prompting an installation.
-    #
-    # If it returns a status code of 1, the tools are already installed
-    # and outputs the message "command line tools are already installed"
-    #
-    # Any other status code is a problem.
-    try:
-        tools.subprocess.check_output(["xcode-select", "--install"])
-        raise BriefcaseCommandError(
-            """\
-The command line developer tools are not installed.
-
-You should be shown a dialog prompting you to install them. Select "Install"
-to continue, and re-run Briefcase once that installation is complete.
-"""
-        )
-    except subprocess.CalledProcessError as e:
-        if e.returncode != 1:
-            tools.logger.warning(
-                """
-*************************************************************************
-** WARNING: Unable to determine if Xcode is installed                  **
-*************************************************************************
-
-    Briefcase will proceed, assuming everything is OK. If you experience
-    problems, this is almost certainly the cause of those problems.
-
-    Please report this as a bug at:
-
-       https://github.com/beeware/briefcase/issues/new
-
-    In your report, please including the output from running:
-
-        $ xcode-select --install
-
-    from the command prompt.
-
-*************************************************************************
-"""
-            )
-
-
-def ensure_xcode_is_installed(
-    tools: ToolCache,
-    min_version: tuple = None,
-    xcode_location="/Applications/Xcode.app",
-):
-    """Determine if Xcode is installed; and if so, that it meets minimum version
-    requirements.
-
-    Raises an exception if XCode isn't installed, or if the version of Xcode
-    that is installed doesn't meet the minimum requirement.
-
-    :param tools: ToolCache of available tools
-    :param min_version: The minimum allowed version of Xcode, specified as a
-        tuple of integers (e.g., (11, 2, 1)). Default: ``None``, meaning there
-        is no minimum version.
-    :param xcode_location: The location where we expect to find an Xcode install.
-        Used for testing; defaults to ``/Applications/Xcode.app``.
-    """
-    # Check for *any* version of Xcode tools. xcode-select returns:
-    #  * The path to the currently active Xcode install; or
-    #  * error code 2 - No Xcode installation
-    try:
-        tools.subprocess.check_output(["xcode-select", "-p"])
-    except subprocess.CalledProcessError as e:
-        raise BriefcaseCommandError(
-            """\
+class Xcode(Tool):
+    name = "xcode"
+    full_name = "Xcode"
+    supported_host_os = {"Darwin"}
+
+    @classmethod
+    def verify_install(
+        cls,
+        tools: ToolCache,
+        min_version: tuple[int, int] | tuple[int, int, int] | None = None,
+        **kwargs,
+    ) -> Xcode:
+        """Verify that Xcode and the command line developer tools are installed and
+        ready for use.
+
+        We need Xcode, *and* the Xcode Command Line Tools. A completely clean
+        machine will have neither Xcode *nor* the Command Line Tools. However,
+        it's possible to install Xcode and *not* install the command line tools,
+        and vice versa.
+
+        We also need to ensure that an adequate version of Xcode is available.
+
+        Then, there is a license that needs to be accepted.
+
+        Lastly, we ensure that the iOS simulator is installed.
+
+        :param tools: ToolCache of available tools
+        :param min_version: The minimum allowed version of Xcode, specified as a
+            tuple of integers (e.g., (11, 2, 1)). Default: ``None``, meaning there
+            is no minimum version.
+        """
+        # short circuit since already verified and available
+        if hasattr(tools, "xcode"):
+            return tools.xcode
+
+        cls.ensure_xcode_is_installed(tools=tools, min_version=min_version)
+        XcodeCliTools.verify(tools=tools)
+
+        tools.xcode = Xcode(tools=tools)
+        return tools.xcode
+
+    @classmethod
+    def ensure_xcode_is_installed(
+        cls,
+        tools: ToolCache,
+        min_version: tuple[int, int] | tuple[int, int, int] | None = None,
+        xcode_location: str = "/Applications/Xcode.app",
+    ):
+        """Determine if Xcode is installed; and if so, that it meets minimum version
+        requirements.
+
+        Raises an exception if XCode isn't installed, or if the version of Xcode
+        that is installed doesn't meet the minimum requirement.
+
+        :param tools: ToolCache of available tools
+        :param min_version: The minimum allowed version of Xcode, specified as a
+            tuple of integers (e.g., (11, 2, 1)). Default: ``None``, meaning there
+            is no minimum version.
+        :param xcode_location: The location where we expect to find an Xcode install.
+            Used for testing; defaults to ``/Applications/Xcode.app``.
+        """
+        # Check for *any* version of Xcode tools. xcode-select returns:
+        #  * The path to the currently active Xcode install; or
+        #  * error code 2 - No Xcode installation
+        try:
+            tools.subprocess.check_output(["xcode-select", "-p"])
+        except subprocess.CalledProcessError as e:
+            raise BriefcaseCommandError(
+                """\
 Could not find an Xcode installation.
 
 To select an existing Xcode installation, run:
@@ -154,46 +95,46 @@ To select an existing Xcode installation, run:
 or install Xcode from the macOS App Store. Once you have installed Xcode,
 you can re-run Briefcase.
 """
-        ) from e
-
-    try:
-        # xcodebuild -version returns the version of Xcode that is currently
-        # selected. If the current Xcode is a commandline tools install,
-        # returns an error:
-        #   xcode-select: error: tool 'xcodebuild' requires Xcode, but active
-        #   developer directory '/Library/Developer/CommandLineTools' is a
-        #   command line tools instance
-        output = tools.subprocess.check_output(["xcodebuild", "-version"])
-
-        if min_version is not None:
-            # Look for a line in the output that reads "Xcode X.Y.Z"
-            version_lines = [
-                line for line in output.split("\n") if line.startswith("Xcode ")
-            ]
-            if version_lines:
-                # Split the content after the first space
-                # and split that content on the dots.
-                # Append 0's to fill any gaps caused by
-                # version numbers that don't have a minor version.
-                # At this point, version lines *must* have at least one element,
-                # and each line *must* have a string with at least one space,
-                # so if either array lookup fails, something weird is happening.
-                version = tuple(
-                    int(v) for v in version_lines[0].split(" ")[1].split(".")
-                ) + (0, 0)
-
-                if version < min_version:
-                    min_version = ".".join(str(v) for v in min_version)
-                    version = ".".join(str(v) for v in version)
-                    raise BriefcaseCommandError(
-                        f"Xcode {min_version} is required; {version} is installed. Please update Xcode."
-                    )
-                else:
-                    # Version number is acceptable
-                    return
+            ) from e
 
-            tools.logger.warning(
-                """
+        try:
+            # xcodebuild -version returns the version of Xcode that is currently
+            # selected. If the current Xcode is a commandline tools install,
+            # returns an error:
+            #   xcode-select: error: tool 'xcodebuild' requires Xcode, but active
+            #   developer directory '/Library/Developer/CommandLineTools' is a
+            #   command line tools instance
+            output = tools.subprocess.check_output(["xcodebuild", "-version"])
+
+            if min_version is not None:
+                # Look for a line in the output that reads "Xcode X.Y.Z"
+                version_lines = [
+                    line for line in output.split("\n") if line.startswith("Xcode ")
+                ]
+                if version_lines:
+                    # Split the content after the first space
+                    # and split that content on the dots.
+                    # Append 0's to fill any gaps caused by
+                    # version numbers that don't have a minor version.
+                    # At this point, version lines *must* have at least one element,
+                    # and each line *must* have a string with at least one space,
+                    # so if either array lookup fails, something weird is happening.
+                    version = tuple(
+                        int(v) for v in version_lines[0].split(" ")[1].split(".")
+                    ) + (0, 0)
+
+                    if version < min_version:
+                        min_version = ".".join(str(v) for v in min_version)
+                        version = ".".join(str(v) for v in version)
+                        raise BriefcaseCommandError(
+                            f"Xcode {min_version} is required; {version} is installed. Please update Xcode."
+                        )
+                    else:
+                        # Version number is acceptable
+                        return
+
+                tools.logger.warning(
+                    """
 *************************************************************************
 ** WARNING: Unable to determine the version of Xcode that is installed **
 *************************************************************************
@@ -213,41 +154,41 @@ you can re-run Briefcase.
 
 *************************************************************************
 """
-            )
+                )
 
-    except subprocess.CalledProcessError as e:
-        if " is a command line tools instance" in e.output:
-            # Commandline tools are currently selected. Look for the existence
-            # of the default folder; if that folder doesn't exist, we can't
-            # conclude that Xcode *isn't* installed.
-            if Path(xcode_location).exists():
-                preamble = """\
+        except subprocess.CalledProcessError as e:
+            if " is a command line tools instance" in e.output:
+                # Commandline tools are currently selected. Look for the existence
+                # of the default folder; if that folder doesn't exist, we can't
+                # conclude that Xcode *isn't* installed.
+                if Path(xcode_location).exists():
+                    preamble = """\
 Xcode appears to be installed, but the active developer directory is the Xcode
 command line tools. To make Xcode the active developer directory, run:
 
     $ sudo xcode-select --switch /Applications/Xcode.app
 """
-            else:
-                preamble = """\
+                else:
+                    preamble = """\
 You have the Xcode command line tools installed; however, Briefcase requires
 a full Xcode install. Xcode can be downloaded from the macOS App Store at
 <https://apps.apple.com/au/app/xcode/id497799835?mt=12>.
 """
 
-            raise BriefcaseCommandError(
-                preamble
-                + """
+                raise BriefcaseCommandError(
+                    preamble
+                    + """
 Or, to use a version of Xcode installed in a non-default location:
 
     $ sudo xcode-select --switch /path/to/Xcode.app
 
 and then re-run Briefcase.
 """
-            ) from e
+                ) from e
 
-        else:
-            raise BriefcaseCommandError(
-                """\
+            else:
+                raise BriefcaseCommandError(
+                    """\
 An Xcode install appears to exist, but Briefcase was unable to
 determine the current Xcode version. Running:
 
@@ -258,23 +199,106 @@ should return the current Xcode version, but it raised an error.
 You may need to re-install Xcode. Re-run Briefcase once that
 installation is complete.
 """
-            ) from e
+                ) from e
+
+
+class XcodeCliTools(Tool):
+    name = "xcode_cli"
+    full_name = "Xcode Command Line Tools"
+    supported_host_os = {"Darwin"}
+
+    @classmethod
+    def verify_install(cls, tools: ToolCache, **kwargs) -> XcodeCliTools:
+        """Verify that command line developer tools are installed and ready for use.
+
+        A completely clean machine will have neither Xcode *nor* the Command Line
+        Tools. However, it's possible to install Xcode and *not* install the command
+        line tools, and vice versa.
+
+        Lastly, there is a license that needs to be accepted.
+
+        :param tools: ToolCache of available tools
+        """
+        # short circuit since already verified and available
+        if hasattr(tools, "xcode_cli"):
+            return tools.xcode_cli
+
+        cls.ensure_command_line_tools_are_installed(tools=tools)
+        cls.confirm_xcode_license_accepted(tools=tools)
+
+        tools.xcode_cli = XcodeCliTools(tools=tools)
+        return tools.xcode_cli
+
+    @classmethod
+    def ensure_command_line_tools_are_installed(cls, tools: ToolCache):
+        """Determine if the Xcode command line tools are installed.
+
+        If they are not installed, an exception is raised; in addition, an OS dialog
+        will be displayed prompting the user to install Xcode.
+
+        :param tools: ToolCache of available tools
+        """
+        # We determine if the command line tools are installed by running:
+        #
+        #   xcode-select --install
+        #
+        # If that command exits with status 0, it means the tools are *not*
+        # installed; but a dialog will be displayed prompting an installation.
+        #
+        # If it returns a status code of 1, the tools are already installed
+        # and outputs the message "command line tools are already installed"
+        #
+        # Any other status code is a problem.
+        try:
+            tools.subprocess.check_output(["xcode-select", "--install"])
+            raise BriefcaseCommandError(
+                """\
+The command line developer tools are not installed.
+
+You should be shown a dialog prompting you to install them. Select "Install"
+to continue, and re-run Briefcase once that installation is complete.
+"""
+            )
+        except subprocess.CalledProcessError as e:
+            if e.returncode != 1:
+                tools.logger.warning(
+                    """
+*************************************************************************
+** WARNING: Unable to determine if Xcode is installed                  **
+*************************************************************************
 
+    Briefcase will proceed, assuming everything is OK. If you experience
+    problems, this is almost certainly the cause of those problems.
 
-def confirm_xcode_license_accepted(tools: ToolCache):
-    """Confirm if the Xcode license has been accepted.
+    Please report this as a bug at:
 
-    :param tools: ToolCache of available tools
-    """
-    # Lastly, check if the XCode license has been accepted. The command line
-    # tools return a status code of 69 (nice...) if the license has not been
-    # accepted. In this case, we can prompt the user to accept the license.
-    try:
-        tools.subprocess.check_output(["/usr/bin/clang", "--version"])
-    except subprocess.CalledProcessError as e:
-        if e.returncode == 69:
-            tools.logger.info(
-                """
+       https://github.com/beeware/briefcase/issues/new
+
+    In your report, please including the output from running:
+
+        $ xcode-select --install
+
+    from the command prompt.
+
+*************************************************************************
+"""
+                )
+
+    @classmethod
+    def confirm_xcode_license_accepted(cls, tools: ToolCache):
+        """Confirm if the Xcode license has been accepted.
+
+        :param tools: ToolCache of available tools
+        """
+        # Lastly, check if the XCode license has been accepted. The command line
+        # tools return a status code of 69 (nice...) if the license has not been
+        # accepted. In this case, we can prompt the user to accept the license.
+        try:
+            tools.subprocess.check_output(["/usr/bin/clang", "--version"])
+        except subprocess.CalledProcessError as e:
+            if e.returncode == 69:
+                tools.logger.info(
+                    """
 Use of Xcode and the iOS developer tools are covered by a license that must be
 accepted before you can use those tools.
 
@@ -286,37 +310,37 @@ can run this command and accept the license when prompted:
 Briefcase will try to run this command now. You will need to enter your
 password (Briefcase will not store this password anywhere).
 """
-            )
-            try:
-                tools.subprocess.run(
-                    ["sudo", "xcodebuild", "-license"],
-                    check=True,
-                    stream_output=False,
                 )
-            except subprocess.CalledProcessError as e:
-                # status code 1 - sudo fail
-                # status code 69 - license not accepted.
-                if e.returncode == 1:
-                    raise BriefcaseCommandError(
-                        """\
+                try:
+                    tools.subprocess.run(
+                        ["sudo", "xcodebuild", "-license"],
+                        check=True,
+                        stream_output=False,
+                    )
+                except subprocess.CalledProcessError as e:
+                    # status code 1 - sudo fail
+                    # status code 69 - license not accepted.
+                    if e.returncode == 1:
+                        raise BriefcaseCommandError(
+                            """\
 Briefcase was unable to run the Xcode licensing tool. This may be because you
 did not enter your password correctly, or because your account does not have
 administrator privileges on this computer.
 
 You need to accept the Xcode license before Briefcase can package your app.
 """
-                    )
-                elif e.returncode == 69:
-                    raise BriefcaseCommandError(
-                        """\
+                        )
+                    elif e.returncode == 69:
+                        raise BriefcaseCommandError(
+                            """\
 Xcode license has not been accepted. Briefcase cannot continue.
 
 You need to accept the Xcode license before Briefcase can package your app.
 """
-                    )
-                else:
-                    tools.logger.warning(
-                        """
+                        )
+                    else:
+                        tools.logger.warning(
+                            """
 *************************************************************************
 ** WARNING: Unable to determine if the Xcode license has been accepted **
 *************************************************************************
@@ -336,10 +360,10 @@ You need to accept the Xcode license before Briefcase can package your app.
 
 *************************************************************************
 """
-                    )
-        else:
-            tools.logger.warning(
-                """
+                        )
+            else:
+                tools.logger.warning(
+                    """
 *************************************************************************
 ** WARNING: Unable to determine if the Xcode license has been accepted **
 *************************************************************************
@@ -359,14 +383,14 @@ You need to accept the Xcode license before Briefcase can package your app.
 
 *************************************************************************
 """
-            )
+                )
 
 
 def get_simulators(
     tools: ToolCache,
     os_name: str,
-    simulator_location="/Library/Developer/PrivateFrameworks/CoreSimulator.framework/",
-):
+    simulator_location: str = "/Library/Developer/PrivateFrameworks/CoreSimulator.framework/",
+) -> dict[str, str]:
     """Obtain the simulators available on this machine.
 
     The return value is a 2 level dictionary. The outer dictionary is
@@ -439,7 +463,7 @@ Press Return to continue: """
         raise BriefcaseCommandError("Unable to run xcrun simctl.") from e
 
 
-def get_device_state(tools: ToolCache, udid: str):
+def get_device_state(tools: ToolCache, udid: str) -> str:
     """Determine the state of an iOS simulator device.
 
     :param tools: ToolCache of available tools
@@ -474,7 +498,7 @@ def get_device_state(tools: ToolCache, udid: str):
 IDENTITY_RE = re.compile(r"\s*\d+\) ([0-9A-F]{40}) \"(.*)\"")
 
 
-def get_identities(tools: ToolCache, policy: str):
+def get_identities(tools: ToolCache, policy: str) -> dict[str, str]:
     """Obtain a set of valid identities for the given policy.
 
     :param tools: ToolCache of available tools
diff --git a/src/briefcase/platforms/iOS/__init__.py b/src/briefcase/platforms/iOS/__init__.py
index 59cc09f6..c026ab93 100644
--- a/src/briefcase/platforms/iOS/__init__.py
+++ b/src/briefcase/platforms/iOS/__init__.py
@@ -1,4 +1,4 @@
-from briefcase.integrations.xcode import verify_xcode_install
+from briefcase.integrations.xcode import Xcode
 
 DEFAULT_OUTPUT_FORMAT = "Xcode"
 
@@ -11,10 +11,7 @@ class iOSMixin:
     )
 
     def verify_tools(self):
-        # Require XCode 10.0.0. There's no particular reason for this
-        # specific version, other than it's a nice round number that's
-        # not *that* old at time of writing.
-        verify_xcode_install(self.tools, min_version=(10, 0, 0))
+        Xcode.verify(self.tools, min_version=(13, 0, 0))
 
         # Verify superclass tools *after* xcode. This ensures we get the
         # git check *after* the xcode check.
diff --git a/src/briefcase/platforms/linux/flatpak.py b/src/briefcase/platforms/linux/flatpak.py
index 3f8cdaee..9a124d5e 100644
--- a/src/briefcase/platforms/linux/flatpak.py
+++ b/src/briefcase/platforms/linux/flatpak.py
@@ -187,7 +187,6 @@ class LinuxFlatpakRunCommand(LinuxFlatpakMixin, RunCommand):
         # Start the app in a way that lets us stream the logs
         app_popen = self.tools.flatpak.run(
             bundle_identifier=app.bundle_identifier,
-            app_name=app.app_name,
             args=passthrough,
             **kwargs,
         )
diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py
index 049d7ef0..0d4878f5 100644
--- a/src/briefcase/platforms/macOS/__init__.py
+++ b/src/briefcase/platforms/macOS/__init__.py
@@ -13,10 +13,7 @@ from briefcase.config import BaseConfig
 from briefcase.console import select_option
 from briefcase.exceptions import BriefcaseCommandError
 from briefcase.integrations.subprocess import get_process_id_by_command, is_process_dead
-from briefcase.integrations.xcode import (
-    get_identities,
-    verify_command_line_tools_install,
-)
+from briefcase.integrations.xcode import XcodeCliTools, get_identities
 
 try:
     import dmgbuild
@@ -442,7 +439,7 @@ class macOSPackageMixin(macOSSigningMixin):
 
     def verify_tools(self):
         # Require the XCode command line tools.
-        verify_command_line_tools_install(self.tools)
+        XcodeCliTools.verify(tools=self.tools)
 
         # Verify superclass tools *after* xcode. This ensures we get the
         # git check *after* the xcode check.
diff --git a/src/briefcase/platforms/macOS/xcode.py b/src/briefcase/platforms/macOS/xcode.py
index df23deb1..5c8c24b3 100644
--- a/src/briefcase/platforms/macOS/xcode.py
+++ b/src/briefcase/platforms/macOS/xcode.py
@@ -11,7 +11,7 @@ from briefcase.commands import (
 )
 from briefcase.config import BaseConfig
 from briefcase.exceptions import BriefcaseCommandError
-from briefcase.integrations.xcode import verify_xcode_install
+from briefcase.integrations.xcode import Xcode
 from briefcase.platforms.macOS import macOSMixin, macOSPackageMixin, macOSRunMixin
 
 
@@ -23,10 +23,7 @@ class macOSXcodeMixin(macOSMixin):
     )
 
     def verify_tools(self):
-        # Require XCode 10.0.0. There's no particular reason for this
-        # specific version, other than it's a nice round number that's
-        # not *that* old at time of writing.
-        verify_xcode_install(self.tools, min_version=(10, 0, 0))
+        Xcode.verify(self.tools, min_version=(13, 0, 0))
 
         # Verify superclass tools *after* xcode. This ensures we get the
         # git check *after* the xcode check.
diff --git a/src/briefcase/platforms/windows/__init__.py b/src/briefcase/platforms/windows/__init__.py
index dee57622..fbd2c18f 100644
--- a/src/briefcase/platforms/windows/__init__.py
+++ b/src/briefcase/platforms/windows/__init__.py
@@ -158,9 +158,9 @@ class WindowsPackageCommand(PackageCommand):
 
     def verify_tools(self):
         super().verify_tools()
-        WiX.verify(self.tools)
+        WiX.verify(tools=self.tools)
         if self._windows_sdk_needed:
-            WindowsSDK.verify(self.tools)
+            WindowsSDK.verify(tools=self.tools)
 
     def add_options(self, parser):
         super().add_options(parser)
diff --git a/src/briefcase/platforms/windows/visualstudio.py b/src/briefcase/platforms/windows/visualstudio.py
index 638fc85f..757fb58d 100644
--- a/src/briefcase/platforms/windows/visualstudio.py
+++ b/src/briefcase/platforms/windows/visualstudio.py
@@ -38,7 +38,7 @@ class WindowsVisualStudioBuildCommand(WindowsVisualStudioMixin, BuildCommand):
 
     def verify_tools(self):
         super().verify_tools()
-        VisualStudio.verify(self.tools)
+        VisualStudio.verify(tools=self.tools)
 
     def build_app(self, app: BaseConfig, **kwargs):
         """Build the Visual Studio project.


Test patch:
diff --git a/tests/commands/conftest.py b/tests/commands/conftest.py
new file mode 100644
index 00000000..9a7dd26f
--- /dev/null
+++ b/tests/commands/conftest.py
@@ -0,0 +1,11 @@
+from unittest.mock import MagicMock
+
+import git as git_
+import pytest
+
+
+@pytest.fixture
+def mock_git():
+    git = MagicMock(spec_set=git_)
+    git.exc = git_.exc
+    return git
diff --git a/tests/commands/create/conftest.py b/tests/commands/create/conftest.py
index fe99dba6..0971ca52 100644
--- a/tests/commands/create/conftest.py
+++ b/tests/commands/create/conftest.py
@@ -7,11 +7,22 @@ from cookiecutter.main import cookiecutter
 from briefcase.commands import CreateCommand
 from briefcase.config import AppConfig
 from briefcase.console import Console, Log
+from briefcase.integrations.base import Tool
 from briefcase.integrations.subprocess import Subprocess
 
 from ...utils import DummyConsole, create_file
 
 
+@pytest.fixture
+def monkeypatch_tool_host_os(monkeypatch):
+    """Add testing host OS as supported for tools that support all platforms."""
+    monkeypatch.setattr(
+        Tool,
+        "supported_host_os",
+        Tool.supported_host_os.union({"c64"}),
+    )
+
+
 class DefaultCreateCommand(CreateCommand):
     # An instance of CreateCommand that inherits the default
     # behavior of create handling.
@@ -132,7 +143,7 @@ class TrackingCreateCommand(DummyCreateCommand):
 
 
 @pytest.fixture
-def create_command(tmp_path, mock_git):
+def create_command(tmp_path, mock_git, monkeypatch_tool_host_os):
     return DummyCreateCommand(
         base_path=tmp_path / "base_path",
         data_path=tmp_path / "data",
@@ -142,7 +153,7 @@ def create_command(tmp_path, mock_git):
 
 
 @pytest.fixture
-def tracking_create_command(tmp_path, mock_git):
+def tracking_create_command(tmp_path, mock_git, monkeypatch_tool_host_os):
     return TrackingCreateCommand(
         git=mock_git,
         base_path=tmp_path / "base_path",
diff --git a/tests/commands/create/test_call.py b/tests/commands/create/test_call.py
index 8e40ec99..0ee8b994 100644
--- a/tests/commands/create/test_call.py
+++ b/tests/commands/create/test_call.py
@@ -1,6 +1,6 @@
 import pytest
 
-from briefcase.commands.create import git
+from briefcase.commands.create import Git
 from briefcase.exceptions import BriefcaseCommandError
 
 
@@ -10,7 +10,7 @@ def test_no_git(tracking_create_command, monkeypatch):
     def monkeypatch_verify_git(*a, **kw):
         raise BriefcaseCommandError("Briefcase requires git, but it is not installed")
 
-    monkeypatch.setattr(git, "verify_git_is_installed", monkeypatch_verify_git)
+    monkeypatch.setattr(Git, "verify", monkeypatch_verify_git)
 
     # The command will fail tool verification.
     with pytest.raises(
diff --git a/tests/commands/new/test_call.py b/tests/commands/new/test_call.py
index 44293528..8e3e1890 100644
--- a/tests/commands/new/test_call.py
+++ b/tests/commands/new/test_call.py
@@ -1,6 +1,6 @@
 import pytest
 
-from briefcase.commands.create import git
+from briefcase.commands.create import Git
 from briefcase.exceptions import BriefcaseCommandError
 
 
@@ -10,7 +10,7 @@ def test_no_git(new_command, monkeypatch):
     def monkeypatch_verify_git(*a, **kw):
         raise BriefcaseCommandError("Briefcase requires git, but it is not installed")
 
-    monkeypatch.setattr(git, "verify_git_is_installed", monkeypatch_verify_git)
+    monkeypatch.setattr(Git, "verify", monkeypatch_verify_git)
 
     # The command will fail tool verification.
     with pytest.raises(
diff --git a/tests/commands/update/test_call.py b/tests/commands/update/test_call.py
index 4cc27559..2e92fae7 100644
--- a/tests/commands/update/test_call.py
+++ b/tests/commands/update/test_call.py
@@ -1,6 +1,6 @@
 import pytest
 
-from briefcase.commands.create import git
+from briefcase.commands.create import Git
 from briefcase.exceptions import BriefcaseCommandError
 
 
@@ -10,7 +10,7 @@ def test_no_git(update_command, monkeypatch):
     def monkeypatch_verify_git(*a, **kw):
         raise BriefcaseCommandError("Briefcase requires git, but it is not installed")
 
-    monkeypatch.setattr(git, "verify_git_is_installed", monkeypatch_verify_git)
+    monkeypatch.setattr(Git, "verify", monkeypatch_verify_git)
 
     # The command will fail tool verification.
     with pytest.raises(
diff --git a/tests/commands/upgrade/conftest.py b/tests/commands/upgrade/conftest.py
index 68a8a6fa..561f6a2b 100644
--- a/tests/commands/upgrade/conftest.py
+++ b/tests/commands/upgrade/conftest.py
@@ -2,9 +2,18 @@ from unittest.mock import MagicMock
 
 import pytest
 
+import briefcase.commands.upgrade
 from briefcase.commands import UpgradeCommand
 from briefcase.console import Console, Log
 from briefcase.exceptions import MissingToolError
+from briefcase.integrations.base import ManagedTool, Tool
+
+
+@pytest.fixture
+def upgrade_command(tmp_path):
+    command = DummyUpgradeCommand(base_path=tmp_path)
+    command.tools.host_os = "wonky"
+    return command
 
 
 class DummyUpgradeCommand(UpgradeCommand):
@@ -18,108 +27,134 @@ class DummyUpgradeCommand(UpgradeCommand):
     output_format = "Dummy"
     description = "Dummy update command"
 
-    def __init__(self, *args, sdks, **kwargs):
+    def __init__(self, *args, **kwargs):
         kwargs.setdefault("logger", Log())
         kwargs.setdefault("console", Console())
         super().__init__(*args, **kwargs)
 
-        self.sdks = sdks
+    def bundle_path(self, app):
+        return self.platform_path / f"{app.app_name}.dummy"
 
     def binary_path(self, app):
         return self.bundle_path(app) / f"{app.app_name}.bin"
 
 
 @pytest.fixture
-def ManagedSDK1():
-    sdk = MagicMock()
-    sdk.verify.return_value = sdk
-    sdk.name = "managed-1"
-    sdk.full_name = "Managed 1"
-    sdk.exists.return_value = True
-    sdk.managed_install = True
-    # No plugins defined on SDK1
-    sdk.plugins.values.side_effect = AttributeError
-    return sdk
+def mock_tool_registry(monkeypatch):
+    """Tool registry with all dummy tools."""
+    tool_list = [
+        DummyTool,
+        DummyManagedTool1,
+        DummyManagedTool2,
+        DummyManagedTool3,
+        DummyUnManagedManagedTool,
+        DummyNotInstalledManagedTool,
+    ]
 
+    tool_registry = dict()
+    for tool in tool_list:
+        monkeypatch.setattr(tool, "verify", MagicMock(wraps=tool.verify))
+        tool_registry[tool.name] = tool
 
-@pytest.fixture
-def ManagedSDK2Plugin1():
-    sdk = MagicMock()
-    sdk.verify.return_value = sdk
-    sdk.name = "managed-2-plugin-1"
-    sdk.full_name = "Managed 2 plugin 1"
-    sdk.exists.return_value = True
-    sdk.managed_install = True
-    return sdk
+    monkeypatch.setattr(briefcase.commands.upgrade, "tool_registry", tool_registry)
 
 
 @pytest.fixture
-def ManagedSDK2Plugin2():
-    sdk = MagicMock()
-    sdk.verify.return_value = sdk
-    sdk.name = "managed-2-plugin-2"
-    sdk.full_name = "Managed 2 plugin 2"
-    sdk.exists.return_value = True
-    sdk.managed_install = True
-    return sdk
+def mock_no_managed_tool_registry(monkeypatch):
+    """Tool registry without any installed managed tools."""
+    tool_list = [
+        DummyTool,
+        DummyUnManagedManagedTool,
+        DummyNotInstalledManagedTool,
+    ]
 
+    tool_registry = dict()
+    for tool in tool_list:
+        monkeypatch.setattr(tool, "verify", MagicMock(wraps=tool.verify))
+        tool_registry[tool.name] = tool
 
-@pytest.fixture
-def ManagedSDK2Plugin3():
-    sdk = MagicMock()
-    sdk.verify.return_value = sdk
-    sdk.name = "managed-2-plugin-3"
-    sdk.full_name = "Managed 2 plugin 3"
-    sdk.exists.return_value = False
-    sdk.verify.side_effect = MissingToolError("managed-2-plugin-3")
-    sdk.managed_install = True
-    return sdk
+    monkeypatch.setattr(briefcase.commands.upgrade, "tool_registry", tool_registry)
 
 
-@pytest.fixture
-def ManagedSDK2(ManagedSDK2Plugin1, ManagedSDK2Plugin2, ManagedSDK2Plugin3):
-    sdk = MagicMock()
-    sdk.verify.return_value = sdk
-    sdk.name = "managed-2"
-    sdk.full_name = "Managed 2"
-    sdk.exists.return_value = True
-    sdk.managed_install = True
-    sdk.plugins = {
-        "managed2-plugin1": ManagedSDK2Plugin1,
-        "managed2-plugin2": ManagedSDK2Plugin2,
-        "managed2-plugin3": ManagedSDK2Plugin3,
-    }
-    return sdk
+class DummyToolBase(Tool):
+    name = "dummy_tool_base"
+    supported_host_os = {"wonky"}
 
+    @classmethod
+    def verify_install(cls, tools, **kwargs):
+        return cls(tools=tools)
 
-@pytest.fixture
-def NonManagedSDK():
-    sdk = MagicMock()
-    sdk.verify.return_value = sdk
-    sdk.name = "non-managed"
-    sdk.full_name = "Non Managed"
-    sdk.exists.return_value = True
-    sdk.managed_install = False
-    return sdk
 
+class DummyManagedToolBase(ManagedTool):
+    name = "dummy_managed_tool_base"
+    supported_host_os = {"wonky"}
 
-@pytest.fixture
-def NonInstalledSDK():
-    sdk = MagicMock()
-    sdk.name = "non-installed"
-    sdk.full_name = "Non Installed"
-    sdk.verify.side_effect = MissingToolError("non-installed")
-    return sdk
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.actions = []
 
+    @classmethod
+    def verify_install(cls, tools, **kwargs):
+        # add to ToolCache so accessible after upgrade
+        setattr(tools, cls.name, cls(tools=tools))
+        return getattr(tools, cls.name)
 
-@pytest.fixture
-def upgrade_command(tmp_path, ManagedSDK1, ManagedSDK2, NonManagedSDK, NonInstalledSDK):
-    return DummyUpgradeCommand(
-        base_path=tmp_path,
-        sdks=[
-            ManagedSDK1,
-            NonManagedSDK,
-            NonInstalledSDK,
-            ManagedSDK2,
-        ],
-    )
+    def exists(self) -> bool:
+        self.actions.append("exists")
+        return True
+
+    def install(self):
+        self.actions.append("install")
+
+    def uninstall(self):
+        self.actions.append("uninstall")
+
+
+class DummyTool(DummyToolBase):
+    """Unmanaged Tool testing class."""
+
+    name = "unmanaged"
+    full_name = "Unmanaged Dummy Tool"
+
+
+class DummyUnManagedManagedTool(DummyManagedToolBase):
+    """Managed Tool without a managed install testing class."""
+
+    name = "unmanaged_managed"
+    full_name = "Unmanaged Managed Dummy Tool"
+
+    @property
+    def managed_install(self) -> bool:
+        return False
+
+
+class DummyNotInstalledManagedTool(DummyManagedToolBase):
+    """Managed Tool without a managed install testing class."""
+
+    name = "not_installed"
+    full_name = "Not Installed Managed Dummy Tool"
+
+    @classmethod
+    def verify_install(cls, tools, **kwargs):
+        raise MissingToolError(cls.full_name)
+
+
+class DummyManagedTool1(DummyManagedToolBase):
+    """Managed Tool testing class."""
+
+    name = "managed_1"
+    full_name = "Managed Dummy Tool 1"
+
+
+class DummyManagedTool2(DummyManagedToolBase):
+    """Managed Tool testing class."""
+
+    name = "managed_2"
+    full_name = "Managed Dummy Tool 2"
+
+
+class DummyManagedTool3(DummyManagedToolBase):
+    """Managed Tool testing class."""
+
+    name = "managed_3"
+    full_name = "Managed Dummy Tool 3"
diff --git a/tests/commands/upgrade/test_call.py b/tests/commands/upgrade/test_call.py
index 14d03a8a..ab6126ec 100644
--- a/tests/commands/upgrade/test_call.py
+++ b/tests/commands/upgrade/test_call.py
@@ -1,200 +1,288 @@
 import pytest
 
-from briefcase.exceptions import BriefcaseCommandError
-
-
-def test_list_tools(
-    upgrade_command,
-    ManagedSDK1,
-    ManagedSDK2,
-    ManagedSDK2Plugin1,
-    ManagedSDK2Plugin2,
-    ManagedSDK2Plugin3,
-    NonManagedSDK,
-    NonInstalledSDK,
-    capsys,
-):
-    """The tools for upgrade can be listed."""
+from briefcase.exceptions import UpgradeToolError
+
+from .conftest import (
+    DummyManagedTool1,
+    DummyManagedTool2,
+    DummyManagedTool3,
+    DummyNotInstalledManagedTool,
+    DummyTool,
+    DummyUnManagedManagedTool,
+)
+
 
+def test_list_tools(upgrade_command, mock_tool_registry, capsys):
+    """The tools for upgrade can be listed."""
     upgrade_command(tool_list=[], list_tools=True)
 
-    # The tools are all verified
-    ManagedSDK1.verify.assert_called_with(upgrade_command.tools, install=False)
-    ManagedSDK2.verify.assert_called_with(upgrade_command.tools, install=False)
-    ManagedSDK2Plugin1.verify.assert_called_with(upgrade_command.tools, install=False)
-    ManagedSDK2Plugin2.verify.assert_called_with(upgrade_command.tools, install=False)
-    ManagedSDK2Plugin3.verify.assert_called_with(upgrade_command.tools, install=False)
-    NonManagedSDK.verify.assert_called_with(upgrade_command.tools, install=False)
-    NonInstalledSDK.verify.assert_called_with(upgrade_command.tools, install=False)
-
-    # The console contains the lines we expect, but not the non-managed and
-    # non-installed tools.
-    out = capsys.readouterr().out
-    assert " - managed-1" in out
-    assert " - managed-2" in out
-    assert " - managed-2-plugin-1" in out
-    assert " - managed-2-plugin-2" in out
-    assert " - managed-2-plugin-3" not in out
-    assert " - non-managed" not in out
-    assert " - non-installed" not in out
-
-
-def test_list_specific_tools(
-    upgrade_command,
-    ManagedSDK1,
-    ManagedSDK2,
-    NonManagedSDK,
-    NonInstalledSDK,
-    capsys,
-):
+    # Tools that are *not* relevant to this upgrade call are not verified
+    DummyTool.verify.assert_not_called()
+
+    # Tools that *are* relevant to this upgrade call are verified
+    DummyUnManagedManagedTool.verify.assert_called_once_with(
+        tools=upgrade_command.tools, install=False
+    )
+    DummyNotInstalledManagedTool.verify.assert_called_once_with(
+        tools=upgrade_command.tools, install=False
+    )
+    DummyManagedTool1.verify.assert_called_once_with(
+        tools=upgrade_command.tools, install=False
+    )
+    DummyManagedTool2.verify.assert_called_once_with(
+        tools=upgrade_command.tools, install=False
+    )
+    DummyManagedTool3.verify.assert_called_once_with(
+        tools=upgrade_command.tools, install=False
+    )
+
+    assert capsys.readouterr().out == (
+        "\n"
+        "[upgrade] Briefcase is managing the following tools:\n"
+        " - Managed Dummy Tool 1 (managed_1)\n"
+        " - Managed Dummy Tool 2 (managed_2)\n"
+        " - Managed Dummy Tool 3 (managed_3)\n"
+    )
+
+
+def test_list_specific_tools(upgrade_command, mock_tool_registry, capsys):
     """If a list of tools is provided, only those are listed."""
+    upgrade_command(tool_list=["managed_1", "managed_2"], list_tools=True)
 
-    upgrade_command(
-        tool_list=["managed-1", "non-managed", "non-installed"], list_tools=True
-    )
-
-    # All tools are verified
-    ManagedSDK1.verify.assert_called_with(upgrade_command.tools, install=False)
-    ManagedSDK2.verify.assert_called_with(upgrade_command.tools, install=False)
-    NonManagedSDK.verify.assert_called_with(upgrade_command.tools, install=False)
-    NonInstalledSDK.verify.assert_called_with(upgrade_command.tools, install=False)
-
-    # The console contains the lines we expect, but not the non-requested,
-    # non-managed, and non-installed tools.
-    out = capsys.readouterr().out
-    assert " - managed-1" in out
-    assert " - managed-2" not in out
-    assert " - non-managed" not in out
-    assert " - non-installed" not in out
-
-
-def test_upgrade_tools(
-    upgrade_command,
-    ManagedSDK1,
-    ManagedSDK2,
-    NonManagedSDK,
-    NonInstalledSDK,
-    capsys,
-):
+    # Tools that are *not* relevant to this upgrade call are not verified
+    DummyTool.verify.assert_not_called()
+    DummyUnManagedManagedTool.verify.assert_not_called()
+    DummyNotInstalledManagedTool.verify.assert_not_called()
+    DummyManagedTool3.verify.assert_not_called()
+
+    # Tools that *are* relevant to this upgrade call are verified
+    DummyManagedTool1.verify.assert_called_once_with(
+        tools=upgrade_command.tools, install=False
+    )
+    DummyManagedTool2.verify.assert_called_once_with(
+        tools=upgrade_command.tools, install=False
+    )
+
+    assert capsys.readouterr().out == (
+        "\n"
+        "[upgrade] Briefcase is managing the following tools:\n"
+        " - Managed Dummy Tool 1 (managed_1)\n"
+        " - Managed Dummy Tool 2 (managed_2)\n"
+    )
+
+
+def test_upgrade_tools(upgrade_command, mock_tool_registry, capsys):
     """All managed tools can be upgraded."""
     upgrade_command(tool_list=[])
 
-    # All tools are verified
-    ManagedSDK1.verify.assert_called_with(upgrade_command.tools, install=False)
-    ManagedSDK2.verify.assert_called_with(upgrade_command.tools, install=False)
-    NonManagedSDK.verify.assert_called_with(upgrade_command.tools, install=False)
-    NonInstalledSDK.verify.assert_called_with(upgrade_command.tools, install=False)
-
-    # The console contains the lines we expect, but not the non-managed and
-    # non-installed tools.
-    out = capsys.readouterr().out
-    assert " - managed-1" in out
-    assert " - managed-2" in out
-    assert " - non-managed" not in out
-    assert " - non-installed" not in out
-
-    # There is also an upgrade message for each tool
-    assert "[managed-1] Upgrading Managed 1..." in out
-    assert "[managed-2] Upgrading Managed 2..." in out
-
-    # The managed tools are upgraded
-    ManagedSDK1.upgrade.assert_called_with()
-    ManagedSDK2.upgrade.assert_called_with()
-    assert NonManagedSDK.upgrade.call_count == 0
-    assert NonInstalledSDK.upgrade.call_count == 0
-
-
-def test_upgrade_specific_tools(
-    upgrade_command,
-    ManagedSDK1,
-    ManagedSDK2,
-    NonManagedSDK,
-    NonInstalledSDK,
-    capsys,
-):
+    # Tools that are *not* relevant to this upgrade call are not verified
+    DummyTool.verify.assert_not_called()
+
+    # Tools that *are* relevant to this upgrade call are verified
+    DummyUnManagedManagedTool.verify.assert_called_once_with(
+        tools=upgrade_command.tools, install=False
+    )
+    DummyNotInstalledManagedTool.verify.assert_called_once_with(
+        tools=upgrade_command.tools, install=False
+    )
+    DummyManagedTool1.verify.assert_called_once_with(
+        tools=upgrade_command.tools, install=False
+    )
+    DummyManagedTool2.verify.assert_called_once_with(
+        tools=upgrade_command.tools, install=False
+    )
+    DummyManagedTool3.verify.assert_called_once_with(
+        tools=upgrade_command.tools, install=False
+    )
+
+    # Tools that are *not* relevant to this upgrade call are not upgraded
+    assert upgrade_command.tools.unmanaged_managed.actions == []
+
+    # Tools that *are* relevant to this upgrade call are upgraded
+    assert upgrade_command.tools.managed_1.actions == [
+        "exists",
+        "uninstall",
+        "install",
+    ]
+    assert upgrade_command.tools.managed_2.actions == [
+        "exists",
+        "uninstall",
+        "install",
+    ]
+    assert upgrade_command.tools.managed_3.actions == [
+        "exists",
+        "uninstall",
+        "install",
+    ]
+
+    assert capsys.readouterr().out == (
+        "\n"
+        "[upgrade] Briefcase will upgrade the following tools:\n"
+        " - Managed Dummy Tool 1 (managed_1)\n"
+        " - Managed Dummy Tool 2 (managed_2)\n"
+        " - Managed Dummy Tool 3 (managed_3)\n"
+        "\n"
+        "[managed_1] Upgrading Managed Dummy Tool 1...\n"
+        "\n"
+        "[managed_2] Upgrading Managed Dummy Tool 2...\n"
+        "\n"
+        "[managed_3] Upgrading Managed Dummy Tool 3...\n"
+    )
+
+
+def test_upgrade_specific_tools(upgrade_command, mock_tool_registry, capsys):
     """If a list of tools is provided, only those are listed."""
+    upgrade_command(tool_list=["managed_1", "managed_2"])
 
-    upgrade_command(
-        tool_list=["managed-1", "non-managed", "non-installed"],
-    )
-
-    # All tools are verified
-    ManagedSDK1.verify.assert_called_with(upgrade_command.tools, install=False)
-    ManagedSDK2.verify.assert_called_with(upgrade_command.tools, install=False)
-    NonManagedSDK.verify.assert_called_with(upgrade_command.tools, install=False)
-    NonInstalledSDK.verify.assert_called_with(upgrade_command.tools, install=False)
-
-    # The console contains the lines we expect, but not the non-requested,
-    # non-managed, and non-installed tools.
-    out = capsys.readouterr().out
-    assert " - managed-1" in out
-    assert " - managed-2" not in out
-    assert " - non-managed" not in out
-    assert " - non-installed" not in out
-
-    # There is also an upgrade message for each tool
-    assert "[managed-1] Upgrading Managed 1..." in out
-
-    # The requested managed tools are upgraded
-    ManagedSDK1.upgrade.assert_called_with()
-    assert ManagedSDK2.upgrade.call_count == 0
-    assert NonManagedSDK.upgrade.call_count == 0
-    assert NonInstalledSDK.upgrade.call_count == 0
-
-
-def test_upgrade_no__tools(
-    upgrade_command,
-    ManagedSDK1,
-    ManagedSDK2,
-    NonManagedSDK,
-    NonInstalledSDK,
-    capsys,
-):
-    """If there is nothing up upgrade, a message is returned."""
+    # Tools that are *not* relevant to this upgrade call are not verified
+    DummyTool.verify.assert_not_called()
+    DummyUnManagedManagedTool.verify.assert_not_called()
+    DummyNotInstalledManagedTool.verify.assert_not_called()
+    DummyManagedTool3.verify.assert_not_called()
 
+    # Tools that *are* relevant to this upgrade call are verified
+    DummyManagedTool1.verify.assert_called_once_with(
+        tools=upgrade_command.tools, install=False
+    )
+    DummyManagedTool2.verify.assert_called_once_with(
+        tools=upgrade_command.tools, install=False
+    )
+
+    # Tools that *are* relevant to this upgrade call are upgraded
+    assert upgrade_command.tools.managed_1.actions == [
+        "exists",
+        "uninstall",
+        "install",
+    ]
+    assert upgrade_command.tools.managed_2.actions == [
+        "exists",
+        "uninstall",
+        "install",
+    ]
+
+    assert capsys.readouterr().out == (
+        "\n"
+        "[upgrade] Briefcase will upgrade the following tools:\n"
+        " - Managed Dummy Tool 1 (managed_1)\n"
+        " - Managed Dummy Tool 2 (managed_2)\n"
+        "\n"
+        "[managed_1] Upgrading Managed Dummy Tool 1...\n"
+        "\n"
+        "[managed_2] Upgrading Managed Dummy Tool 2...\n"
+    )
+
+
+def test_upgrade_no_tools(upgrade_command, mock_no_managed_tool_registry, capsys):
+    """If no tools are being managed, a message is returned."""
+    upgrade_command(tool_list=[])
+
+    # Tools that are *not* relevant to this upgrade call are not verified
+    DummyTool.verify.assert_not_called()
+
+    # Tools that *are* relevant to this upgrade call are verified
+    DummyUnManagedManagedTool.verify.assert_called_once_with(
+        tools=upgrade_command.tools, install=False
+    )
+    DummyNotInstalledManagedTool.verify.assert_called_once_with(
+        tools=upgrade_command.tools, install=False
+    )
+
+    assert capsys.readouterr().out == "Briefcase is not managing any tools.\n"
+
+
+def test_upgrade_unmanaged_tools(upgrade_command, mock_tool_registry, capsys):
+    """If only unmanaged tools are requested to upgrade, error is raised."""
+    with pytest.raises(
+        UpgradeToolError,
+        match="Briefcase is not managing not_installed, unmanaged, unmanaged_managed.",
+    ):
+        upgrade_command(tool_list=["unmanaged", "unmanaged_managed", "not_installed"])
+
+    # Tools that are *not* relevant to this upgrade call are not verified
+    DummyTool.verify.assert_not_called()
+    DummyManagedTool1.verify.assert_not_called()
+    DummyManagedTool2.verify.assert_not_called()
+    DummyManagedTool3.verify.assert_not_called()
+
+    # Tools that *are* relevant to this upgrade call are verified
+    DummyUnManagedManagedTool.verify.assert_called_once_with(
+        tools=upgrade_command.tools, install=False
+    )
+    DummyNotInstalledManagedTool.verify.assert_called_once_with(
+        tools=upgrade_command.tools, install=False
+    )
+
+    assert capsys.readouterr().out == ""
+
+
+def test_upgrade_mixed_tools(upgrade_command, mock_tool_registry, capsys):
+    """If managed and unmanaged tools are requested to upgrade, a warning is shown and
+    the upgrade continues."""
     upgrade_command(
-        tool_list=["non-managed", "non-installed"],
-    )
-
-    # All tools are verified
-    ManagedSDK1.verify.assert_called_with(upgrade_command.tools, install=False)
-    ManagedSDK2.verify.assert_called_with(upgrade_command.tools, install=False)
-    NonManagedSDK.verify.assert_called_with(upgrade_command.tools, install=False)
-    NonInstalledSDK.verify.assert_called_with(upgrade_command.tools, install=False)
-
-    # The console contains no mention of tools...
-    out = capsys.readouterr().out
-    assert " - managed-1" not in out
-    assert " - managed-2" not in out
-    assert " - non-managed" not in out
-    assert " - non-installed" not in out
-
-    # ...but it *does* say there's nothing being managed.
-    assert "Briefcase is not managing any tools." in out
-
-    # Nothing is upgraded
-    assert ManagedSDK1.upgrade.call_count == 0
-    assert ManagedSDK2.upgrade.call_count == 0
-    assert NonManagedSDK.upgrade.call_count == 0
-    assert NonInstalledSDK.upgrade.call_count == 0
-
-
-def test_unknown_tool(
-    upgrade_command,
-    ManagedSDK1,
-    ManagedSDK2,
-    NonManagedSDK,
-    NonInstalledSDK,
-    capsys,
-):
-    """If a list of tools is provided, only those are listed."""
+        tool_list=[
+            "managed_1",
+            "managed_2",
+            "unmanaged",
+            "unmanaged_managed",
+            "not_installed",
+        ]
+    )
+
+    # Tools that are *not* relevant to this upgrade call are not verified
+    DummyTool.verify.assert_not_called()
+    DummyManagedTool3.verify.assert_not_called()
+
+    # Tools that *are* relevant to this upgrade call are verified
+    DummyUnManagedManagedTool.verify.assert_called_once_with(
+        tools=upgrade_command.tools, install=False
+    )
+    DummyNotInstalledManagedTool.verify.assert_called_once_with(
+        tools=upgrade_command.tools, install=False
+    )
+
+    # Tools that are *not* relevant to this upgrade call are not upgraded
+    assert upgrade_command.tools.unmanaged_managed.actions == []
+
+    # Tools that *are* relevant to this upgrade call are upgraded
+    assert upgrade_command.tools.managed_1.actions == [
+        "exists",
+        "uninstall",
+        "install",
+    ]
+    assert upgrade_command.tools.managed_2.actions == [
+        "exists",
+        "uninstall",
+        "install",
+    ]
+
+    assert capsys.readouterr().out == (
+        "Briefcase is not managing not_installed, unmanaged, unmanaged_managed.\n"
+        "\n"
+        "[upgrade] Briefcase will upgrade the following tools:\n"
+        " - Managed Dummy Tool 1 (managed_1)\n"
+        " - Managed Dummy Tool 2 (managed_2)\n"
+        "\n"
+        "[managed_1] Upgrading Managed Dummy Tool 1...\n"
+        "\n"
+        "[managed_2] Upgrading Managed Dummy Tool 2...\n"
+    )
+
+
+def test_unknown_tool(upgrade_command, mock_tool_registry, capsys):
+    """An upgrade attempt for an unknown tool raises an error."""
+
+    with pytest.raises(
+        UpgradeToolError,
+        match="Briefcase does not know how to manage unknown_tool_1, unknown_tool_2.",
+    ):
+        upgrade_command(tool_list=["managed_1", "unknown_tool_1", "unknown_tool_2"])
 
-    # Requesting an unknown tool raises an error
-    with pytest.raises(BriefcaseCommandError):
-        upgrade_command(tool_list=["managed-1", "unknown-tool"])
+    # Tools that are *not* relevant to this upgrade call are not verified
+    DummyTool.verify.assert_not_called()
+    DummyManagedTool1.verify.assert_not_called()
+    DummyManagedTool2.verify.assert_not_called()
+    DummyManagedTool3.verify.assert_not_called()
+    DummyUnManagedManagedTool.verify.assert_not_called()
+    DummyNotInstalledManagedTool.verify.assert_not_called()
 
-    # All tools are still verified
-    ManagedSDK1.verify.assert_called_with(upgrade_command.tools, install=False)
-    ManagedSDK2.verify.assert_called_with(upgrade_command.tools, install=False)
-    NonManagedSDK.verify.assert_called_with(upgrade_command.tools, install=False)
-    NonInstalledSDK.verify.assert_called_with(upgrade_command.tools, install=False)
+    assert capsys.readouterr().out == ""
diff --git a/tests/conftest.py b/tests/conftest.py
index 7bacd676..4f91dcd4 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,11 +1,9 @@
 import inspect
 import subprocess
 import time
-from unittest.mock import ANY, MagicMock
+from unittest.mock import ANY
 
-import git as git_
 import pytest
-from git import exc as git_exceptions
 
 from briefcase.config import AppConfig
 from briefcase.console import Printer
@@ -19,15 +17,9 @@ def pytest_sessionfinish(session, exitstatus):
     Printer.dev_null.close()
 
 
-@pytest.fixture
-def mock_git():
-    git = MagicMock(spec_set=git_)
-    git.exc = git_exceptions
-    return git
-
-
-# alias print() to allow non-briefcase code to use it
+# alias so fixtures can still use them
 _print = print
+_sleep = time.sleep
 
 
 def monkeypatched_print(*args, **kwargs):
@@ -46,16 +38,13 @@ def monkeypatched_print(*args, **kwargs):
 
 @pytest.fixture(autouse=True)
 def no_print(monkeypatch):
-    """Replace builtin print function for ALL tests."""
+    """Replace builtin ``print()`` for ALL tests."""
     monkeypatch.setattr("builtins.print", monkeypatched_print)
 
 
-_sleep = time.sleep
-
-
 @pytest.fixture
 def sleep_zero(monkeypatch):
-    """Replace all calls to ``time.sleep(x)`` with ``time.sleep(0)."""
+    """Replace all calls to ``time.sleep(x)`` with ``time.sleep(0)``."""
     monkeypatch.setattr(time, "sleep", lambda x: _sleep(0))
 
 
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_upgrade.py b/tests/integrations/android_sdk/AndroidSDK/test_upgrade.py
index 1b560a4d..ddb59759 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test_upgrade.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test_upgrade.py
@@ -30,3 +30,14 @@ def test_upgrade_failure(mock_tools, android_sdk):
         check=True,
         stream_output=False,
     )
+
+
+def test_uninstall_does_nothing(mock_tools, android_sdk):
+    """Uninstalling the Android SDK does nothing since it is upgraded in place."""
+    android_sdk.uninstall()
+
+    mock_tools.download.file.assert_not_called()
+    mock_tools.shutil.unpack_archive.assert_not_called()
+    mock_tools.subprocess.Popen.assert_not_called()
+    mock_tools.subprocess.run.assert_not_called()
+    mock_tools.subprocess.check_output.assert_not_called()
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_verify.py b/tests/integrations/android_sdk/AndroidSDK/test_verify.py
index cfddf96f..b6ba106f 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test_verify.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test_verify.py
@@ -5,7 +5,12 @@ from unittest.mock import MagicMock
 
 import pytest
 
-from briefcase.exceptions import BriefcaseCommandError, MissingToolError, NetworkFailure
+from briefcase.exceptions import (
+    BriefcaseCommandError,
+    MissingToolError,
+    NetworkFailure,
+    UnsupportedHostError,
+)
 from briefcase.integrations.android_sdk import AndroidSDK
 from briefcase.integrations.base import ToolCache
 
@@ -62,6 +67,17 @@ def test_short_circuit(mock_tools):
     assert tool == mock_tools.android_sdk
 
 
+def test_unsupported_os(mock_tools):
+    """When host OS is not supported, an error is raised."""
+    mock_tools.host_os = "wonky"
+
+    with pytest.raises(
+        UnsupportedHostError,
+        match=f"{AndroidSDK.name} is not supported on wonky",
+    ):
+        AndroidSDK.verify(mock_tools)
+
+
 def test_succeeds_immediately_in_happy_path(mock_tools, tmp_path):
     """If verify is invoked on a path containing an Android SDK, it does nothing."""
     # If `sdkmanager` exists and has the right permissions, and `android-sdk-license`
diff --git a/tests/integrations/base/conftest.py b/tests/integrations/base/conftest.py
new file mode 100644
index 00000000..75a1e208
--- /dev/null
+++ b/tests/integrations/base/conftest.py
@@ -0,0 +1,9 @@
+import pytest
+
+from briefcase.console import Console, Log
+from briefcase.integrations.base import ToolCache
+
+
+@pytest.fixture
+def simple_tools(tmp_path):
+    return ToolCache(logger=Log(), console=Console(), base_path=tmp_path)
diff --git a/tests/integrations/base/test_ManagedTool.py b/tests/integrations/base/test_ManagedTool.py
new file mode 100644
index 00000000..2fe3b6c9
--- /dev/null
+++ b/tests/integrations/base/test_ManagedTool.py
@@ -0,0 +1,154 @@
+from unittest.mock import MagicMock
+
+import pytest
+
+from briefcase.exceptions import (
+    MissingToolError,
+    NonManagedToolError,
+    UnsupportedHostError,
+)
+from briefcase.integrations.base import ManagedTool
+
+
+class DummyManagedTool(ManagedTool):
+    """Managed Tool testing class."""
+
+    name = "ManagedDummyTool"
+    full_name = "Managed Dummy Tool"
+    supported_host_os = {"wonky"}
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.actions = []
+
+    @classmethod
+    def verify_install(cls, tools, **kwargs):
+        return f"i'm a {cls.name}"
+
+    def exists(self) -> bool:
+        self.actions.append("exists")
+        return True
+
+    def install(self):
+        self.actions.append("install")
+
+    def uninstall(self):
+        self.actions.append("uninstall")
+
+
+class NonExistsManagedTool(DummyManagedTool):
+    def exists(self) -> bool:
+        return False
+
+
+class NonManagedManagedTool(DummyManagedTool):
+    @property
+    def managed_install(self) -> bool:
+        return False
+
+
+@pytest.fixture
+def managed_tool(mock_tools) -> DummyManagedTool:
+    return DummyManagedTool(tools=mock_tools)
+
+
+@pytest.fixture
+def nonexistent_managed_tool(mock_tools) -> NonExistsManagedTool:
+    return NonExistsManagedTool(tools=mock_tools)
+
+
+@pytest.fixture
+def nonmanaged_managed_tool(mock_tools) -> NonManagedManagedTool:
+    return NonManagedManagedTool(tools=mock_tools)
+
+
+@pytest.mark.parametrize(
+    "klass, kwargs",
+    [
+        (DummyManagedTool, {}),
+        (DummyManagedTool, {"one": "two", "three": "four"}),
+    ],
+)
+def test_tool_verify(mock_tools, klass, kwargs, monkeypatch):
+    """Tool verification checks host OS and tool install."""
+    # Wrap verify calls to confirm they were called
+    mock_verify_host = MagicMock(wraps=klass.verify_host)
+    monkeypatch.setattr(klass, "verify_host", mock_verify_host)
+    mock_verify_install = MagicMock(wraps=klass.verify_install)
+    monkeypatch.setattr(klass, "verify_install", mock_verify_install)
+
+    # Mock the dummy tool's supported OS
+    mock_tools.host_os = "wonky"
+
+    tool = klass.verify(tools=mock_tools, **kwargs)
+
+    mock_verify_host.assert_called_once_with(tools=mock_tools)
+    mock_verify_install.assert_called_once_with(
+        tools=mock_tools, app=None, install=True, **kwargs
+    )
+    assert tool == f"i'm a {klass.name}"
+
+
+@pytest.mark.parametrize(
+    "klass, kwargs",
+    [
+        (DummyManagedTool, {}),
+        (DummyManagedTool, {"one": "two", "three": "four"}),
+    ],
+)
+def test_tool_verify_with_app(mock_tools, first_app_config, klass, kwargs, monkeypatch):
+    """App-bound Tool verification checks host OS and tool install."""
+    # Wrap verify calls to confirm they were called
+    mock_verify_host = MagicMock(wraps=klass.verify_host)
+    monkeypatch.setattr(klass, "verify_host", mock_verify_host)
+    mock_verify_install = MagicMock(wraps=klass.verify_install)
+    monkeypatch.setattr(klass, "verify_install", mock_verify_install)
+
+    # Mock the dummy tool's supported OS
+    mock_tools.host_os = "wonky"
+
+    tool = klass.verify(tools=mock_tools, app=first_app_config, **kwargs)
+
+    mock_verify_host.assert_called_once_with(tools=mock_tools)
+    mock_verify_install.assert_called_once_with(
+        tools=mock_tools,
+        app=first_app_config,
+        install=True,
+        **kwargs,
+    )
+    assert tool == "i'm a ManagedDummyTool"
+
+
+def test_tool_unsupported_host_os(mock_tools):
+    """Tool verification fails for unsupported Host OS."""
+    mock_tools.host_os = "not wonky"
+
+    with pytest.raises(
+        UnsupportedHostError,
+        match="ManagedDummyTool is not supported on not wonky",
+    ):
+        DummyManagedTool.verify(tools=mock_tools)
+
+
+def test_managed_install_is_true(managed_tool):
+    """Tool.managed_install defaults False."""
+    assert managed_tool.managed_install is True
+
+
+def test_managed_upgrade(managed_tool):
+    """Order of operations is correct for upgrade."""
+    managed_tool.upgrade()
+
+    assert managed_tool.actions == ["exists", "uninstall", "install"]
+
+
+def test_managed_raises_if_unmanaged(mock_tools, nonmanaged_managed_tool):
+    """If a ManagedTool is unmanaged, upgrade raises."""
+    with pytest.raises(NonManagedToolError):
+        nonmanaged_managed_tool.upgrade()
+
+
+def test_managed_raises_if_not_exists(mock_tools, nonexistent_managed_tool):
+    """If a ManagedTool doesn't exist, upgrade raises."""
+    with pytest.raises(MissingToolError):
+        nonexistent_managed_tool.upgrade()
diff --git a/tests/integrations/base/test_Tool.py b/tests/integrations/base/test_Tool.py
new file mode 100644
index 00000000..79979343
--- /dev/null
+++ b/tests/integrations/base/test_Tool.py
@@ -0,0 +1,88 @@
+from unittest.mock import MagicMock
+
+import pytest
+
+from briefcase.exceptions import UnsupportedHostError
+from briefcase.integrations.base import Tool
+
+
+class DummyTool(Tool):
+    """Unmanaged Tool testing class."""
+
+    name = "UnmanagedDummyTool"
+    full_name = "Unmanaged Dummy Tool"
+    supported_host_os = {"wonky"}
+
+    @classmethod
+    def verify_install(cls, tools, **kwargs):
+        return f"i'm a {cls.name}"
+
+
+@pytest.fixture
+def unmanaged_tool(mock_tools) -> DummyTool:
+    return DummyTool(tools=mock_tools)
+
+
+@pytest.mark.parametrize(
+    "klass, kwargs",
+    [
+        (DummyTool, {}),
+        (DummyTool, {"one": "two", "three": "four"}),
+    ],
+)
+def test_tool_verify(mock_tools, klass, kwargs, monkeypatch):
+    """Tool verification checks host OS and tool install."""
+    # Wrap verify calls to confirm they were called
+    mock_verify_host = MagicMock(wraps=klass.verify_host)
+    monkeypatch.setattr(klass, "verify_host", mock_verify_host)
+    mock_verify_install = MagicMock(wraps=klass.verify_install)
+    monkeypatch.setattr(klass, "verify_install", mock_verify_install)
+
+    # Mock the dummy tool's supported OS
+    mock_tools.host_os = "wonky"
+
+    tool = klass.verify(tools=mock_tools, **kwargs)
+
+    mock_verify_host.assert_called_once_with(tools=mock_tools)
+    mock_verify_install.assert_called_once_with(tools=mock_tools, app=None, **kwargs)
+    assert tool == f"i'm a {klass.name}"
+
+
+@pytest.mark.parametrize(
+    "klass, kwargs",
+    [
+        (DummyTool, {}),
+        (DummyTool, {"one": "two", "three": "four"}),
+    ],
+)
+def test_tool_verify_with_app(mock_tools, first_app_config, klass, kwargs, monkeypatch):
+    """App-bound Tool verification checks host OS and tool install."""
+    # Wrap verify calls to confirm they were called
+    mock_verify_host = MagicMock(wraps=klass.verify_host)
+    monkeypatch.setattr(klass, "verify_host", mock_verify_host)
+    mock_verify_install = MagicMock(wraps=klass.verify_install)
+    monkeypatch.setattr(klass, "verify_install", mock_verify_install)
+
+    # Mock the dummy tool's supported OS
+    mock_tools.host_os = "wonky"
+
+    tool = klass.verify(tools=mock_tools, app=first_app_config, **kwargs)
+
+    mock_verify_host.assert_called_once_with(tools=mock_tools)
+    mock_verify_install.assert_called_once_with(
+        tools=mock_tools,
+        app=first_app_config,
+        **kwargs,
+    )
+    assert tool == "i'm a UnmanagedDummyTool"
+
+
+def test_tool_unsupported_host_os(mock_tools):
+    """Tool verification fails for unsupported Host OS."""
+    mock_tools.host_os = "not wonky"
+
+    with pytest.raises(
+        UnsupportedHostError,
+        match="UnmanagedDummyTool is not supported on not wonky",
+    ):
+        DummyTool.verify(tools=mock_tools)
diff --git a/tests/integrations/base/test_ToolCache.py b/tests/integrations/base/test_ToolCache.py
index 960ac469..0d174bdc 100644
--- a/tests/integrations/base/test_ToolCache.py
+++ b/tests/integrations/base/test_ToolCache.py
@@ -1,7 +1,5 @@
 import importlib
-import inspect
 import os
-import pkgutil
 import platform
 import shutil
 import sys
@@ -13,61 +11,40 @@ from cookiecutter.main import cookiecutter
 
 import briefcase.integrations
 from briefcase.console import Console, Log
-from briefcase.integrations.base import Tool, ToolCache
+from briefcase.integrations.base import ToolCache
 
-
-@pytest.fixture
-def simple_tools(tmp_path):
-    return ToolCache(
-        logger=Log(),
-        console=Console(),
-        base_path=tmp_path,
-    )
-
-
-def tools_for_module(tool_module_name: str) -> set:
-    """Return names of classes that subclass Tool in a module in
-    ``briefcase.integrations``, e.g. "android_sdk"."""
-    return {
-        klass_name
-        for klass_name, _ in inspect.getmembers(
-            sys.modules[f"briefcase.integrations.{tool_module_name}"],
-            lambda klass: (
-                inspect.isclass(klass) and issubclass(klass, Tool) and klass is not Tool
-            ),
-        )
-    }
+from .test_tool_registry import integrations_modules, tools_for_module
 
 
 def test_toolcache_typing():
     """Tool typing for ToolCache is correct."""
-    # Modules in ``briefcase.integrations`` that do not contain tools.
-    nontool_modules = {"base"}
     # Tools that are intentionally not annotated in ToolCache.
     tools_unannotated = {"cookiecutter"}
     # Tool names to exclude from the dynamic annotation checks; they are manually checked.
     tool_names_skip_dynamic_check = {
-        "app_context",
-        "git",
-        "xcode",
-        "xcode_cli",
-        "ETC_OS_RELEASE",
+        "app_context",  # Tested by the Docker module
+        "git",  # An external API, not a Briefcase Tool
+        "xcode_cli",  # Tested by the Xcode module
+        "ETC_OS_RELEASE",  # A constant, not a tool
     }
     # Tool classes to exclude from dynamic annotation checks.
-    tool_klasses_skip_dynamic_checks = {"DockerAppContext", "NativeAppContext"}
+    tool_klasses_skip_dynamic_checks = {
+        "Git",
+        "DockerAppContext",
+        "NativeAppContext",
+        "LinuxDeployQtPlugin",
+        "LinuxDeployGtkPlugin",
+        "LinuxDeployURLPlugin",
+        "LinuxDeployLocalFilePlugin",
+    }
 
     # Ensure all modules containing Tools are exported in ``briefcase.integrations``.
-    tool_modules = {
-        module.name
-        for module in pkgutil.iter_modules(briefcase.integrations.__path__)
-        if module.name not in nontool_modules
-    }
-    assert sorted(tool_modules) == sorted(briefcase.integrations.__all__)
+    assert sorted(integrations_modules()) == sorted(briefcase.integrations.__all__)
 
     # Ensure defined Tool modules/classes are annotated in ToolCache.
     for tool_module_name in briefcase.integrations.__all__:
         if tool_module_name not in tools_unannotated:
-            assert tool_module_name in ToolCache.__annotations__.keys()
+            assert tool_module_name in ToolCache.__annotations__
         for tool_name in tools_for_module(tool_module_name):
             if tool_name not in tool_klasses_skip_dynamic_checks:
                 assert tool_name in ToolCache.__annotations__.values()
@@ -91,11 +68,8 @@ def test_toolcache_typing():
     app_context_annotated = ToolCache.__annotations__["app_context"].split(" | ")
     assert sorted(app_context_klasses) == sorted(app_context_annotated)
 
-    assert ToolCache.__annotations__["xcode"] == "bool"
-    assert ToolCache.__annotations__["xcode_cli"] == "bool"
-
     assert ToolCache.__annotations__["git"] == "git_"
-
+    assert ToolCache.__annotations__["xcode_cli"] == "XcodeCliTools"
     assert ToolCache.__annotations__["ETC_OS_RELEASE"] == "Path"
 
 
diff --git a/tests/integrations/base/test_tool_registry.py b/tests/integrations/base/test_tool_registry.py
new file mode 100644
index 00000000..29839684
--- /dev/null
+++ b/tests/integrations/base/test_tool_registry.py
@@ -0,0 +1,60 @@
+import inspect
+import pkgutil
+import sys
+from typing import Dict, Set, Type
+
+import pytest
+
+import briefcase.integrations
+from briefcase.integrations.base import ManagedTool, Tool, tool_registry
+
+
+def integrations_modules() -> Set[str]:
+    """All modules in ``briefcase.integrations`` irrespective of whether they are
+    defined in ``briefcase.integrations.__all__``"""
+    return {
+        module.name
+        for module in pkgutil.iter_modules(briefcase.integrations.__path__)
+        if module.name not in {"base"}
+    }
+
+
+def tools_for_module(tool_module_name: str) -> Dict[str, Type[Tool]]:
+    """Return classes that subclass Tool in a module in
+    ``briefcase.integrations``, e.g. {"android_sdk": AndroidSDK}."""
+    return dict(
+        inspect.getmembers(
+            sys.modules[f"briefcase.integrations.{tool_module_name}"],
+            lambda klass: (
+                inspect.isclass(klass)
+                and issubclass(klass, (Tool, ManagedTool))
+                and klass not in {Tool, ManagedTool}
+            ),
+        )
+    )
+
+
+@pytest.fixture
+def all_defined_tools() -> Set[Type[Tool]]:
+    """All classes under ``briefcase.integrations`` that subclass Tool."""
+    return {
+        tool
+        for toolset in map(tools_for_module, integrations_modules())
+        for tool in toolset.values()
+    }
+
+
+def test_tool_registry(all_defined_tools, simple_tools):
+    """The Tool Registry must contain all defined Tools."""
+    # test uses subset since registry will contain dummy testing tools
+    assert all_defined_tools.issubset(tool_registry.values())
+
+
+def test_unique_tool_names(all_defined_tools):
+    """All tools must have a unique name."""
+    assert len(all_defined_tools) == len({t.name for t in all_defined_tools})
+
+
+def test_valid_tool_names(all_defined_tools):
+    """All tools must have a name without spaces."""
+    assert all(" " not in t.name for t in all_defined_tools)
diff --git a/tests/integrations/docker/test_DockerAppContext__verify.py b/tests/integrations/docker/test_DockerAppContext__verify.py
index 9f909dc3..fc754b9b 100644
--- a/tests/integrations/docker/test_DockerAppContext__verify.py
+++ b/tests/integrations/docker/test_DockerAppContext__verify.py
@@ -4,7 +4,7 @@ from unittest.mock import MagicMock
 
 import pytest
 
-from briefcase.exceptions import BriefcaseCommandError
+from briefcase.exceptions import BriefcaseCommandError, UnsupportedHostError
 from briefcase.integrations.docker import DockerAppContext
 from briefcase.integrations.subprocess import Subprocess
 
@@ -31,6 +31,17 @@ def test_short_circuit(mock_tools, first_app_config, verify_kwargs):
     assert app_context == mock_tools[first_app_config].app_context
 
 
+def test_unsupported_os(mock_tools, first_app_config, verify_kwargs):
+    """When host OS is not supported, an error is raised."""
+    mock_tools.host_os = "wonky"
+
+    with pytest.raises(
+        UnsupportedHostError,
+        match=f"{DockerAppContext.name} is not supported on wonky",
+    ):
+        DockerAppContext.verify(mock_tools, first_app_config, **verify_kwargs)
+
+
 def test_success(mock_tools, first_app_config, verify_kwargs):
     """Docker app context is successfully created and prepared."""
     mock_tools.subprocess = MagicMock(spec_set=Subprocess)
diff --git a/tests/integrations/docker/test_Docker__verify.py b/tests/integrations/docker/test_Docker__verify.py
index 4b691756..712869a8 100644
--- a/tests/integrations/docker/test_Docker__verify.py
+++ b/tests/integrations/docker/test_Docker__verify.py
@@ -3,7 +3,7 @@ from unittest.mock import MagicMock, call
 
 import pytest
 
-from briefcase.exceptions import BriefcaseCommandError
+from briefcase.exceptions import BriefcaseCommandError, UnsupportedHostError
 from briefcase.integrations.base import ToolCache
 from briefcase.integrations.docker import Docker
 from briefcase.integrations.subprocess import Subprocess
@@ -30,6 +30,17 @@ def test_short_circuit(mock_tools):
     assert tool == mock_tools.docker
 
 
+def test_unsupported_os(mock_tools):
+    """When host OS is not supported, an error is raised."""
+    mock_tools.host_os = "wonky"
+
+    with pytest.raises(
+        UnsupportedHostError,
+        match=f"{Docker.name} is not supported on wonky",
+    ):
+        Docker.verify(mock_tools)
+
+
 @pytest.mark.parametrize("host_os", ["Windows", "Linux", "Darwin"])
 def test_docker_install_url(host_os):
     """Docker details available for each OS."""
diff --git a/tests/integrations/download/test_Download__verify.py b/tests/integrations/download/test_Download__verify.py
index d125c755..28270fd5 100644
--- a/tests/integrations/download/test_Download__verify.py
+++ b/tests/integrations/download/test_Download__verify.py
@@ -1,3 +1,6 @@
+import pytest
+
+from briefcase.exceptions import UnsupportedHostError
 from briefcase.integrations.download import Download
 
 
@@ -11,6 +14,20 @@ def test_short_circuit(mock_tools):
     assert tool == mock_tools.download
 
 
+def test_unsupported_os(mock_tools):
+    """When host OS is not supported, an error is raised."""
+    mock_tools.host_os = "wonky"
+
+    # Delete download since it has already been verified
+    delattr(mock_tools, "download")
+
+    with pytest.raises(
+        UnsupportedHostError,
+        match=f"{Download.name} is not supported on wonky",
+    ):
+        Download.verify(mock_tools)
+
+
 def test_verify(mock_tools):
     """Verifying Download always returns/set download tool."""
     download_tool = Download.verify(mock_tools)
diff --git a/tests/integrations/flatpak/conftest.py b/tests/integrations/flatpak/conftest.py
index 038f5932..14ae8d78 100644
--- a/tests/integrations/flatpak/conftest.py
+++ b/tests/integrations/flatpak/conftest.py
@@ -3,17 +3,23 @@ from unittest.mock import MagicMock
 import pytest
 
 from briefcase.integrations.base import ToolCache
+from briefcase.integrations.download import Download
 from briefcase.integrations.flatpak import Flatpak
 from briefcase.integrations.subprocess import Subprocess
 
 
 @pytest.fixture
 def mock_tools(mock_tools) -> ToolCache:
+    mock_tools.host_os = "Linux"
+    mock_tools.host_arch = "gothic"
+
+    # Mock default tools
     mock_tools.subprocess = MagicMock(spec_set=Subprocess)
+    mock_tools.download = MagicMock(spec_set=Download)
+
     return mock_tools
 
 
 @pytest.fixture
 def flatpak(mock_tools):
-    mock_tools.host_arch = "gothic"
     return Flatpak(tools=mock_tools)
diff --git a/tests/integrations/flatpak/test_Flatpak__run.py b/tests/integrations/flatpak/test_Flatpak__run.py
index 170dda4d..f189a988 100644
--- a/tests/integrations/flatpak/test_Flatpak__run.py
+++ b/tests/integrations/flatpak/test_Flatpak__run.py
@@ -9,10 +9,7 @@ def test_run(flatpak):
     flatpak.tools.subprocess.Popen.return_value = log_popen
 
     # Call run()
-    result = flatpak.run(
-        bundle_identifier="com.example.my-app",
-        app_name="my-app",
-    )
+    result = flatpak.run(bundle_identifier="com.example.my-app")
 
     # The expected call was made
     flatpak.tools.subprocess.Popen.assert_called_once_with(
@@ -39,7 +36,6 @@ def test_run_with_args(flatpak):
     # Call run()
     result = flatpak.run(
         bundle_identifier="com.example.my-app",
-        app_name="my-app",
         args=["foo", "bar"],
     )
 
@@ -70,7 +66,6 @@ def test_main_module_override(flatpak):
     # Call run()
     result = flatpak.run(
         bundle_identifier="com.example.my-app",
-        app_name="my-app",
         main_module="org.beeware.test-case",
     )
 
diff --git a/tests/integrations/flatpak/test_Flatpak__verify.py b/tests/integrations/flatpak/test_Flatpak__verify.py
index 4b64a24d..4f60bea7 100644
--- a/tests/integrations/flatpak/test_Flatpak__verify.py
+++ b/tests/integrations/flatpak/test_Flatpak__verify.py
@@ -3,7 +3,7 @@ from unittest import mock
 
 import pytest
 
-from briefcase.exceptions import BriefcaseCommandError
+from briefcase.exceptions import BriefcaseCommandError, UnsupportedHostError
 from briefcase.integrations.flatpak import Flatpak
 
 
@@ -17,6 +17,18 @@ def test_short_circuit(mock_tools):
     assert tool == mock_tools.flatpak
 
 
+@pytest.mark.parametrize("host_os", ["Darwin", "Windows", "wonky"])
+def test_unsupported_os(mock_tools, host_os):
+    """When host OS is not supported, an error is raised."""
+    mock_tools.host_os = host_os
+
+    with pytest.raises(
+        UnsupportedHostError,
+        match=f"{Flatpak.name} is not supported on {host_os}",
+    ):
+        Flatpak.verify(mock_tools)
+
+
 def test_flatpak_not_installed(mock_tools):
     """If flatpak isn't installed, an error is raised."""
     # Mock the response from a missing tool
diff --git a/tests/integrations/git/__init__.py b/tests/integrations/git/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/integrations/git/test_Git__verify.py b/tests/integrations/git/test_Git__verify.py
new file mode 100644
index 00000000..8e7accfc
--- /dev/null
+++ b/tests/integrations/git/test_Git__verify.py
@@ -0,0 +1,25 @@
+import pytest
+
+from briefcase.exceptions import UnsupportedHostError
+from briefcase.integrations.git import Git
+
+
+def test_short_circuit(mock_tools):
+    """Tool is not created if already cached."""
+    mock_tools.git = "tool"
+
+    tool = Git.verify(mock_tools)
+
+    assert tool == "tool"
+    assert tool == mock_tools.git
+
+
+def test_unsupported_os(mock_tools):
+    """When host OS is not supported, an error is raised."""
+    mock_tools.host_os = "wonky"
+
+    with pytest.raises(
+        UnsupportedHostError,
+        match=f"{Git.name} is not supported on wonky",
+    ):
+        Git.verify(mock_tools)
diff --git a/tests/integrations/java/test_JDK__verify.py b/tests/integrations/java/test_JDK__verify.py
index 925a9fed..2eca486a 100644
--- a/tests/integrations/java/test_JDK__verify.py
+++ b/tests/integrations/java/test_JDK__verify.py
@@ -7,7 +7,12 @@ from unittest import mock
 
 import pytest
 
-from briefcase.exceptions import BriefcaseCommandError, MissingToolError, NetworkFailure
+from briefcase.exceptions import (
+    BriefcaseCommandError,
+    MissingToolError,
+    NetworkFailure,
+    UnsupportedHostError,
+)
 from briefcase.integrations.java import JDK
 
 CALL_JAVA_HOME = mock.call(["/usr/libexec/java_home"])
@@ -28,6 +33,17 @@ def test_short_circuit(mock_tools):
     assert tool == mock_tools.java
 
 
+def test_unsupported_os(mock_tools):
+    """When host OS is not supported, an error is raised."""
+    mock_tools.host_os = "wonky"
+
+    with pytest.raises(
+        UnsupportedHostError,
+        match=f"{JDK.name} is not supported on wonky",
+    ):
+        JDK.verify(mock_tools)
+
+
 def test_macos_tool_java_home(mock_tools, capsys):
     """On macOS, the /usr/libexec/java_home utility is checked."""
     # Mock being on macOS
diff --git a/tests/integrations/linuxdeploy/conftest.py b/tests/integrations/linuxdeploy/conftest.py
index eddc4a20..d95fbb5d 100644
--- a/tests/integrations/linuxdeploy/conftest.py
+++ b/tests/integrations/linuxdeploy/conftest.py
@@ -5,11 +5,13 @@ import pytest
 
 from briefcase.integrations.base import ToolCache
 from briefcase.integrations.download import Download
+from briefcase.integrations.linuxdeploy import LinuxDeploy
 from briefcase.integrations.subprocess import Subprocess
 
 
 @pytest.fixture
 def mock_tools(tmp_path, mock_tools) -> ToolCache:
+    mock_tools.host_os = "Linux"
     mock_tools.host_arch = "wonky"
 
     # Mock default tools
@@ -23,3 +25,8 @@ def mock_tools(tmp_path, mock_tools) -> ToolCache:
     (tmp_path / "bundle").mkdir()
 
     return mock_tools
+
+
+@pytest.fixture
+def linuxdeploy(mock_tools):
+    return LinuxDeploy(mock_tools)
diff --git a/tests/integrations/linuxdeploy/test_LinuxDeployBase__patch_elf_binary.py b/tests/integrations/linuxdeploy/test_LinuxDeployBase__patch_elf_binary.py
index 806969e9..756be873 100644
--- a/tests/integrations/linuxdeploy/test_LinuxDeployBase__patch_elf_binary.py
+++ b/tests/integrations/linuxdeploy/test_LinuxDeployBase__patch_elf_binary.py
@@ -5,34 +5,13 @@ from briefcase.integrations.linuxdeploy import (
     ELF_PATCH_OFFSET,
     ELF_PATCH_ORIGINAL_BYTES,
     ELF_PATCH_PATCHED_BYTES,
-    LinuxDeployBase,
 )
 from tests.integrations.linuxdeploy.utils import create_mock_appimage
 
 
-@pytest.fixture
-def linuxdeploy(mock_tools, tmp_path):
-    class LinuxDeployDummy(LinuxDeployBase):
-        name = "dummy-plugin"
-
-        @property
-        def file_name(self):
-            return "linuxdeploy-dummy-wonky.AppImage"
-
-        @property
-        def download_url(self):
-            return "https://example.com/path/to/linuxdeploy-dummy-wonky.AppImage"
-
-        @property
-        def file_path(self):
-            return tmp_path / "plugin"
-
-    return LinuxDeployDummy(mock_tools)
-
-
 def test_patch_linuxdeploy_elf_header_unpatched(linuxdeploy, tmp_path):
     """If the linuxdeploy tool/plugin is not patched, patch it."""
-    appimage_path = tmp_path / "plugin" / "linuxdeploy-dummy-wonky.AppImage"
+    appimage_path = tmp_path / "tools" / "linuxdeploy-wonky.AppImage"
 
     # Mock an unpatched linuxdeploy AppImage
     pre_patch_header = create_mock_appimage(
@@ -53,7 +32,7 @@ def test_patch_linuxdeploy_elf_header_unpatched(linuxdeploy, tmp_path):
 
 def test_patch_linuxdeploy_elf_header_already_patched(linuxdeploy, tmp_path):
     """If linuxdeploy is already patched, don't patch it."""
-    appimage_path = tmp_path / "plugin" / "linuxdeploy-dummy-wonky.AppImage"
+    appimage_path = tmp_path / "tools" / "linuxdeploy-wonky.AppImage"
 
     # Mock a patched linuxdeploy AppImage
     pre_patch_header = create_mock_appimage(
@@ -74,7 +53,7 @@ def test_patch_linuxdeploy_elf_header_already_patched(linuxdeploy, tmp_path):
 
 def test_patch_linuxdeploy_elf_header_bad_appimage(linuxdeploy, tmp_path):
     """If linuxdeploy does not have a valid header, raise an error."""
-    appimage_path = tmp_path / "plugin" / "linuxdeploy-dummy-wonky.AppImage"
+    appimage_path = tmp_path / "tools" / "linuxdeploy-wonky.AppImage"
 
     # Mock a bad linuxdeploy AppImage
     create_mock_appimage(appimage_path=appimage_path, mock_appimage_kind="corrupt")
@@ -86,11 +65,11 @@ def test_patch_linuxdeploy_elf_header_bad_appimage(linuxdeploy, tmp_path):
 
 def test_patch_linuxdeploy_elf_header_empty_appimage(linuxdeploy, tmp_path):
     """If file is empty, raise an error."""
-    appimage_path = tmp_path / "plugin" / "linuxdeploy-dummy-wonky.AppImage"
+    appimage_path = tmp_path / "tools" / "linuxdeploy-wonky.AppImage"
 
     # Mock a bad linuxdeploy AppImage
     create_mock_appimage(appimage_path=appimage_path, mock_appimage_kind="empty")
 
     # Create a linuxdeploy wrapper, then patch the elf header
     with pytest.raises(CorruptToolError):
-        linuxdeploy = linuxdeploy.patch_elf_header()
+        linuxdeploy.patch_elf_header()
diff --git a/tests/integrations/linuxdeploy/test_LinuxDeployBase__upgrade.py b/tests/integrations/linuxdeploy/test_LinuxDeployBase__upgrade.py
index d8f5ee57..4548b541 100644
--- a/tests/integrations/linuxdeploy/test_LinuxDeployBase__upgrade.py
+++ b/tests/integrations/linuxdeploy/test_LinuxDeployBase__upgrade.py
@@ -1,37 +1,14 @@
 import pytest
 
 from briefcase.exceptions import MissingToolError, NetworkFailure
-from briefcase.integrations.linuxdeploy import LinuxDeployBase
 from tests.integrations.linuxdeploy.utils import side_effect_create_mock_appimage
 
 
-@pytest.fixture
-def linuxdeploy(mock_tools, tmp_path):
-    class LinuxDeployDummy(LinuxDeployBase):
-        name = "dummy-plugin"
-        full_name = "Dummy plugin"
-
-        @property
-        def file_name(self):
-            return "linuxdeploy-dummy-wonky.AppImage"
-
-        @property
-        def download_url(self):
-            return "https://example.com/path/to/linuxdeploy-dummy-wonky.AppImage"
-
-        @property
-        def file_path(self):
-            return tmp_path / "plugin"
-
-    return LinuxDeployDummy(mock_tools)
-
-
 def test_upgrade_exists(linuxdeploy, mock_tools, tmp_path):
     """If linuxdeploy already exists, upgrading deletes first."""
-    appimage_path = tmp_path / "plugin" / "linuxdeploy-dummy-wonky.AppImage"
+    appimage_path = tmp_path / "tools" / "linuxdeploy-wonky.AppImage"
 
     # Mock the existence of an install
-    appimage_path.parent.mkdir(parents=True)
     appimage_path.touch()
 
     # Mock a successful download
@@ -47,9 +24,9 @@ def test_upgrade_exists(linuxdeploy, mock_tools, tmp_path):
 
     # A download is invoked
     mock_tools.download.file.assert_called_with(
-        url="https://example.com/path/to/linuxdeploy-dummy-wonky.AppImage",
-        download_path=tmp_path / "plugin",
-        role="Dummy plugin",
+        url="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-wonky.AppImage",
+        download_path=tmp_path / "tools",
+        role="linuxdeploy",
     )
     # The downloaded file will be made executable
     mock_tools.os.chmod.assert_called_with(appimage_path, 0o755)
@@ -68,9 +45,9 @@ def test_upgrade_does_not_exist(linuxdeploy, mock_tools):
 def test_upgrade_linuxdeploy_download_failure(linuxdeploy, mock_tools, tmp_path):
     """If linuxdeploy doesn't exist, but a download failure occurs, an error is
     raised."""
+    appimage_path = tmp_path / "tools" / "linuxdeploy-wonky.AppImage"
+
     # Mock the existence of an install
-    appimage_path = tmp_path / "plugin" / "linuxdeploy-dummy-wonky.AppImage"
-    appimage_path.parent.mkdir(parents=True)
     appimage_path.touch()
 
     mock_tools.download.file.side_effect = NetworkFailure("mock")
@@ -84,7 +61,7 @@ def test_upgrade_linuxdeploy_download_failure(linuxdeploy, mock_tools, tmp_path)
 
     # A download was invoked
     mock_tools.download.file.assert_called_with(
-        url="https://example.com/path/to/linuxdeploy-dummy-wonky.AppImage",
-        download_path=tmp_path / "plugin",
-        role="Dummy plugin",
+        url="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-wonky.AppImage",
+        download_path=tmp_path / "tools",
+        role="linuxdeploy",
     )
diff --git a/tests/integrations/linuxdeploy/test_LinuxDeployBase__verify.py b/tests/integrations/linuxdeploy/test_LinuxDeployBase__verify.py
index 053261fd..edeaf9f7 100644
--- a/tests/integrations/linuxdeploy/test_LinuxDeployBase__verify.py
+++ b/tests/integrations/linuxdeploy/test_LinuxDeployBase__verify.py
@@ -1,6 +1,7 @@
 import pytest
 
-from briefcase.exceptions import MissingToolError, NetworkFailure
+from briefcase.exceptions import MissingToolError, NetworkFailure, UnsupportedHostError
+from briefcase.integrations.base import ManagedTool
 from briefcase.integrations.linuxdeploy import LinuxDeployBase
 from tests.integrations.linuxdeploy.utils import (
     side_effect_create_mock_appimage,
@@ -8,13 +9,13 @@ from tests.integrations.linuxdeploy.utils import (
 )
 
 
-class LinuxDeployDummy(LinuxDeployBase):
+class LinuxDeployDummy(LinuxDeployBase, ManagedTool):
     name = "dummy-plugin"
     full_name = "Dummy plugin"
     install_msg = "Installing dummy plugin"
 
-    def __init__(self, command, file_name="linuxdeploy-dummy-wonky.AppImage", **kwargs):
-        super().__init__(command)
+    def __init__(self, tools, file_name="linuxdeploy-dummy-wonky.AppImage", **kwargs):
+        super().__init__(tools=tools)
         self._file_name = file_name
         self.kwargs = kwargs
 
@@ -35,12 +36,24 @@ def test_short_circuit(mock_tools):
     """Tool is not created if already cached."""
     mock_tools.linuxdeploy = "tool"
 
-    tool = LinuxDeployBase.verify(mock_tools)
+    tool = LinuxDeployDummy.verify(mock_tools)
 
     assert tool == "tool"
     assert tool == mock_tools.linuxdeploy
 
 
+@pytest.mark.parametrize("host_os", ["Darwin", "Windows", "wonky"])
+def test_unsupported_os(mock_tools, host_os):
+    """When host OS is not supported, an error is raised."""
+    mock_tools.host_os = host_os
+
+    with pytest.raises(
+        UnsupportedHostError,
+        match=f"{LinuxDeployDummy.name} is not supported on {host_os}",
+    ):
+        LinuxDeployDummy.verify(mock_tools)
+
+
 def test_verify_exists(mock_tools, tmp_path):
     """If the tool/plugin already exists, verification doesn't download."""
     appimage_path = (
@@ -90,6 +103,7 @@ def test_verify_does_not_exist(mock_tools, tmp_path):
 
     # The extra verification arguments were passed to the tool instance
     assert linuxdeploy.kwargs == {
+        "app": None,
         "arg1": "value1",
         "arg2": "value2",
     }
diff --git a/tests/integrations/linuxdeploy/test_LinuxDeployGtkPlugin__properties.py b/tests/integrations/linuxdeploy/test_LinuxDeployGtkPlugin__properties.py
index c832c3ee..312a1bc5 100644
--- a/tests/integrations/linuxdeploy/test_LinuxDeployGtkPlugin__properties.py
+++ b/tests/integrations/linuxdeploy/test_LinuxDeployGtkPlugin__properties.py
@@ -4,31 +4,31 @@ from briefcase.integrations.linuxdeploy import LinuxDeployGtkPlugin
 
 
 @pytest.fixture
-def linuxdeploy_plugin(mock_tools):
+def linuxdeploy_gtk_plugin(mock_tools):
     return LinuxDeployGtkPlugin(mock_tools)
 
 
-def test_file_path(linuxdeploy_plugin, mock_tools):
+def test_file_path(linuxdeploy_gtk_plugin, mock_tools):
     """Default Linuxdeploy plugins reside in the linuxdeploy plugins path."""
     assert (
-        linuxdeploy_plugin.file_path
+        linuxdeploy_gtk_plugin.file_path
         == mock_tools.base_path / "linuxdeploy_plugins" / "gtk"
     )
 
 
-def test_file_name(linuxdeploy_plugin):
+def test_file_name(linuxdeploy_gtk_plugin):
     """GTK plugin has a known name."""
-    assert linuxdeploy_plugin.file_name == "linuxdeploy-plugin-gtk.sh"
+    assert linuxdeploy_gtk_plugin.file_name == "linuxdeploy-plugin-gtk.sh"
 
 
-def test_plugin_id(linuxdeploy_plugin):
+def test_plugin_id(linuxdeploy_gtk_plugin):
     """GTK plugin ID is fixed."""
-    assert linuxdeploy_plugin.plugin_id == "gtk"
+    assert linuxdeploy_gtk_plugin.plugin_id == "gtk"
 
 
-def test_download_url(linuxdeploy_plugin):
+def test_download_url(linuxdeploy_gtk_plugin):
     """GTK plugin download URL is architecture dependent."""
-    assert linuxdeploy_plugin.download_url == (
+    assert linuxdeploy_gtk_plugin.download_url == (
         "https://raw.githubusercontent.com/linuxdeploy/linuxdeploy-plugin-gtk/"
         "master/linuxdeploy-plugin-gtk.sh"
     )
diff --git a/tests/integrations/linuxdeploy/test_LinuxDeployLocalFilePlugin__properties.py b/tests/integrations/linuxdeploy/test_LinuxDeployLocalFilePlugin__properties.py
index 00895d9a..e7b9634a 100644
--- a/tests/integrations/linuxdeploy/test_LinuxDeployLocalFilePlugin__properties.py
+++ b/tests/integrations/linuxdeploy/test_LinuxDeployLocalFilePlugin__properties.py
@@ -4,7 +4,7 @@ from briefcase.integrations.linuxdeploy import LinuxDeployLocalFilePlugin
 
 
 @pytest.fixture
-def linuxdeploy_plugin(mock_tools, tmp_path):
+def linuxdeploy_local_file_plugin(mock_tools, tmp_path):
     return LinuxDeployLocalFilePlugin(
         mock_tools,
         plugin_path=tmp_path / "path" / "to" / "linuxdeploy-plugin-custom.sh",
@@ -12,23 +12,23 @@ def linuxdeploy_plugin(mock_tools, tmp_path):
     )
 
 
-def test_file_path(linuxdeploy_plugin, tmp_path):
+def test_file_path(linuxdeploy_local_file_plugin, tmp_path):
     """Local file plugins are kept in the bundle."""
-    assert linuxdeploy_plugin.file_path == tmp_path / "bundle"
+    assert linuxdeploy_local_file_plugin.file_path == tmp_path / "bundle"
 
 
-def test_file_name(linuxdeploy_plugin):
+def test_file_name(linuxdeploy_local_file_plugin):
     """Local file plugin filename is as provided."""
-    assert linuxdeploy_plugin.file_name == "linuxdeploy-plugin-custom.sh"
+    assert linuxdeploy_local_file_plugin.file_name == "linuxdeploy-plugin-custom.sh"
 
 
-def test_plugin_id(linuxdeploy_plugin):
+def test_plugin_id(linuxdeploy_local_file_plugin):
     """The custom plugin ID can be determined from the filename."""
-    assert linuxdeploy_plugin.plugin_id == "custom"
+    assert linuxdeploy_local_file_plugin.plugin_id == "custom"
 
 
-def test_download_url(linuxdeploy_plugin):
+def test_download_url(linuxdeploy_local_file_plugin):
     """Local file plugins don't have a download URL."""
     # Local file plugins don't have download URL.
     with pytest.raises(RuntimeError):
-        _ = linuxdeploy_plugin.download_url
+        _ = linuxdeploy_local_file_plugin.download_url
diff --git a/tests/integrations/linuxdeploy/test_LinuxDeployLocalFilePlugin__verify.py b/tests/integrations/linuxdeploy/test_LinuxDeployLocalFilePlugin__verify.py
index 3bad1d07..0158de69 100644
--- a/tests/integrations/linuxdeploy/test_LinuxDeployLocalFilePlugin__verify.py
+++ b/tests/integrations/linuxdeploy/test_LinuxDeployLocalFilePlugin__verify.py
@@ -1,11 +1,23 @@
 import pytest
 
-from briefcase.exceptions import BriefcaseCommandError
+from briefcase.exceptions import BriefcaseCommandError, UnsupportedHostError
 from briefcase.integrations.linuxdeploy import LinuxDeployLocalFilePlugin
 
 from .utils import create_mock_appimage
 
 
+@pytest.mark.parametrize("host_os", ["Darwin", "Windows", "wonky"])
+def test_unsupported_os(mock_tools, host_os):
+    """When host OS is not supported, an error is raised."""
+    mock_tools.host_os = host_os
+
+    with pytest.raises(
+        UnsupportedHostError,
+        match=f"{LinuxDeployLocalFilePlugin.name} is not supported on {host_os}",
+    ):
+        LinuxDeployLocalFilePlugin.verify(mock_tools)
+
+
 def test_verify(mock_tools, tmp_path):
     """Local file plugins are installed by copying."""
     plugin_path = tmp_path / "path" / "to" / "linuxdeploy-plugin-custom.sh"
diff --git a/tests/integrations/linuxdeploy/test_LinuxDeployQtPlugin__properties.py b/tests/integrations/linuxdeploy/test_LinuxDeployQtPlugin__properties.py
index 404d2f19..13e2bce1 100644
--- a/tests/integrations/linuxdeploy/test_LinuxDeployQtPlugin__properties.py
+++ b/tests/integrations/linuxdeploy/test_LinuxDeployQtPlugin__properties.py
@@ -4,31 +4,31 @@ from briefcase.integrations.linuxdeploy import LinuxDeployQtPlugin
 
 
 @pytest.fixture
-def linuxdeploy_plugin(mock_tools):
+def linuxdeploy_qt_plugin(mock_tools):
     return LinuxDeployQtPlugin(mock_tools)
 
 
-def test_file_path(mock_tools, linuxdeploy_plugin):
+def test_file_path(mock_tools, linuxdeploy_qt_plugin):
     """Default Linuxdeploy plugins reside in the linuxdeploy plugins path."""
     assert (
-        linuxdeploy_plugin.file_path
+        linuxdeploy_qt_plugin.file_path
         == mock_tools.base_path / "linuxdeploy_plugins" / "qt"
     )
 
 
-def test_file_name(linuxdeploy_plugin):
+def test_file_name(linuxdeploy_qt_plugin):
     """Linuxdeploy Qt plugin filename is architecture dependent."""
-    assert linuxdeploy_plugin.file_name == "linuxdeploy-plugin-qt-wonky.AppImage"
+    assert linuxdeploy_qt_plugin.file_name == "linuxdeploy-plugin-qt-wonky.AppImage"
 
 
-def test_plugin_id(linuxdeploy_plugin):
+def test_plugin_id(linuxdeploy_qt_plugin):
     """Linuxdeploy Qt plugin ID is fixed."""
-    assert linuxdeploy_plugin.plugin_id == "qt"
+    assert linuxdeploy_qt_plugin.plugin_id == "qt"
 
 
-def test_download_url(linuxdeploy_plugin):
+def test_download_url(linuxdeploy_qt_plugin):
     """Linuxdeploy Qt plugin download URL is architecture dependent."""
-    assert linuxdeploy_plugin.download_url == (
+    assert linuxdeploy_qt_plugin.download_url == (
         "https://github.com/linuxdeploy/linuxdeploy-plugin-qt/"
         "releases/download/continuous/linuxdeploy-plugin-qt-wonky.AppImage"
     )
diff --git a/tests/integrations/linuxdeploy/test_LinuxDeployURLPlugin__properties.py b/tests/integrations/linuxdeploy/test_LinuxDeployURLPlugin__properties.py
index 7b82e452..155c128d 100644
--- a/tests/integrations/linuxdeploy/test_LinuxDeployURLPlugin__properties.py
+++ b/tests/integrations/linuxdeploy/test_LinuxDeployURLPlugin__properties.py
@@ -4,17 +4,17 @@ from briefcase.integrations.linuxdeploy import LinuxDeployURLPlugin
 
 
 @pytest.fixture
-def linuxdeploy_plugin(mock_tools):
+def linuxdeploy_url_plugin(mock_tools):
     return LinuxDeployURLPlugin(
         mock_tools,
         url="https://example.com/path/to/linuxdeploy-plugin-foobar.sh",
     )
 
 
-def test_file_path(mock_tools, linuxdeploy_plugin):
+def test_file_path(mock_tools, linuxdeploy_url_plugin):
     """Custom URL plugins are in the linuxdeploy plugins folder, behind a hash."""
     assert (
-        linuxdeploy_plugin.file_path
+        linuxdeploy_url_plugin.file_path
         == mock_tools.base_path
         / "linuxdeploy_plugins"
         / "foobar"
@@ -22,18 +22,18 @@ def test_file_path(mock_tools, linuxdeploy_plugin):
     )
 
 
-def test_file_name(linuxdeploy_plugin):
+def test_file_name(linuxdeploy_url_plugin):
     """Custom URL plugin filenames come from the URL."""
-    assert linuxdeploy_plugin.file_name == "linuxdeploy-plugin-foobar.sh"
+    assert linuxdeploy_url_plugin.file_name == "linuxdeploy-plugin-foobar.sh"
 
 
-def test_plugin_id(linuxdeploy_plugin):
+def test_plugin_id(linuxdeploy_url_plugin):
     """The Custom URL plugin ID can be determined from the filename."""
-    assert linuxdeploy_plugin.plugin_id == "foobar"
+    assert linuxdeploy_url_plugin.plugin_id == "foobar"
 
 
-def test_download_url(linuxdeploy_plugin):
+def test_download_url(linuxdeploy_url_plugin):
     """The download URL for the plugin is as-provided."""
-    assert linuxdeploy_plugin.download_url == (
+    assert linuxdeploy_url_plugin.download_url == (
         "https://example.com/path/to/linuxdeploy-plugin-foobar.sh"
     )
diff --git a/tests/integrations/linuxdeploy/test_LinuxDeployURLPlugin__verify.py b/tests/integrations/linuxdeploy/test_LinuxDeployURLPlugin__verify.py
index c482a0b0..5c4f0be7 100644
--- a/tests/integrations/linuxdeploy/test_LinuxDeployURLPlugin__verify.py
+++ b/tests/integrations/linuxdeploy/test_LinuxDeployURLPlugin__verify.py
@@ -1,11 +1,27 @@
 import pytest
 
-from briefcase.exceptions import BriefcaseCommandError, NetworkFailure
+from briefcase.exceptions import (
+    BriefcaseCommandError,
+    NetworkFailure,
+    UnsupportedHostError,
+)
 from briefcase.integrations.linuxdeploy import LinuxDeployURLPlugin
 
 from .utils import side_effect_create_mock_appimage
 
 
+@pytest.mark.parametrize("host_os", ["Darwin", "Windows", "wonky"])
+def test_unsupported_os(mock_tools, host_os):
+    """When host OS is not supported, an error is raised."""
+    mock_tools.host_os = host_os
+
+    with pytest.raises(
+        UnsupportedHostError,
+        match=f"{LinuxDeployURLPlugin.name} is not supported on {host_os}",
+    ):
+        LinuxDeployURLPlugin.verify(mock_tools)
+
+
 def test_verify(mock_tools, tmp_path):
     """URL plugins will be downloaded."""
     # Mock a successful download
diff --git a/tests/integrations/linuxdeploy/test_LinuxDeploy__is_elf_file.py b/tests/integrations/linuxdeploy/test_LinuxDeploy__is_elf_file.py
index 1b9ae669..2971f21c 100644
--- a/tests/integrations/linuxdeploy/test_LinuxDeploy__is_elf_file.py
+++ b/tests/integrations/linuxdeploy/test_LinuxDeploy__is_elf_file.py
@@ -1,33 +1,10 @@
-import pytest
-
-from briefcase.integrations.linuxdeploy import LinuxDeployBase
 from tests.integrations.linuxdeploy.utils import create_mock_appimage
 
 
-@pytest.fixture
-def linuxdeploy(mock_tools, tmp_path):
-    class LinuxDeployDummy(LinuxDeployBase):
-        name = "dummy-plugin"
-
-        @property
-        def file_name(self):
-            return "linuxdeploy-dummy-wonky.AppImage"
-
-        @property
-        def download_url(self):
-            return "https://example.com/path/to/linuxdeploy-dummy-wonky.AppImage"
-
-        @property
-        def file_path(self):
-            return tmp_path / "plugin"
-
-    return LinuxDeployDummy(mock_tools)
-
-
 def test_is_elf_header_positive_detection(linuxdeploy, tmp_path):
     """File with an ELF Header is identified as an ELF file."""
     create_mock_appimage(
-        appimage_path=tmp_path / "plugin" / "linuxdeploy-dummy-wonky.AppImage"
+        appimage_path=tmp_path / "tools" / "linuxdeploy-wonky.AppImage"
     )
     assert linuxdeploy.is_elf_file() is True
 
@@ -35,7 +12,7 @@ def test_is_elf_header_positive_detection(linuxdeploy, tmp_path):
 def test_is_elf_header_negative_detection(linuxdeploy, tmp_path):
     """File without an ELF Header is not identified as an ELF file."""
     create_mock_appimage(
-        appimage_path=tmp_path / "plugin" / "linuxdeploy-dummy-wonky.AppImage",
+        appimage_path=tmp_path / "tools" / "linuxdeploy-wonky.AppImage",
         mock_appimage_kind="corrupt",
     )
     assert linuxdeploy.is_elf_file() is False
@@ -44,7 +21,7 @@ def test_is_elf_header_negative_detection(linuxdeploy, tmp_path):
 def test_is_elf_header_empty_file(linuxdeploy, tmp_path):
     """Empty file is not identified as an ELF file without any errors."""
     create_mock_appimage(
-        appimage_path=tmp_path / "plugin" / "linuxdeploy-dummy-wonky.AppImage",
+        appimage_path=tmp_path / "tools" / "linuxdeploy-wonky.AppImage",
         mock_appimage_kind="empty",
     )
     assert linuxdeploy.is_elf_file() is False
diff --git a/tests/integrations/linuxdeploy/test_LinuxDeploy__properties.py b/tests/integrations/linuxdeploy/test_LinuxDeploy__properties.py
index d8424eb3..c5d17c8a 100644
--- a/tests/integrations/linuxdeploy/test_LinuxDeploy__properties.py
+++ b/tests/integrations/linuxdeploy/test_LinuxDeploy__properties.py
@@ -1,45 +1,31 @@
-from briefcase.integrations.linuxdeploy import (
-    LinuxDeploy,
-    LinuxDeployGtkPlugin,
-    LinuxDeployQtPlugin,
-)
+from briefcase.integrations.linuxdeploy import LinuxDeployGtkPlugin, LinuxDeployQtPlugin
 
 
-def test_managed_install(mock_tools):
+def test_managed_install(linuxdeploy):
     """All linuxdeploy installs are managed."""
-    linuxdeploy = LinuxDeploy(mock_tools)
+    assert linuxdeploy.managed_install is True
 
-    assert linuxdeploy.managed_install
 
-
-def test_file_path(mock_tools):
+def test_file_path(linuxdeploy, mock_tools):
     """Linuxdeploy resides in the tool path."""
-    linuxdeploy = LinuxDeploy(mock_tools)
-
     assert linuxdeploy.file_path == mock_tools.base_path
 
 
-def test_file_name(mock_tools):
+def test_file_name(linuxdeploy):
     """Linuxdeploy filename is architecture dependent."""
-    linuxdeploy = LinuxDeploy(mock_tools)
-
     assert linuxdeploy.file_name == "linuxdeploy-wonky.AppImage"
 
 
-def test_download_url(mock_tools):
+def test_download_url(linuxdeploy):
     """Linuxdeploy download URL is architecture dependent."""
-    linuxdeploy = LinuxDeploy(mock_tools)
-
     assert linuxdeploy.download_url == (
         "https://github.com/linuxdeploy/linuxdeploy/"
         "releases/download/continuous/linuxdeploy-wonky.AppImage"
     )
 
 
-def test_plugins(mock_tools):
+def test_plugins(linuxdeploy):
     """There are 2 known plugins."""
-    linuxdeploy = LinuxDeploy(mock_tools)
-
     assert linuxdeploy.plugins == {
         "gtk": LinuxDeployGtkPlugin,
         "qt": LinuxDeployQtPlugin,
diff --git a/tests/integrations/linuxdeploy/test_LinuxDeploy__verify_plugins.py b/tests/integrations/linuxdeploy/test_LinuxDeploy__verify_plugins.py
index a1414514..db17c3f8 100644
--- a/tests/integrations/linuxdeploy/test_LinuxDeploy__verify_plugins.py
+++ b/tests/integrations/linuxdeploy/test_LinuxDeploy__verify_plugins.py
@@ -5,7 +5,6 @@ from unittest.mock import MagicMock
 import pytest
 
 from briefcase.integrations.linuxdeploy import (
-    LinuxDeploy,
     LinuxDeployGtkPlugin,
     LinuxDeployLocalFilePlugin,
     LinuxDeployQtPlugin,
@@ -19,11 +18,6 @@ from .utils import (
 )
 
 
-@pytest.fixture
-def linuxdeploy(mock_tools):
-    return LinuxDeploy(mock_tools)
-
-
 def test_no_plugins(linuxdeploy, mock_tools, tmp_path):
     """If there are no plugins, verify is a no-op."""
 
diff --git a/tests/integrations/rcedit/conftest.py b/tests/integrations/rcedit/conftest.py
index 15fbb26a..44958eb7 100644
--- a/tests/integrations/rcedit/conftest.py
+++ b/tests/integrations/rcedit/conftest.py
@@ -10,6 +10,7 @@ from briefcase.integrations.subprocess import Subprocess
 
 @pytest.fixture
 def mock_tools(tmp_path, mock_tools) -> ToolCache:
+    mock_tools.host_os = "Windows"
     mock_tools.host_arch = "wonky"
 
     # Mock default tools
diff --git a/tests/integrations/rcedit/test_RCEdit__verify.py b/tests/integrations/rcedit/test_RCEdit__verify.py
index 7ffa1a61..887dbc65 100644
--- a/tests/integrations/rcedit/test_RCEdit__verify.py
+++ b/tests/integrations/rcedit/test_RCEdit__verify.py
@@ -1,6 +1,6 @@
 import pytest
 
-from briefcase.exceptions import MissingToolError, NetworkFailure
+from briefcase.exceptions import MissingToolError, NetworkFailure, UnsupportedHostError
 from briefcase.integrations.rcedit import RCEdit
 
 
@@ -14,6 +14,18 @@ def test_short_circuit(mock_tools):
     assert tool == mock_tools.rcedit
 
 
+@pytest.mark.parametrize("host_os", ["Darwin", "Linux", "wonky"])
+def test_unsupported_os(mock_tools, host_os):
+    """When host OS is not supported, an error is raised."""
+    mock_tools.host_os = host_os
+
+    with pytest.raises(
+        UnsupportedHostError,
+        match=f"{RCEdit.name} is not supported on {host_os}",
+    ):
+        RCEdit.verify(mock_tools)
+
+
 def test_verify_exists(mock_tools, tmp_path):
     """If RCEdit already exists, verification doesn't download."""
     rcedit_path = tmp_path / "tools" / "rcedit-x64.exe"
diff --git a/tests/integrations/subprocess/test_NativeAppContext__verify.py b/tests/integrations/subprocess/test_NativeAppContext__verify.py
index d36f2265..e15b4bef 100644
--- a/tests/integrations/subprocess/test_NativeAppContext__verify.py
+++ b/tests/integrations/subprocess/test_NativeAppContext__verify.py
@@ -1,3 +1,6 @@
+import pytest
+
+from briefcase.exceptions import UnsupportedHostError
 from briefcase.integrations.subprocess import NativeAppContext, Subprocess
 
 
@@ -11,6 +14,17 @@ def test_short_circuit(mock_tools, first_app_config):
     assert tool == mock_tools[first_app_config].app_context
 
 
+def test_unsupported_os(mock_tools):
+    """When host OS is not supported, an error is raised."""
+    mock_tools.host_os = "wonky"
+
+    with pytest.raises(
+        UnsupportedHostError,
+        match=f"{NativeAppContext.name} is not supported on wonky",
+    ):
+        NativeAppContext.verify(mock_tools, app=object())
+
+
 def test_verify(mock_tools, first_app_config):
     """Subprocess verify always returns/sets subprocess tool."""
     native_app_context_tool = NativeAppContext.verify(mock_tools, first_app_config)
diff --git a/tests/integrations/subprocess/test_Subprocess__verify.py b/tests/integrations/subprocess/test_Subprocess__verify.py
index 21e9624a..7180ea75 100644
--- a/tests/integrations/subprocess/test_Subprocess__verify.py
+++ b/tests/integrations/subprocess/test_Subprocess__verify.py
@@ -1,3 +1,6 @@
+import pytest
+
+from briefcase.exceptions import UnsupportedHostError
 from briefcase.integrations.subprocess import Subprocess
 
 
@@ -11,6 +14,20 @@ def test_short_circuit(mock_tools):
     assert tool == mock_tools.subprocess
 
 
+def test_unsupported_os(mock_tools):
+    """When host OS is not supported, an error is raised."""
+    mock_tools.host_os = "wonky"
+
+    # Delete subprocess since it has already been verified
+    delattr(mock_tools, "subprocess")
+
+    with pytest.raises(
+        UnsupportedHostError,
+        match=f"{Subprocess.name} is not supported on wonky",
+    ):
+        Subprocess.verify(mock_tools)
+
+
 def test_verify(mock_tools):
     """Subprocess verify always returns/sets subprocess tool."""
     subprocess_tool = Subprocess.verify(mock_tools)
diff --git a/tests/integrations/visualstudio/test_VisualStudio__verify.py b/tests/integrations/visualstudio/test_VisualStudio__verify.py
index 4b8f5dc6..c8462192 100644
--- a/tests/integrations/visualstudio/test_VisualStudio__verify.py
+++ b/tests/integrations/visualstudio/test_VisualStudio__verify.py
@@ -6,7 +6,7 @@ from unittest.mock import call
 
 import pytest
 
-from briefcase.exceptions import BriefcaseCommandError
+from briefcase.exceptions import BriefcaseCommandError, UnsupportedHostError
 from briefcase.integrations.visualstudio import VisualStudio
 
 MSBUILD_OUTPUT = """Microsoft (R) Build Engine version 17.2.1+52cd2da31 for .NET Framework
@@ -68,6 +68,18 @@ def test_short_circuit(mock_tools):
     assert tool == mock_tools.visualstudio
 
 
+@pytest.mark.parametrize("host_os", ["Darwin", "Linux", "wonky"])
+def test_unsupported_os(mock_tools, host_os):
+    """When host OS is not supported, an error is raised."""
+    mock_tools.host_os = host_os
+
+    with pytest.raises(
+        UnsupportedHostError,
+        match=f"{VisualStudio.name} is not supported on {host_os}",
+    ):
+        VisualStudio.verify(mock_tools)
+
+
 def test_msbuild_on_path(mock_tools):
     """If MSBuild is on the path, that version is used."""
     # MSBuild is on the path, so check_output succeeds
@@ -214,6 +226,30 @@ def test_vswhere_does_not_exist(mock_tools):
     )
 
 
+def test_programfiles_envvar_does_not_exist(mock_tools):
+    """If ProgramFiles(x86) env var is not set, an error is raised."""
+    # MSBuild is not on the path
+    mock_tools.subprocess.check_output.side_effect = FileNotFoundError
+
+    # ProgramFiles(x86) env var is not set
+    mock_tools.os.environ.pop("ProgramFiles(x86)", None)
+
+    # Verify the installation
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=r"Visual Studio does not appear to be installed.",
+    ):
+        VisualStudio.verify(mock_tools)
+
+    # Verification calls are as expected
+    mock_tools.subprocess.check_output.assert_has_calls(
+        [
+            call(["MSBuild.exe", "--version"]),
+        ],
+        any_order=False,
+    )
+
+
 def test_vswhere_bad_executable(mock_tools, vswhere_path):
     """If VSWhere exists, but cannot be executed, an error is raised."""
     # MSBuild is not on the path, and vswhere raises an error
diff --git a/tests/integrations/windows_sdk/test_WindowsSDK__verify.py b/tests/integrations/windows_sdk/test_WindowsSDK__verify.py
index 85cb96cf..c1c20309 100644
--- a/tests/integrations/windows_sdk/test_WindowsSDK__verify.py
+++ b/tests/integrations/windows_sdk/test_WindowsSDK__verify.py
@@ -5,7 +5,7 @@ from unittest.mock import MagicMock
 
 import pytest
 
-from briefcase.exceptions import BriefcaseCommandError
+from briefcase.exceptions import BriefcaseCommandError, UnsupportedHostError
 from briefcase.integrations import windows_sdk
 from briefcase.integrations.windows_sdk import WindowsSDK
 
@@ -65,10 +65,29 @@ def test_short_circuit(mock_tools):
     assert tool == mock_tools.windows_sdk
 
 
+@pytest.mark.parametrize("host_os", ["Darwin", "Linux", "wonky"])
+def test_unsupported_os(mock_tools, host_os):
+    """When host OS is not supported, an error is raised."""
+    mock_tools.host_os = host_os
+
+    with pytest.raises(
+        UnsupportedHostError,
+        match=f"{WindowsSDK.name} is not supported on {host_os}",
+    ):
+        WindowsSDK.verify(mock_tools)
+
+
 @pytest.mark.parametrize(
     "host_arch, sdk_arch", [("AMD64", "x64"), ("ARM64", "arm64"), ("gothic", "gothic")]
 )
-def test_winsdk_arch(mock_tools, host_arch, sdk_arch, tmp_path, monkeypatch):
+def test_winsdk_arch(
+    mock_tools,
+    mock_winreg,
+    host_arch,
+    sdk_arch,
+    tmp_path,
+    monkeypatch,
+):
     """The architecture of the host machine is respected."""
     # Mock the environment for a Windows SDK install
     sdk_path, sdk_ver = setup_winsdk_install(tmp_path, "1.1.1", sdk_arch)
@@ -93,7 +112,7 @@ def test_winsdk_arch(mock_tools, host_arch, sdk_arch, tmp_path, monkeypatch):
     assert win_sdk.signtool_exe == signtool_path
 
 
-def test_winsdk_valid_env_vars(mock_tools, tmp_path, monkeypatch):
+def test_winsdk_valid_env_vars(mock_tools, mock_winreg, tmp_path, monkeypatch):
     """If the WindowsSDKDir and WindowsSDKVersion env vars point to a suitable Windows
     SDK install, the validator succeeds."""
     # Mock the environment for a Windows SDK install
@@ -117,7 +136,7 @@ def test_winsdk_valid_env_vars(mock_tools, tmp_path, monkeypatch):
     assert win_sdk.signtool_exe == signtool_path
 
 
-def test_winsdk_invalid_env_vars(mock_tools, tmp_path, monkeypatch):
+def test_winsdk_invalid_env_vars(mock_tools, mock_winreg, tmp_path, monkeypatch):
     """If the WindowsSDKDir and WindowsSDKVersion env vars point to an invalid Windows
     SDK install, the validator fails."""
     # Mock the environment for a Windows SDK install
diff --git a/tests/integrations/wix/test_WiX__verify.py b/tests/integrations/wix/test_WiX__verify.py
index e0b76d5d..b041426e 100644
--- a/tests/integrations/wix/test_WiX__verify.py
+++ b/tests/integrations/wix/test_WiX__verify.py
@@ -3,7 +3,12 @@ from unittest.mock import MagicMock
 
 import pytest
 
-from briefcase.exceptions import BriefcaseCommandError, MissingToolError, NetworkFailure
+from briefcase.exceptions import (
+    BriefcaseCommandError,
+    MissingToolError,
+    NetworkFailure,
+    UnsupportedHostError,
+)
 from briefcase.integrations.wix import WIX_DOWNLOAD_URL, WiX
 
 
@@ -17,6 +22,18 @@ def test_short_circuit(mock_tools):
     assert tool == mock_tools.wix
 
 
+@pytest.mark.parametrize("host_os", ["Darwin", "Linux", "wonky"])
+def test_unsupported_os(mock_tools, host_os):
+    """When host OS is not supported, an error is raised."""
+    mock_tools.host_os = host_os
+
+    with pytest.raises(
+        UnsupportedHostError,
+        match=f"{WiX.name} is not supported on {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
diff --git a/tests/integrations/xcode/conftest.py b/tests/integrations/xcode/conftest.py
index ce468560..0301d7f5 100644
--- a/tests/integrations/xcode/conftest.py
+++ b/tests/integrations/xcode/conftest.py
@@ -9,6 +9,8 @@ from briefcase.integrations.subprocess import Subprocess
 
 @pytest.fixture
 def mock_tools(tmp_path, mock_tools) -> ToolCache:
+    mock_tools.host_os = "Darwin"
+
     # Mock default tools
     mock_tools.subprocess = MagicMock(spec_set=Subprocess)
     mock_tools.download = MagicMock(spec_set=Download)
diff --git a/tests/integrations/xcode/test_XcodeCLITools__verify.py b/tests/integrations/xcode/test_XcodeCLITools__verify.py
new file mode 100644
index 00000000..1e8f0bc5
--- /dev/null
+++ b/tests/integrations/xcode/test_XcodeCLITools__verify.py
@@ -0,0 +1,26 @@
+import pytest
+
+from briefcase.exceptions import UnsupportedHostError
+from briefcase.integrations.xcode import XcodeCliTools
+
+
+def test_short_circuit(mock_tools):
+    """Tool is not created if already cached."""
+    mock_tools.xcode_cli = "tool"
+
+    tool = XcodeCliTools.verify(mock_tools)
+
+    assert tool == "tool"
+    assert tool == mock_tools.xcode_cli
+
+
+@pytest.mark.parametrize("host_os", ["Linux", "Windows", "wonky"])
+def test_unsupported_os(mock_tools, host_os):
+    """When host OS is not supported, an error is raised."""
+    mock_tools.host_os = host_os
+
+    with pytest.raises(
+        UnsupportedHostError,
+        match=f"{XcodeCliTools.name} is not supported on {host_os}",
+    ):
+        XcodeCliTools.verify(mock_tools)
diff --git a/tests/integrations/xcode/test_Xcode__verify.py b/tests/integrations/xcode/test_Xcode__verify.py
new file mode 100644
index 00000000..d13ac6ca
--- /dev/null
+++ b/tests/integrations/xcode/test_Xcode__verify.py
@@ -0,0 +1,26 @@
+import pytest
+
+from briefcase.exceptions import UnsupportedHostError
+from briefcase.integrations.xcode import Xcode
+
+
+def test_short_circuit(mock_tools):
+    """Tool is not created if already cached."""
+    mock_tools.xcode = "tool"
+
+    tool = Xcode.verify(mock_tools)
+
+    assert tool == "tool"
+    assert tool == mock_tools.xcode
+
+
+@pytest.mark.parametrize("host_os", ["Linux", "Windows", "wonky"])
+def test_unsupported_os(mock_tools, host_os):
+    """When host OS is not supported, an error is raised."""
+    mock_tools.host_os = host_os
+
+    with pytest.raises(
+        UnsupportedHostError,
+        match=f"{Xcode.name} is not supported on {host_os}",
+    ):
+        Xcode.verify(mock_tools)
diff --git a/tests/integrations/xcode/test_confirm_xcode_license_accepted.py b/tests/integrations/xcode/test_confirm_xcode_license_accepted.py
index fe147a04..59d78f3d 100644
--- a/tests/integrations/xcode/test_confirm_xcode_license_accepted.py
+++ b/tests/integrations/xcode/test_confirm_xcode_license_accepted.py
@@ -3,13 +3,13 @@ import subprocess
 import pytest
 
 from briefcase.exceptions import BriefcaseCommandError
-from briefcase.integrations.xcode import confirm_xcode_license_accepted
+from briefcase.integrations.xcode import XcodeCliTools
 
 
 def test_license_accepted(capsys, mock_tools):
     """If the Xcode license has been accepted, pass without comment."""
     # Check passes without an error...
-    confirm_xcode_license_accepted(mock_tools)
+    XcodeCliTools.confirm_xcode_license_accepted(mock_tools)
 
     # ... clang was invoked ...
     mock_tools.subprocess.check_output.assert_called_once_with(
@@ -29,7 +29,7 @@ def test_unknown_error(capsys, mock_tools):
     )
 
     # Check passes without an error...
-    confirm_xcode_license_accepted(mock_tools)
+    XcodeCliTools.confirm_xcode_license_accepted(mock_tools)
 
     # ... clang was invoked ...
     mock_tools.subprocess.check_output.assert_called_once_with(
@@ -49,7 +49,7 @@ def test_accept_license(mock_tools):
     )
 
     # Check passes without an error...
-    confirm_xcode_license_accepted(mock_tools)
+    XcodeCliTools.confirm_xcode_license_accepted(mock_tools)
 
     # ... clang *and* xcodebuild were invoked ...
     mock_tools.subprocess.check_output.assert_called_once_with(
@@ -78,7 +78,7 @@ def test_sudo_fail(mock_tools):
         BriefcaseCommandError,
         match=r"Briefcase was unable to run the Xcode licensing tool.",
     ):
-        confirm_xcode_license_accepted(mock_tools)
+        XcodeCliTools.confirm_xcode_license_accepted(mock_tools)
 
     # ... clang *and* xcodebuild were invoked ...
     mock_tools.subprocess.check_output.assert_called_once_with(
@@ -106,7 +106,7 @@ def test_license_not_accepted(mock_tools):
     with pytest.raises(
         BriefcaseCommandError, match=r"Xcode license has not been accepted."
     ):
-        confirm_xcode_license_accepted(mock_tools)
+        XcodeCliTools.confirm_xcode_license_accepted(mock_tools)
 
     # ... clang *and* xcodebuild were invoked ...
     mock_tools.subprocess.check_output.assert_called_once_with(
@@ -131,7 +131,7 @@ def test_license_status_unknown(capsys, mock_tools):
     )
 
     # Check passes without error...
-    confirm_xcode_license_accepted(mock_tools)
+    XcodeCliTools.confirm_xcode_license_accepted(mock_tools)
 
     # ... clang *and* xcodebuild were invoked ...
     mock_tools.subprocess.check_output.assert_called_once_with(
diff --git a/tests/integrations/xcode/test_ensure_command_line_tools_are_installed.py b/tests/integrations/xcode/test_ensure_command_line_tools_are_installed.py
index 8dde97d4..658bc838 100644
--- a/tests/integrations/xcode/test_ensure_command_line_tools_are_installed.py
+++ b/tests/integrations/xcode/test_ensure_command_line_tools_are_installed.py
@@ -3,13 +3,13 @@ import subprocess
 import pytest
 
 from briefcase.exceptions import BriefcaseCommandError
-from briefcase.integrations.xcode import ensure_command_line_tools_are_installed
+from briefcase.integrations.xcode import XcodeCliTools
 
 
 def test_not_installed(mock_tools):
     """If cmdline dev tools are not installed, raise an error."""
     with pytest.raises(BriefcaseCommandError):
-        ensure_command_line_tools_are_installed(mock_tools)
+        XcodeCliTools.ensure_command_line_tools_are_installed(mock_tools)
 
     # xcode-select was invoked
     mock_tools.subprocess.check_output.assert_called_once_with(
@@ -25,7 +25,7 @@ def test_installed(capsys, mock_tools):
     )
 
     # Check passes without an error...
-    ensure_command_line_tools_are_installed(mock_tools)
+    XcodeCliTools.ensure_command_line_tools_are_installed(mock_tools)
 
     # ... xcode-select was invoked
     mock_tools.subprocess.check_output.assert_called_once_with(
@@ -45,7 +45,7 @@ def test_unsure_if_installed(capsys, mock_tools):
     )
 
     # Check passes without an error...
-    ensure_command_line_tools_are_installed(mock_tools)
+    XcodeCliTools.ensure_command_line_tools_are_installed(mock_tools)
 
     # ... xcode-select was invoked
     mock_tools.subprocess.check_output.assert_called_once_with(
diff --git a/tests/integrations/xcode/test_ensure_xcode_is_installed.py b/tests/integrations/xcode/test_ensure_xcode_is_installed.py
index 7e5856cd..e1e093d7 100644
--- a/tests/integrations/xcode/test_ensure_xcode_is_installed.py
+++ b/tests/integrations/xcode/test_ensure_xcode_is_installed.py
@@ -5,7 +5,7 @@ from unittest import mock
 import pytest
 
 from briefcase.exceptions import BriefcaseCommandError
-from briefcase.integrations.xcode import ensure_xcode_is_installed
+from briefcase.integrations.xcode import Xcode
 
 
 @pytest.fixture
@@ -29,7 +29,7 @@ def test_not_installed(tmp_path, mock_tools):
 
     # Test a location where Xcode *won't* be installed
     with pytest.raises(BriefcaseCommandError):
-        ensure_xcode_is_installed(mock_tools)
+        Xcode.ensure_xcode_is_installed(mock_tools)
 
     # subprocess was invoked as expected
     mock_tools.subprocess.check_output.assert_has_calls(
@@ -49,7 +49,10 @@ def test_custom_install_location(default_xcode_install_path, tmp_path, mock_tool
         "Xcode 13.3.1\nBuild version 11B500\n",  # xcodebuild -version
     ]
 
-    ensure_xcode_is_installed(mock_tools, xcode_location=default_xcode_install_path)
+    Xcode.ensure_xcode_is_installed(
+        mock_tools,
+        xcode_location=default_xcode_install_path,
+    )
 
     # subprocess was invoked as expected
     mock_tools.subprocess.check_output.assert_has_calls(
@@ -78,7 +81,10 @@ def test_command_line_tools_only(default_xcode_install_path, mock_tools):
         BriefcaseCommandError,
         match=r"You have the Xcode command line tools installed",
     ):
-        ensure_xcode_is_installed(mock_tools, xcode_location=default_xcode_install_path)
+        Xcode.ensure_xcode_is_installed(
+            mock_tools,
+            xcode_location=default_xcode_install_path,
+        )
 
     # subprocess was invoked as expected
     mock_tools.subprocess.check_output.assert_has_calls(
@@ -111,7 +117,10 @@ def test_installed_but_command_line_tools_selected(
         BriefcaseCommandError,
         match=r"Xcode appears to be installed, but the active developer directory ",
     ):
-        ensure_xcode_is_installed(mock_tools, xcode_location=default_xcode_install_path)
+        Xcode.ensure_xcode_is_installed(
+            mock_tools,
+            xcode_location=default_xcode_install_path,
+        )
 
     # subprocess was invoked as expected
     mock_tools.subprocess.check_output.assert_has_calls(
@@ -149,7 +158,10 @@ def test_custom_install_with_command_line_tools(
         BriefcaseCommandError,
         match=r"You have the Xcode command line tools installed",
     ):
-        ensure_xcode_is_installed(mock_tools, xcode_location=default_xcode_install_path)
+        Xcode.ensure_xcode_is_installed(
+            mock_tools,
+            xcode_location=default_xcode_install_path,
+        )
 
     # subprocess was invoked as expected
     mock_tools.subprocess.check_output.assert_has_calls(
@@ -175,7 +187,7 @@ def test_installed_but_corrupted(xcode, mock_tools):
     with pytest.raises(
         BriefcaseCommandError, match=r"should return the current Xcode version"
     ):
-        ensure_xcode_is_installed(mock_tools, xcode_location=xcode)
+        Xcode.ensure_xcode_is_installed(mock_tools, xcode_location=xcode)
 
     # subprocess was invoked as expected
     mock_tools.subprocess.check_output.assert_has_calls(
@@ -195,7 +207,7 @@ def test_installed_no_minimum_version(xcode, mock_tools):
     ]
 
     # Check passes without an error.
-    ensure_xcode_is_installed(mock_tools, xcode_location=xcode)
+    Xcode.ensure_xcode_is_installed(mock_tools, xcode_location=xcode)
 
     # subprocess was invoked as expected
     mock_tools.subprocess.check_output.assert_has_calls(
@@ -223,7 +235,11 @@ def test_installed_extra_output(capsys, xcode, mock_tools):
     ]
 
     # Check passes without an error.
-    ensure_xcode_is_installed(mock_tools, xcode_location=xcode, min_version=(11, 1))
+    Xcode.ensure_xcode_is_installed(
+        mock_tools,
+        xcode_location=xcode,
+        min_version=(11, 1),
+    )
 
     # subprocess was invoked as expected
     mock_tools.subprocess.check_output.assert_has_calls(
@@ -291,7 +307,7 @@ def test_installed_with_minimum_version_success(
     mock_tools.subprocess.check_output.side_effect = check_output_mock
 
     # Check passes without an error.
-    ensure_xcode_is_installed(
+    Xcode.ensure_xcode_is_installed(
         mock_tools,
         min_version=min_version,
     )
@@ -335,7 +351,7 @@ def test_installed_with_minimum_version_failure(
 
     # Check raises an error.
     with pytest.raises(BriefcaseCommandError):
-        ensure_xcode_is_installed(
+        Xcode.ensure_xcode_is_installed(
             mock_tools,
             min_version=min_version,
             xcode_location=xcode,
@@ -359,7 +375,7 @@ def test_unexpected_version_output(capsys, xcode, mock_tools):
     ]
 
     # Check passes without an error...
-    ensure_xcode_is_installed(
+    Xcode.ensure_xcode_is_installed(
         mock_tools,
         min_version=(11, 2, 1),
         xcode_location=xcode,
diff --git a/tests/integrations/xcode/test_verify_command_line_tools_install.py b/tests/integrations/xcode/test_verify_command_line_tools_install.py
index fe9a14e1..44f9cf02 100644
--- a/tests/integrations/xcode/test_verify_command_line_tools_install.py
+++ b/tests/integrations/xcode/test_verify_command_line_tools_install.py
@@ -1,7 +1,7 @@
 from subprocess import CalledProcessError
 from unittest import mock
 
-from briefcase.integrations.xcode import verify_command_line_tools_install
+from briefcase.integrations.xcode import XcodeCliTools
 
 
 def test_verify_command_line_tools_install(mock_tools):
@@ -11,7 +11,7 @@ def test_verify_command_line_tools_install(mock_tools):
         "clang 37.42",  # clang --version
     ]
 
-    verify_command_line_tools_install(mock_tools)
+    XcodeCliTools.verify(mock_tools)
 
     # The command line tools are verified
     assert mock_tools.xcode_cli is not None
@@ -23,7 +23,7 @@ def test_reverify_command_line_tools_install(mock_tools):
     xcode_cli = mock.MagicMock()
     mock_tools.xcode_cli = xcode_cli
 
-    verify_command_line_tools_install(mock_tools)
+    XcodeCliTools.verify(mock_tools)
 
     # The command line tools are verified
     assert mock_tools.xcode_cli is not None
diff --git a/tests/integrations/xcode/test_verify_xcode_install.py b/tests/integrations/xcode/test_verify_xcode_install.py
index af19cd05..d51aaad3 100644
--- a/tests/integrations/xcode/test_verify_xcode_install.py
+++ b/tests/integrations/xcode/test_verify_xcode_install.py
@@ -1,7 +1,7 @@
 from subprocess import CalledProcessError
 from unittest import mock
 
-from briefcase.integrations.xcode import verify_xcode_install
+from briefcase.integrations.xcode import Xcode
 
 
 def test_verify_xcode_install(mock_tools):
@@ -13,7 +13,7 @@ def test_verify_xcode_install(mock_tools):
         "clang 37.42",  # clang --version
     ]
 
-    verify_xcode_install(mock_tools)
+    Xcode.verify(mock_tools)
 
     # Both Xcode and the command line tools are verified
     assert mock_tools.xcode is not None
@@ -29,7 +29,7 @@ def test_reverify_xcode_install(mock_tools):
     xcode_cli = mock.MagicMock()
     mock_tools.xcode_cli = xcode_cli
 
-    verify_xcode_install(mock_tools)
+    Xcode.verify(mock_tools)
 
     # Both Xcode and the command line tools are verified
     assert mock_tools.xcode == xcode
diff --git a/tests/platforms/android/gradle/test_run.py b/tests/platforms/android/gradle/test_run.py
index 61614348..d6ef6655 100644
--- a/tests/platforms/android/gradle/test_run.py
+++ b/tests/platforms/android/gradle/test_run.py
@@ -490,21 +490,28 @@ def test_run_idle_device(run_command, first_app_config):
 
 def test_log_file_extra(run_command, monkeypatch):
     """Android commands register a log file extra to list SDK packages."""
-    verify = mock.MagicMock(return_value=run_command.tools.android_sdk)
-    monkeypatch.setattr(AndroidSDK, "verify", verify)
+    mock_android_sdk_verify = mock.MagicMock(return_value=run_command.tools.android_sdk)
+    monkeypatch.setattr(AndroidSDK, "verify", mock_android_sdk_verify)
     monkeypatch.setattr(AndroidSDK, "verify_emulator", mock.MagicMock())
 
     # Even if one command triggers another, the sdkmanager should only be run once.
     run_command.update_command.verify_tools()
     run_command.verify_tools()
 
-    sdk_manager = "/path/to/android_sdk/cmdline-tools/latest/bin/sdkmanager"
-    if platform.system() == "Windows":
-        sdk_manager += ".bat"
+    # Android SDK tool was verified
+    mock_android_sdk_verify.assert_has_calls([mock.call(tools=run_command.tools)] * 2)
+    assert isinstance(run_command.tools.android_sdk, AndroidSDK)
 
-    run_command.tools.logger.save_log = True
+    # list_packages() was not called
     run_command.tools.subprocess.check_output.assert_not_called()
+
+    # list_packages() is called when saving the log
+    run_command.tools.logger.save_log = True
     run_command.tools.logger.save_log_to_file(run_command)
+
+    sdk_manager = "/path/to/android_sdk/cmdline-tools/latest/bin/sdkmanager"
+    if platform.system() == "Windows":
+        sdk_manager += ".bat"
     run_command.tools.subprocess.check_output.assert_called_once_with(
         [normpath(sdk_manager), "--list_installed"],
         env={
diff --git a/tests/platforms/iOS/xcode/test_mixin.py b/tests/platforms/iOS/xcode/test_mixin.py
index a94d3ea4..8c0ab8b8 100644
--- a/tests/platforms/iOS/xcode/test_mixin.py
+++ b/tests/platforms/iOS/xcode/test_mixin.py
@@ -48,19 +48,19 @@ def test_verify(create_command, monkeypatch):
 
     mock_ensure_xcode_is_installed = MagicMock()
     monkeypatch.setattr(
-        briefcase.integrations.xcode,
+        briefcase.integrations.xcode.Xcode,
         "ensure_xcode_is_installed",
         mock_ensure_xcode_is_installed,
     )
     mock_ensure_command_line_tools_are_installed = MagicMock()
     monkeypatch.setattr(
-        briefcase.integrations.xcode,
+        briefcase.integrations.xcode.XcodeCliTools,
         "ensure_command_line_tools_are_installed",
         mock_ensure_command_line_tools_are_installed,
     )
     mock_confirm_xcode_license_accepted = MagicMock()
     monkeypatch.setattr(
-        briefcase.integrations.xcode,
+        briefcase.integrations.xcode.XcodeCliTools,
         "confirm_xcode_license_accepted",
         mock_confirm_xcode_license_accepted,
     )
@@ -69,10 +69,12 @@ def test_verify(create_command, monkeypatch):
 
     assert create_command.tools.xcode_cli is not None
     mock_ensure_xcode_is_installed.assert_called_once_with(
-        create_command.tools,
-        min_version=(10, 0, 0),
+        tools=create_command.tools,
+        min_version=(13, 0, 0),
     )
     mock_ensure_command_line_tools_are_installed.assert_called_once_with(
-        create_command.tools
+        tools=create_command.tools
+    )
+    mock_confirm_xcode_license_accepted.assert_called_once_with(
+        tools=create_command.tools
     )
-    mock_confirm_xcode_license_accepted.assert_called_once_with(create_command.tools)
diff --git a/tests/platforms/linux/appimage/test_build.py b/tests/platforms/linux/appimage/test_build.py
index 8944d5c3..e16fb1c6 100644
--- a/tests/platforms/linux/appimage/test_build.py
+++ b/tests/platforms/linux/appimage/test_build.py
@@ -14,7 +14,7 @@ from briefcase.exceptions import (
     UnsupportedHostError,
 )
 from briefcase.integrations.docker import DockerAppContext
-from briefcase.integrations.linuxdeploy import LinuxDeploy
+from briefcase.integrations.linuxdeploy import LinuxDeploy, LinuxDeployBase
 from briefcase.platforms.linux.appimage import LinuxAppImageBuildCommand
 
 from ....utils import create_file
@@ -463,6 +463,7 @@ def test_build_appimage_with_plugins_in_docker(
     first_app,
     tmp_path,
     sub_stream_kw,
+    monkeypatch,
 ):
     """A Linux app can be packaged as an AppImage with plugins in a Docker container."""
     # Mock the existence of some plugins
@@ -491,6 +492,7 @@ def test_build_appimage_with_plugins_in_docker(
 
     # Enable docker, and move to a non-Linux OS.
     build_command.tools.host_os = "TestOS"
+    monkeypatch.setattr(LinuxDeployBase, "supported_host_os", {"TestOS"})
     build_command.use_docker = True
 
     # Provide Docker app context
diff --git a/tests/platforms/linux/appimage/test_mixin.py b/tests/platforms/linux/appimage/test_mixin.py
index 5baf7680..ac4bd513 100644
--- a/tests/platforms/linux/appimage/test_mixin.py
+++ b/tests/platforms/linux/appimage/test_mixin.py
@@ -3,6 +3,7 @@ from unittest.mock import MagicMock
 
 import pytest
 
+import briefcase.platforms.linux.appimage
 from briefcase.console import Console, Log
 from briefcase.integrations.docker import Docker, DockerAppContext
 from briefcase.integrations.subprocess import Subprocess
@@ -109,16 +110,41 @@ def test_verify_linux_docker(create_command, tmp_path, first_app_config, monkeyp
     create_command.use_docker = True
 
     # Mock Docker tool verification
-    Docker.verify = MagicMock()
-    DockerAppContext.verify = MagicMock()
+    mock__version_compat = MagicMock(spec=Docker._version_compat)
+    mock__user_access = MagicMock(spec=Docker._user_access)
+    mock__buildx_installed = MagicMock(spec=Docker._buildx_installed)
+    monkeypatch.setattr(
+        briefcase.platforms.linux.appimage.Docker,
+        "_version_compat",
+        mock__version_compat,
+    )
+    monkeypatch.setattr(
+        briefcase.platforms.linux.appimage.Docker,
+        "_user_access",
+        mock__user_access,
+    )
+    monkeypatch.setattr(
+        briefcase.platforms.linux.appimage.Docker,
+        "_buildx_installed",
+        mock__buildx_installed,
+    )
+    mock_docker_app_context_verify = MagicMock(spec=DockerAppContext.verify)
+    monkeypatch.setattr(
+        briefcase.platforms.linux.appimage.DockerAppContext,
+        "verify",
+        mock_docker_app_context_verify,
+    )
 
     # Verify the tools
     create_command.verify_tools()
     create_command.verify_app_tools(app=first_app_config)
 
     # Docker and Docker app context are verified
-    Docker.verify.assert_called_with(tools=create_command.tools)
-    DockerAppContext.verify.assert_called_with(
+    mock__version_compat.assert_called_with(tools=create_command.tools)
+    mock__user_access.assert_called_with(tools=create_command.tools)
+    mock__buildx_installed.assert_called_with(tools=create_command.tools)
+    assert isinstance(create_command.tools.docker, Docker)
+    mock_docker_app_context_verify.assert_called_with(
         tools=create_command.tools,
         app=first_app_config,
         image_tag="briefcase/com.example.first-app:appimage",
@@ -141,22 +167,52 @@ def test_verify_linux_docker(create_command, tmp_path, first_app_config, monkeyp
     )
 
 
-def test_verify_non_linux_docker(create_command, tmp_path, first_app_config):
+def test_verify_non_linux_docker(
+    create_command,
+    first_app_config,
+    monkeypatch,
+    tmp_path,
+):
     """If Docker is enabled on non-Linux, the Docker alias is set."""
     create_command.tools.host_os = "Darwin"
     create_command.use_docker = True
 
     # Mock Docker tool verification
-    Docker.verify = MagicMock()
-    DockerAppContext.verify = MagicMock()
+    mock__version_compat = MagicMock(spec=Docker._version_compat)
+    mock__user_access = MagicMock(spec=Docker._user_access)
+    mock__buildx_installed = MagicMock(spec=Docker._buildx_installed)
+    monkeypatch.setattr(
+        briefcase.platforms.linux.appimage.Docker,
+        "_version_compat",
+        mock__version_compat,
+    )
+    monkeypatch.setattr(
+        briefcase.platforms.linux.appimage.Docker,
+        "_user_access",
+        mock__user_access,
+    )
+    monkeypatch.setattr(
+        briefcase.platforms.linux.appimage.Docker,
+        "_buildx_installed",
+        mock__buildx_installed,
+    )
+    mock_docker_app_context_verify = MagicMock(spec=DockerAppContext.verify)
+    monkeypatch.setattr(
+        briefcase.platforms.linux.appimage.DockerAppContext,
+        "verify",
+        mock_docker_app_context_verify,
+    )
 
     # Verify the tools
     create_command.verify_tools()
     create_command.verify_app_tools(app=first_app_config)
 
     # Docker and Docker app context are verified
-    Docker.verify.assert_called_with(tools=create_command.tools)
-    DockerAppContext.verify.assert_called_with(
+    mock__version_compat.assert_called_with(tools=create_command.tools)
+    mock__user_access.assert_called_with(tools=create_command.tools)
+    mock__buildx_installed.assert_called_with(tools=create_command.tools)
+    assert isinstance(create_command.tools.docker, Docker)
+    mock_docker_app_context_verify.assert_called_with(
         tools=create_command.tools,
         app=first_app_config,
         image_tag="briefcase/com.example.first-app:appimage",
diff --git a/tests/platforms/linux/flatpak/test_mixin.py b/tests/platforms/linux/flatpak/test_mixin.py
index 040e1f23..5319dc5a 100644
--- a/tests/platforms/linux/flatpak/test_mixin.py
+++ b/tests/platforms/linux/flatpak/test_mixin.py
@@ -2,6 +2,7 @@ from unittest.mock import MagicMock
 
 import pytest
 
+import briefcase.platforms.linux.flatpak
 from briefcase.console import Console, Log
 from briefcase.exceptions import BriefcaseConfigError
 from briefcase.integrations.flatpak import Flatpak
@@ -127,13 +128,21 @@ def test_missing_runtime_version(create_command, first_app_config):
         create_command.flatpak_runtime_version(first_app_config)
 
 
-def test_verify_linux(create_command):
+def test_verify_linux(create_command, monkeypatch, tmp_path):
     """Verifying on Linux creates an SDK wrapper."""
     create_command.tools.host_os = "Linux"
     create_command.tools.subprocess = MagicMock(spec_set=Subprocess)
 
+    mock_flatpak_verify = MagicMock(wraps=Flatpak.verify)
+    monkeypatch.setattr(
+        briefcase.platforms.linux.flatpak.Flatpak,
+        "verify",
+        mock_flatpak_verify,
+    )
+
     # Verify the tools
     create_command.verify_tools()
 
-    # No error and an SDK wrapper is created
+    # Flatpak tool was verified
+    mock_flatpak_verify.assert_called_once_with(tools=create_command.tools)
     assert isinstance(create_command.tools.flatpak, Flatpak)
diff --git a/tests/platforms/linux/flatpak/test_run.py b/tests/platforms/linux/flatpak/test_run.py
index 55746f57..9afa2e21 100644
--- a/tests/platforms/linux/flatpak/test_run.py
+++ b/tests/platforms/linux/flatpak/test_run.py
@@ -36,7 +36,6 @@ def test_run(run_command, first_app_config):
     # App is executed
     run_command.tools.flatpak.run.assert_called_once_with(
         bundle_identifier="com.example.first-app",
-        app_name="first-app",
         args=[],
     )
 
@@ -65,7 +64,6 @@ def test_run_with_passthrough(run_command, first_app_config):
     # App is executed with args
     run_command.tools.flatpak.run.assert_called_once_with(
         bundle_identifier="com.example.first-app",
-        app_name="first-app",
         args=["foo", "--bar"],
     )
 
@@ -88,7 +86,6 @@ def test_run_app_failed(run_command, first_app_config, tmp_path):
     # The run command was still invoked
     run_command.tools.flatpak.run.assert_called_once_with(
         bundle_identifier="com.example.first-app",
-        app_name="first-app",
         args=[],
     )
 
@@ -108,7 +105,6 @@ def test_run_test_mode(run_command, first_app_config):
     # App is executed
     run_command.tools.flatpak.run.assert_called_once_with(
         bundle_identifier="com.example.first-app",
-        app_name="first-app",
         args=[],
         main_module="tests.first_app",
     )
@@ -138,7 +134,6 @@ def test_run_test_mode_with_args(run_command, first_app_config):
     # App is executed
     run_command.tools.flatpak.run.assert_called_once_with(
         bundle_identifier="com.example.first-app",
-        app_name="first-app",
         args=["foo", "--bar"],
         main_module="tests.first_app",
     )
diff --git a/tests/platforms/linux/system/test_mixin__verify.py b/tests/platforms/linux/system/test_mixin__verify.py
index fad3299b..88fb0ab3 100644
--- a/tests/platforms/linux/system/test_mixin__verify.py
+++ b/tests/platforms/linux/system/test_mixin__verify.py
@@ -1,5 +1,6 @@
 from unittest.mock import MagicMock
 
+import briefcase.platforms.linux.system
 from briefcase.integrations.docker import Docker, DockerAppContext
 from briefcase.integrations.subprocess import Subprocess
 
@@ -48,8 +49,30 @@ def test_linux_docker(create_command, tmp_path, first_app_config, monkeypatch):
     first_app_config.python_version_tag = "3"
 
     # Mock Docker tool verification
-    Docker.verify = MagicMock()
-    DockerAppContext.verify = MagicMock()
+    mock__version_compat = MagicMock(spec=Docker._version_compat)
+    mock__user_access = MagicMock(spec=Docker._user_access)
+    mock__buildx_installed = MagicMock(spec=Docker._buildx_installed)
+    monkeypatch.setattr(
+        briefcase.platforms.linux.system.Docker,
+        "_version_compat",
+        mock__version_compat,
+    )
+    monkeypatch.setattr(
+        briefcase.platforms.linux.system.Docker,
+        "_user_access",
+        mock__user_access,
+    )
+    monkeypatch.setattr(
+        briefcase.platforms.linux.system.Docker,
+        "_buildx_installed",
+        mock__buildx_installed,
+    )
+    mock_docker_app_context_verify = MagicMock(spec=DockerAppContext.verify)
+    monkeypatch.setattr(
+        briefcase.platforms.linux.system.DockerAppContext,
+        "verify",
+        mock_docker_app_context_verify,
+    )
     create_command.verify_python = MagicMock()
 
     # Verify the tools
@@ -57,8 +80,11 @@ def test_linux_docker(create_command, tmp_path, first_app_config, monkeypatch):
     create_command.verify_app_tools(app=first_app_config)
 
     # Docker and Docker app context are verified
-    Docker.verify.assert_called_with(tools=create_command.tools)
-    DockerAppContext.verify.assert_called_with(
+    mock__version_compat.assert_called_with(tools=create_command.tools)
+    mock__user_access.assert_called_with(tools=create_command.tools)
+    mock__buildx_installed.assert_called_with(tools=create_command.tools)
+    assert isinstance(create_command.tools.docker, Docker)
+    mock_docker_app_context_verify.assert_called_with(
         tools=create_command.tools,
         app=first_app_config,
         image_tag="briefcase/com.example.first-app:somevendor-surprising",
@@ -91,7 +117,7 @@ def test_linux_docker(create_command, tmp_path, first_app_config, monkeypatch):
     create_command.verify_python.assert_not_called()
 
 
-def test_non_linux_docker(create_command, tmp_path, first_app_config):
+def test_non_linux_docker(create_command, first_app_config, monkeypatch, tmp_path):
     """If Docker is enabled on non-Linux, the Docker alias is set."""
     create_command.tools.host_os = "Darwin"
     create_command.target_image = "somevendor:surprising"
@@ -103,8 +129,30 @@ def test_non_linux_docker(create_command, tmp_path, first_app_config):
     first_app_config.python_version_tag = "3"
 
     # Mock Docker tool verification
-    Docker.verify = MagicMock()
-    DockerAppContext.verify = MagicMock()
+    mock__version_compat = MagicMock(spec=Docker._version_compat)
+    mock__user_access = MagicMock(spec=Docker._user_access)
+    mock__buildx_installed = MagicMock(spec=Docker._buildx_installed)
+    monkeypatch.setattr(
+        briefcase.platforms.linux.system.Docker,
+        "_version_compat",
+        mock__version_compat,
+    )
+    monkeypatch.setattr(
+        briefcase.platforms.linux.system.Docker,
+        "_user_access",
+        mock__user_access,
+    )
+    monkeypatch.setattr(
+        briefcase.platforms.linux.system.Docker,
+        "_buildx_installed",
+        mock__buildx_installed,
+    )
+    mock_docker_app_context_verify = MagicMock(spec=DockerAppContext.verify)
+    monkeypatch.setattr(
+        briefcase.platforms.linux.system.DockerAppContext,
+        "verify",
+        mock_docker_app_context_verify,
+    )
     create_command.verify_python = MagicMock()
 
     # Verify the tools
@@ -112,8 +160,11 @@ def test_non_linux_docker(create_command, tmp_path, first_app_config):
     create_command.verify_app_tools(app=first_app_config)
 
     # Docker and Docker app context are verified
-    Docker.verify.assert_called_with(tools=create_command.tools)
-    DockerAppContext.verify.assert_called_with(
+    mock__version_compat.assert_called_with(tools=create_command.tools)
+    mock__user_access.assert_called_with(tools=create_command.tools)
+    mock__buildx_installed.assert_called_with(tools=create_command.tools)
+    assert isinstance(create_command.tools.docker, Docker)
+    mock_docker_app_context_verify.assert_called_with(
         tools=create_command.tools,
         app=first_app_config,
         image_tag="briefcase/com.example.first-app:somevendor-surprising",
diff --git a/tests/platforms/linux/system/test_mixin__verify_system_python.py b/tests/platforms/linux/system/test_mixin__verify_system_python.py
index b45b2319..6bdb8807 100644
--- a/tests/platforms/linux/system/test_mixin__verify_system_python.py
+++ b/tests/platforms/linux/system/test_mixin__verify_system_python.py
@@ -37,7 +37,7 @@ def test_valid_python3(monkeypatch, create_command):
 )
 def test_bad_python3(monkeypatch, create_command, resolved_path, expected_error):
     """If the system Python3 isn't obviously a Python3, an error is raised."""
-    # Mock a Python3 symlink that isn'tthe existence of a valid non-docker system Python
+    # Mock a Python3 symlink that isn't the existence of a valid non-docker system Python
     # with the same major/minor as the current Python
     python3 = MagicMock()
     python3.resolve.return_value = Path(resolved_path)
diff --git a/tests/platforms/macOS/app/test_package.py b/tests/platforms/macOS/app/test_package.py
index 2aaaf68e..6630affe 100644
--- a/tests/platforms/macOS/app/test_package.py
+++ b/tests/platforms/macOS/app/test_package.py
@@ -840,13 +840,13 @@ def test_verify(package_command, monkeypatch):
     # Mock the existence of the command line tools
     mock_ensure_command_line_tools_are_installed = mock.MagicMock()
     monkeypatch.setattr(
-        briefcase.integrations.xcode,
+        briefcase.integrations.xcode.XcodeCliTools,
         "ensure_command_line_tools_are_installed",
         mock_ensure_command_line_tools_are_installed,
     )
     mock_confirm_xcode_license_accepted = mock.MagicMock()
     monkeypatch.setattr(
-        briefcase.integrations.xcode,
+        briefcase.integrations.xcode.XcodeCliTools,
         "confirm_xcode_license_accepted",
         mock_confirm_xcode_license_accepted,
     )
@@ -855,6 +855,8 @@ def test_verify(package_command, monkeypatch):
 
     assert package_command.tools.xcode_cli is not None
     mock_ensure_command_line_tools_are_installed.assert_called_once_with(
-        package_command.tools
+        tools=package_command.tools
+    )
+    mock_confirm_xcode_license_accepted.assert_called_once_with(
+        tools=package_command.tools
     )
-    mock_confirm_xcode_license_accepted.assert_called_once_with(package_command.tools)
diff --git a/tests/platforms/macOS/xcode/test_package.py b/tests/platforms/macOS/xcode/test_package.py
index 3a1610b9..e397e364 100644
--- a/tests/platforms/macOS/xcode/test_package.py
+++ b/tests/platforms/macOS/xcode/test_package.py
@@ -26,19 +26,19 @@ def test_verify(package_command, monkeypatch):
 
     mock_ensure_xcode_is_installed = mock.MagicMock()
     monkeypatch.setattr(
-        briefcase.integrations.xcode,
+        briefcase.integrations.xcode.Xcode,
         "ensure_xcode_is_installed",
         mock_ensure_xcode_is_installed,
     )
     mock_ensure_command_line_tools_are_installed = mock.MagicMock()
     monkeypatch.setattr(
-        briefcase.integrations.xcode,
+        briefcase.integrations.xcode.XcodeCliTools,
         "ensure_command_line_tools_are_installed",
         mock_ensure_command_line_tools_are_installed,
     )
     mock_confirm_xcode_license_accepted = mock.MagicMock()
     monkeypatch.setattr(
-        briefcase.integrations.xcode,
+        briefcase.integrations.xcode.XcodeCliTools,
         "confirm_xcode_license_accepted",
         mock_confirm_xcode_license_accepted,
     )
@@ -47,10 +47,12 @@ def test_verify(package_command, monkeypatch):
 
     assert package_command.tools.xcode_cli is not None
     mock_ensure_xcode_is_installed.assert_called_once_with(
-        package_command.tools,
-        min_version=(10, 0, 0),
+        tools=package_command.tools,
+        min_version=(13, 0, 0),
     )
     mock_ensure_command_line_tools_are_installed.assert_called_once_with(
-        package_command.tools
+        tools=package_command.tools
+    )
+    mock_confirm_xcode_license_accepted.assert_called_once_with(
+        tools=package_command.tools
     )
-    mock_confirm_xcode_license_accepted.assert_called_once_with(package_command.tools)
diff --git a/tests/platforms/windows/app/test_build.py b/tests/platforms/windows/app/test_build.py
index d84788bc..2d6ac253 100644
--- a/tests/platforms/windows/app/test_build.py
+++ b/tests/platforms/windows/app/test_build.py
@@ -26,6 +26,7 @@ def build_command(tmp_path):
         base_path=tmp_path / "base_path",
         data_path=tmp_path / "briefcase",
     )
+    command.tools.host_os = "Windows"
     command.tools.subprocess = mock.MagicMock(spec_set=Subprocess)
     command.tools.shutil = mock.MagicMock(spec_set=shutil)
     command.tools.download = mock.MagicMock()
@@ -49,22 +50,47 @@ def test_verify_without_windows_sdk(build_command, monkeypatch):
     monkeypatch.setattr(briefcase.platforms.windows.app, "WindowsSDK", mock_sdk)
     mock_sdk.verify.side_effect = BriefcaseCommandError("Windows SDK")
 
+    mock_rcedit_verify = mock.MagicMock(wraps=RCEdit.verify)
+    monkeypatch.setattr(
+        briefcase.platforms.windows.app.RCEdit,
+        "verify",
+        mock_rcedit_verify,
+    )
+
     build_command.verify_tools()
 
-    # No error and an SDK wrapper is created
+    # RCEdit tool was verified
+    mock_rcedit_verify.assert_called_once_with(tools=build_command.tools)
     assert isinstance(build_command.tools.rcedit, RCEdit)
     # Windows SDK tool not created
     assert not hasattr(build_command.tools, "windows_sdk")
 
 
-def test_verify_with_windows_sdk(build_command, windows_sdk, tmp_path):
+def test_verify_with_windows_sdk(build_command, windows_sdk, monkeypatch, tmp_path):
     """Verifying on Windows creates an RCEdit and Windows SDK wrapper."""
     build_command.tools.windows_sdk = windows_sdk
 
+    mock_windows_sdk_verify = mock.MagicMock(wraps=WindowsSDK.verify)
+    monkeypatch.setattr(
+        briefcase.platforms.windows.app.WindowsSDK,
+        "verify",
+        mock_windows_sdk_verify,
+    )
+
+    mock_rcedit_verify = mock.MagicMock(wraps=RCEdit.verify)
+    monkeypatch.setattr(
+        briefcase.platforms.windows.app.RCEdit,
+        "verify",
+        mock_rcedit_verify,
+    )
+
     build_command.verify_tools()
 
-    # No error and SDK wrappers are created
+    # RCEdit tool was verified
+    mock_rcedit_verify.assert_called_once_with(tools=build_command.tools)
     assert isinstance(build_command.tools.rcedit, RCEdit)
+    # WindowsSDK tool was verified
+    mock_windows_sdk_verify.assert_called_once_with(tools=build_command.tools)
     assert isinstance(build_command.tools.windows_sdk, WindowsSDK)
 
 
diff --git a/tests/platforms/windows/app/test_package.py b/tests/platforms/windows/app/test_package.py
index fe08435e..eb9d99d6 100644
--- a/tests/platforms/windows/app/test_package.py
+++ b/tests/platforms/windows/app/test_package.py
@@ -4,6 +4,7 @@ from zipfile import ZipFile
 
 import pytest
 
+import briefcase.platforms.windows
 from briefcase.console import Console, Log
 from briefcase.exceptions import BriefcaseCommandError
 from briefcase.integrations.subprocess import Subprocess
@@ -22,6 +23,7 @@ def package_command(tmp_path):
         base_path=tmp_path / "base_path",
         data_path=tmp_path / "briefcase",
     )
+    command.tools.host_os = "Windows"
     command.tools.subprocess = mock.MagicMock(spec_set=Subprocess)
     command.tools.wix = WiX(command.tools, wix_home=tmp_path / "wix")
     command.tools.windows_sdk = WindowsSDK(
@@ -67,26 +69,51 @@ def test_package_formats(package_command):
     assert package_command.default_packaging_format == "msi"
 
 
-def test_verify(package_command):
+def test_verify(package_command, monkeypatch):
     """Verifying on Windows creates a WiX wrapper."""
     # prime Command to _not_ need Windows SDK
     package_command._windows_sdk_needed = False
 
+    mock_wix_verify = mock.MagicMock(wraps=WiX.verify)
+    monkeypatch.setattr(
+        briefcase.platforms.windows.WiX,
+        "verify",
+        mock_wix_verify,
+    )
+
     package_command.verify_tools()
 
-    # No error and an SDK wrapper is created
+    # WiX tool was verified
+    mock_wix_verify.assert_called_once_with(tools=package_command.tools)
     assert isinstance(package_command.tools.wix, WiX)
 
 
-def test_verify_with_signing(package_command):
+def test_verify_with_signing(package_command, monkeypatch):
     """Verifying on Windows creates WiX and WindowsSDK wrappers when code signing."""
     # prime Command to need Windows SDK
     package_command._windows_sdk_needed = True
 
+    mock_windows_sdk_verify = mock.MagicMock(wraps=WindowsSDK.verify)
+    monkeypatch.setattr(
+        briefcase.platforms.windows.WindowsSDK,
+        "verify",
+        mock_windows_sdk_verify,
+    )
+
+    mock_wix_verify = mock.MagicMock(wraps=WiX.verify)
+    monkeypatch.setattr(
+        briefcase.platforms.windows.WiX,
+        "verify",
+        mock_wix_verify,
+    )
+
     package_command.verify_tools()
 
-    # No error and SDK wrappers are created
+    # WiX tool was verified
+    mock_wix_verify.assert_called_once_with(tools=package_command.tools)
     assert isinstance(package_command.tools.wix, WiX)
+    # WindowsSDK tool was verified
+    mock_windows_sdk_verify.assert_called_once_with(tools=package_command.tools)
     assert isinstance(package_command.tools.windows_sdk, WindowsSDK)
 
 
diff --git a/tests/platforms/windows/visualstudio/test_build.py b/tests/platforms/windows/visualstudio/test_build.py
index b8b3c3cc..6184fb09 100644
--- a/tests/platforms/windows/visualstudio/test_build.py
+++ b/tests/platforms/windows/visualstudio/test_build.py
@@ -42,7 +42,7 @@ def test_verify(build_command, monkeypatch):
     build_command.verify_tools()
 
     # VisualStudio tool was verified
-    mock_visualstudio_verify.assert_called_once_with(build_command.tools)
+    mock_visualstudio_verify.assert_called_once_with(tools=build_command.tools)
     assert isinstance(build_command.tools.visualstudio, VisualStudio)