Repository: beeware/briefcase

Issue:
Don't Log "Aborted by User" When User is Instructed to Use CTRL+C to Exit
**Describe the bug**
"Aborted by User" should not be logged when the user is instructed to use CTRL+C to exit Briefcase.

**To Reproduce**
Steps to reproduce the behavior:
Start the Android emulator or iOS simulator via `briefcase run` and send CTRL+C.

**Expected behavior**
Briefcase should exit normally.

**Screenshots**
```
src/briefcase/integrations/android_sdk.py:            # If the user sends CTRL+C:
src/briefcase/integrations/android_sdk.py:            # Exit normally since the user was instructed to use CTRL+C.
src/briefcase/platforms/android/gradle.py:            "Following device log output (type CTRL-C to stop log)...",
src/briefcase/platforms/iOS/xcode.py:                "Following simulator log output (type CTRL-C to stop log)...",
src/briefcase/platforms/macOS/__init__.py:                    "Following system log output (type CTRL-C to stop log)...",
src/briefcase/platforms/web/static.py:                "Web server log output (type CTRL-C to stop log)...",
src/briefcase/platforms/web/static.py:            # CTRL-C is the accepted way to stop the server.
```

Discussion:

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 046c3c6e..1f49bc32 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -21,7 +21,7 @@ jobs:
     steps:
       - uses: actions/checkout@v3.1.0
       - name: Set up Python
-        uses: actions/setup-python@v4.3.0
+        uses: actions/setup-python@v4.2.0
         with:
           python-version: ${{ env.python_version }}
       - name: Lint with Pre-commit
@@ -38,7 +38,7 @@ jobs:
     - uses: actions/checkout@v3.1.0
       with:
         fetch-depth: 0
-    - uses: actions/setup-python@v4.3.0
+    - uses: actions/setup-python@v4.2.0
       with:
         python-version: ${{ env.python_version }}
     - run: pip install tox
@@ -51,7 +51,7 @@ jobs:
     - uses: actions/checkout@v3.1.0
       with:
         fetch-depth: 0
-    - uses: actions/setup-python@v4.3.0
+    - uses: actions/setup-python@v4.2.0
       with:
         python-version: ${{ env.python_version }}
     - run: pip install tox
@@ -75,7 +75,7 @@ jobs:
       with:
         fetch-depth: 0
     - name: Set up Python
-      uses: actions/setup-python@v4.3.0
+      uses: actions/setup-python@v4.2.0
       with:
         python-version: ${{ matrix.python-version }}
     - name: Get packages
@@ -111,7 +111,7 @@ jobs:
     - uses: actions/checkout@v3.1.0
       with:
         fetch-depth: 0
-    - uses: actions/setup-python@v4.3.0
+    - uses: actions/setup-python@v2
       with:
         # Use latest, so it understands all syntax.
         python-version: "3.10"
@@ -172,7 +172,7 @@ jobs:
     runs-on: ${{ matrix.platform }}
     steps:
     - name: Cache Briefcase tools
-      uses: actions/cache@v3.0.11
+      uses: actions/cache@v3.0.10
       with:
         key: briefcase-${{ matrix.platform }}
         path: |
@@ -184,7 +184,7 @@ jobs:
       with:
         fetch-depth: 0
     - name: Set up Python
-      uses: actions/setup-python@v4.3.0
+      uses: actions/setup-python@v4.2.0
       with:
         python-version: ${{ env.python_version }}
     - name: Install system dependencies
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d8a4c7a6..c3804a67 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -19,7 +19,7 @@ jobs:
         run: |
           echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV
       - name: Set up Python
-        uses: actions/setup-python@v4.3.0
+        uses: actions/setup-python@v4.2.0
         with:
           python-version: '3.x'
       - name: Get packages
diff --git a/.gitignore b/.gitignore
index 8d4c9f1f..87d0db87 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,4 +34,3 @@ venv*/
 *.wp[ur]
 
 tests/apps/verify-*/
-logs/
diff --git a/changes/904.bugfix.rst b/changes/904.bugfix.rst
new file mode 100644
index 00000000..4793f31c
--- /dev/null
+++ b/changes/904.bugfix.rst
@@ -0,0 +1,1 @@
+Briefcase now exits normally when CTRL-C is sent while tailing logs for the App when using ``briefcase run``.
diff --git a/changes/918.misc.rst b/changes/918.misc.rst
deleted file mode 100644
index 1c545dad..00000000
--- a/changes/918.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Minor problems with the release instructions were corrected.
diff --git a/changes/924.misc.rst b/changes/924.misc.rst
deleted file mode 100644
index 6bc7b881..00000000
--- a/changes/924.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Updated actions/setup-python from 4.2.0 to 4.3.0.
diff --git a/changes/925.misc.rst b/changes/925.misc.rst
deleted file mode 100644
index 1ed9d5e0..00000000
--- a/changes/925.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Updated actions/cache from 3.0.10 to 3.0.11.
diff --git a/changes/926.misc.rst b/changes/926.misc.rst
deleted file mode 100644
index 3ca2340a..00000000
--- a/changes/926.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Briefcase versions containing ``dev0`` are now considered development versions for fetching templates.
diff --git a/changes/929.misc.rst b/changes/929.misc.rst
deleted file mode 100644
index 64e013e0..00000000
--- a/changes/929.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-If the user sends CTRL-C while the Android emulator is starting, the troubleshooting steps are printed.
diff --git a/docs/how-to/internal/release.rst b/docs/how-to/internal/release.rst
index 148e82d1..e8b528ed 100644
--- a/docs/how-to/internal/release.rst
+++ b/docs/how-to/internal/release.rst
@@ -48,7 +48,7 @@ The procedure for cutting a new release is as follows:
 4. Tag the release, and push the branch and tag upstream::
 
     $ git tag v1.2.3
-    $ git push upstream HEAD:main
+    $ git push upstream main
     $ git push upstream v1.2.3
 
 5. Pushing the tag will start a workflow to create a draft release on GitHub.
diff --git a/src/briefcase/commands/create.py b/src/briefcase/commands/create.py
index 4e8fc9fd..dfff0ff8 100644
--- a/src/briefcase/commands/create.py
+++ b/src/briefcase/commands/create.py
@@ -291,9 +291,9 @@ class CreateCommand(BaseCommand):
             )
         except TemplateUnsupportedVersion:
             # If we're on a development branch, and the template branch was *not*
-            # provided explicitly, we can use a fallback development template.
+            # provided explicity, we can use a fallback development template.
             # Otherwise, re-raise the exception about the unsupported template version.
-            if version.dev is not None and app.template_branch is None:
+            if version.dev and app.template_branch is None:
                 # Development branches can use the main template.
                 self.logger.info(
                     f"Template branch {template_branch} not found; falling back to development template"
diff --git a/src/briefcase/commands/new.py b/src/briefcase/commands/new.py
index 65a2f512..2abfcfb4 100644
--- a/src/briefcase/commands/new.py
+++ b/src/briefcase/commands/new.py
@@ -503,7 +503,7 @@ What GUI toolkit do you want to use for this project?""",
         except TemplateUnsupportedVersion:
             # If we're *not* on a development branch, raise an error about
             # the missing template branch.
-            if version.dev is None:
+            if not version.dev:
                 raise
 
             # Development branches can use the main template.
diff --git a/src/briefcase/integrations/android_sdk.py b/src/briefcase/integrations/android_sdk.py
index cbd33eb1..0972ef84 100644
--- a/src/briefcase/integrations/android_sdk.py
+++ b/src/briefcase/integrations/android_sdk.py
@@ -812,7 +812,7 @@ Use the -d/--device option to explicitly specify the device to use.
                 f"""
 In future, you can specify this device by running:
 
-    $ briefcase run android -d "@{avd}"
+    $ briefcase run android -d @{avd}
 """
             )
         elif device:
@@ -999,27 +999,6 @@ In future, you can specify this device by running:
             start_new_session=True,
         )
 
-        # wrap AVD name in quotes since '@' is a special char in PowerShell
-        emulator_command = " ".join(
-            f'"{arg}"' if arg.startswith("@") else arg
-            for arg in map(str, emulator_popen.args)
-        )
-
-        error_msg = (
-            "{prologue}"
-            + f"""
-
-Try starting the emulator manually by running:
-
-    $ {emulator_command}
-
-Resolve any problems you discover, then try running your app again. You may
-find this page helpful in diagnosing emulator problems.
-
-    https://developer.android.com/studio/run/emulator-acceleration#accel-vm
-"""
-        )
-
         # The boot process happens in 2 phases.
         # First, the emulator appears in the device list. However, it's
         # not ready until the boot process has finished. To determine
@@ -1034,9 +1013,18 @@ find this page helpful in diagnosing emulator problems.
                 while adb is None:
                     if emulator_popen.poll() is not None:
                         raise BriefcaseCommandError(
-                            error_msg.format(
-                                prologue="Android emulator was unable to start!"
-                            )
+                            f"""\
+Android emulator was unable to start!
+
+Try starting the emulator manually by running:
+
+    $ {' '.join(str(arg) for arg in emulator_popen.args)}
+
+Resolve any problems you discover, then try running your app again. You may
+find this page helpful in diagnosing emulator problems.
+
+    https://developer.android.com/studio/run/emulator-acceleration#accel-vm
+"""
                         )
 
                     for device, details in sorted(self.devices().items()):
@@ -1065,28 +1053,27 @@ find this page helpful in diagnosing emulator problems.
                     while not adb.has_booted():
                         if emulator_popen.poll() is not None:
                             raise BriefcaseCommandError(
-                                error_msg.format(
-                                    prologue="Android emulator was unable to boot!"
-                                )
+                                f"""\
+Android emulator was unable to boot!
+
+Try starting the emulator manually by running:
+
+    $ {' '.join(str(arg) for arg in emulator_popen.args)}
+
+Resolve any problems you discover, then try running your app again. You may
+find this page helpful in diagnosing emulator problems.
+
+    https://developer.android.com/studio/run/emulator-acceleration#accel-vm
+"""
                             )
 
                         # Try again in 2 seconds...
                         self.sleep(2)
-        except BaseException as e:
+        except Exception:
             # if the emulator exited, this should return its output immediately;
             # if it is still running, this will quickly time out and print nothing.
             with suppress(subprocess.TimeoutExpired):
                 self.tools.logger.info(emulator_popen.communicate(timeout=1)[0])
-
-            # Provide troubleshooting steps if user gives up on the emulator starting
-            if isinstance(e, KeyboardInterrupt):
-                self.tools.logger.warning()
-                self.tools.logger.warning(
-                    error_msg.format(
-                        prologue="Is the Android emulator not starting up properly?"
-                    )
-                )
-
             raise
 
         # Return the device ID and full name.
diff --git a/src/briefcase/platforms/android/gradle.py b/src/briefcase/platforms/android/gradle.py
index 939d87b4..5104c5a9 100644
--- a/src/briefcase/platforms/android/gradle.py
+++ b/src/briefcase/platforms/android/gradle.py
@@ -189,9 +189,7 @@ class GradleRunCommand(GradleMixin, RunCommand):
         :param device_or_avd: The device to target. If ``None``, the user will
             be asked to re-run the command selecting a specific device.
         """
-        device, name, avd = self.tools.android_sdk.select_target_device(
-            device_or_avd=device_or_avd
-        )
+        device, name, avd = self.tools.android_sdk.select_target_device(device_or_avd)
 
         # If there's no device ID, that means the emulator isn't running.
         # If there's no AVD either, it means the user has chosen to create
@@ -238,12 +236,15 @@ class GradleRunCommand(GradleMixin, RunCommand):
                 if not pid:
                     time.sleep(0.5)
 
-        self.logger.info(
-            "Following device log output (type CTRL-C to stop log)...",
-            prefix=app.app_name,
-        )
-        self.logger.info("=" * 75)
-        adb.logcat(pid)
+        try:
+            self.logger.info(
+                "Following device log output (type CTRL-C to stop log)...",
+                prefix=app.app_name,
+            )
+            self.logger.info("=" * 75)
+            adb.logcat(pid)
+        except KeyboardInterrupt:
+            pass  # catch CTRL-C to exit normally
 
 
 class GradlePackageCommand(GradleMixin, PackageCommand):
diff --git a/src/briefcase/platforms/iOS/xcode.py b/src/briefcase/platforms/iOS/xcode.py
index fad82107..2624f565 100644
--- a/src/briefcase/platforms/iOS/xcode.py
+++ b/src/briefcase/platforms/iOS/xcode.py
@@ -432,6 +432,8 @@ class iOSXcodeRunCommand(iOSXcodeMixin, RunCommand):
             )
             self.logger.info("=" * 75)
             self.tools.subprocess.stream_output("log stream", simulator_log_popen)
+        except KeyboardInterrupt:
+            pass  # catch CTRL-C to exit normally
         finally:
             self.tools.subprocess.cleanup("log stream", simulator_log_popen)
 
diff --git a/src/briefcase/platforms/linux/appimage.py b/src/briefcase/platforms/linux/appimage.py
index d0d542ae..40d4818a 100644
--- a/src/briefcase/platforms/linux/appimage.py
+++ b/src/briefcase/platforms/linux/appimage.py
@@ -259,12 +259,16 @@ class LinuxAppImageRunCommand(LinuxAppImagePassiveMixin, RunCommand):
         """
         self.logger.info("Starting app...", prefix=app.app_name)
         try:
+            # Start streaming logs for the app.
+            self.logger.info("=" * 75)
             self.tools.subprocess.run(
                 [os.fsdecode(self.binary_path(app))],
                 check=True,
                 cwd=self.tools.home_path,
                 stream_output=True,
             )
+        except KeyboardInterrupt:
+            pass  # Catch CTRL-C to exit normally
         except subprocess.CalledProcessError as e:
             raise BriefcaseCommandError(f"Unable to start app {app.app_name}.") from e
 
diff --git a/src/briefcase/platforms/linux/flatpak.py b/src/briefcase/platforms/linux/flatpak.py
index 60d0a6a6..1f822088 100644
--- a/src/briefcase/platforms/linux/flatpak.py
+++ b/src/briefcase/platforms/linux/flatpak.py
@@ -192,10 +192,15 @@ class LinuxFlatpakRunCommand(LinuxFlatpakMixin, RunCommand):
         :param app: The config object for the app
         """
         self.logger.info("Starting app...", prefix=app.app_name)
-        self.tools.flatpak.run(
-            bundle=app.bundle,
-            app_name=app.app_name,
-        )
+        try:
+            # Start streaming logs for the app.
+            self.tools.logger.info("=" * 75)
+            self.tools.flatpak.run(
+                bundle=app.bundle,
+                app_name=app.app_name,
+            )
+        except KeyboardInterrupt:
+            pass  # Catch CTRL-C to exit normally
 
 
 class LinuxFlatpakPackageCommand(LinuxFlatpakMixin, PackageCommand):
diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py
index 3af9adbc..28213707 100644
--- a/src/briefcase/platforms/macOS/__init__.py
+++ b/src/briefcase/platforms/macOS/__init__.py
@@ -2,7 +2,9 @@ import os
 import re
 import subprocess
 import time
+from contextlib import suppress
 from pathlib import Path
+from signal import SIGTERM
 from zipfile import ZipFile
 
 from briefcase.config import BaseConfig
@@ -92,21 +94,24 @@ class macOSRunMixin:
             app_pid = get_process_id_by_command(
                 command=str(self.binary_path(app)), logger=self.logger
             )
+
             if app_pid is None:
-                self.logger.error()
-                self.logger.error(
+                raise BriefcaseCommandError(
                     f"Unable to find process for app {app.app_name} to start log streaming."
                 )
-            else:
+
+            try:
                 # Start streaming logs for the app.
-                self.logger.info(
-                    "Following system log output (type CTRL-C to stop log)...",
-                    prefix=app.app_name,
-                )
                 self.logger.info("=" * 75)
                 self.tools.subprocess.stream_output(
                     "log stream", log_popen, stop_func=lambda: is_process_dead(app_pid)
                 )
+            finally:
+                # Ensure the App also terminates when exiting
+                with suppress(ProcessLookupError):
+                    self.tools.os.kill(app_pid, SIGTERM)
+        except KeyboardInterrupt:
+            pass  # Catch CTRL-C to exit normally
         finally:
             self.tools.subprocess.cleanup("log stream", log_popen)
 
diff --git a/src/briefcase/platforms/web/static.py b/src/briefcase/platforms/web/static.py
index b54c2b12..714b9c0e 100644
--- a/src/briefcase/platforms/web/static.py
+++ b/src/briefcase/platforms/web/static.py
@@ -275,7 +275,7 @@ class StaticWebRunCommand(StaticWebMixin, RunCommand):
             url = f"http://{host}:{port}"
 
             self.logger.info(f"Web server open on {url}")
-            # If requested, open a brower tab on the newly opened server.
+            # If requested, open a browser tab on the newly opened server.
             if open_browser:
                 webbrowser.open_new_tab(url)
 
diff --git a/src/briefcase/platforms/windows/__init__.py b/src/briefcase/platforms/windows/__init__.py
index ea935531..04bf84ab 100644
--- a/src/briefcase/platforms/windows/__init__.py
+++ b/src/briefcase/platforms/windows/__init__.py
@@ -89,6 +89,8 @@ class WindowsRunCommand(RunCommand):
                 check=True,
                 stream_output=True,
             )
+        except KeyboardInterrupt:
+            pass  # Catch CTRL-C to exit normally
         except subprocess.CalledProcessError as e:
             raise BriefcaseCommandError(f"Unable to start app {app.app_name}.") from e
 


Test patch:
diff --git a/tests/commands/create/test_generate_app_template.py b/tests/commands/create/test_generate_app_template.py
index 599553b4..ff2fb356 100644
--- a/tests/commands/create/test_generate_app_template.py
+++ b/tests/commands/create/test_generate_app_template.py
@@ -51,12 +51,11 @@ def full_context():
     "briefcase_version, expected_branch",
     [
         ("37.42.1", "v37.42.1"),
-        ("37.42.2.dev0+gad61a29.d20220919", "v37.42.2"),
-        ("37.42.3.dev73+gad61a29.d20220919", "v37.42.3"),
-        ("37.42.4a1", "v37.42.4"),
-        ("37.42.5b2", "v37.42.5"),
-        ("37.42.6rc3", "v37.42.6"),
-        ("37.42.7.post1", "v37.42.7"),
+        ("37.42.2.dev73+gad61a29.d20220919", "v37.42.2"),
+        ("37.42.3a1", "v37.42.3"),
+        ("37.42.4b2", "v37.42.4"),
+        ("37.42.5rc3", "v37.42.5"),
+        ("37.42.6.post1", "v37.42.6"),
     ],
 )
 def test_default_template(
@@ -89,21 +88,11 @@ def test_default_template(
     )
 
 
-@pytest.mark.parametrize(
-    "briefcase_version",
-    ("37.42.7.dev0+gad61a29.d20220919", "37.42.7.dev73+gad61a29.d20220919"),
-)
-def test_default_template_dev(
-    monkeypatch,
-    create_command,
-    myapp,
-    full_context,
-    briefcase_version,
-):
+def test_default_template_dev(monkeypatch, create_command, myapp, full_context):
     """In a dev version, template will fall back to the 'main' branch if a
     versioned template doesn't exist."""
     # Set the Briefcase version
-    monkeypatch.setattr(briefcase, "__version__", briefcase_version)
+    monkeypatch.setattr(briefcase, "__version__", "37.42.7.dev73+gad61a29.d20220919")
 
     # There won't be a cookiecutter cache, so there won't be
     # a cache path (yet).
@@ -139,20 +128,12 @@ def test_default_template_dev(
     ]
 
 
-@pytest.mark.parametrize(
-    "briefcase_version",
-    ("37.42.7.dev0+gad61a29.d20220919", "37.42.7.dev73+gad61a29.d20220919"),
-)
 def test_default_template_dev_explicit_branch(
-    monkeypatch,
-    create_command,
-    myapp,
-    full_context,
-    briefcase_version,
+    monkeypatch, create_command, myapp, full_context
 ):
     """In a dev version, if an explicit branch is provided, it is used."""
     # Set the Briefcase version
-    monkeypatch.setattr(briefcase, "__version__", briefcase_version)
+    monkeypatch.setattr(briefcase, "__version__", "37.42.7.dev73+gad61a29.d20220919")
 
     # Set an explicit branch
     branch = "some_branch"
@@ -177,21 +158,13 @@ def test_default_template_dev_explicit_branch(
     ]
 
 
-@pytest.mark.parametrize(
-    "briefcase_version",
-    ("37.42.7.dev0+gad61a29.d20220919", "37.42.7.dev73+gad61a29.d20220919"),
-)
 def test_default_template_dev_explicit_invalid_branch(
-    monkeypatch,
-    create_command,
-    myapp,
-    full_context,
-    briefcase_version,
+    monkeypatch, create_command, myapp, full_context
 ):
     """In a dev version, if an explicit (but invalid) branch is provided, the
-    fallback to the 'main' branch will not occur."""
+    fall back to the 'main' branch will not occur."""
     # Set the Briefcase version to a dev version
-    monkeypatch.setattr(briefcase, "__version__", briefcase_version)
+    monkeypatch.setattr(briefcase, "__version__", "37.42.7.dev73+gad61a29.d20220919")
 
     # Set an explicit branch
     branch = "some_branch"
@@ -515,11 +488,7 @@ def test_cached_template(monkeypatch, create_command, myapp, full_context):
 
 
 def test_cached_template_offline(
-    monkeypatch,
-    create_command,
-    myapp,
-    full_context,
-    capsys,
+    monkeypatch, create_command, myapp, full_context, capsys
 ):
     """If the user is offline, a cached template won't be updated, but will
     still work."""
@@ -563,10 +532,7 @@ def test_cached_template_offline(
 
 
 def test_cached_missing_branch_template(
-    monkeypatch,
-    create_command,
-    myapp,
-    full_context,
+    monkeypatch, create_command, myapp, full_context
 ):
     """If the cached repo doesn't have a branch for this Briefcase version, an
     error is raised."""
diff --git a/tests/commands/new/test_new_app.py b/tests/commands/new/test_new_app.py
index 64eb4cc6..16ead8a8 100644
--- a/tests/commands/new/test_new_app.py
+++ b/tests/commands/new/test_new_app.py
@@ -24,12 +24,11 @@ def new_command(tmp_path):
     "briefcase_version, expected_branch",
     [
         ("37.42.1", "v37.42.1"),
-        ("37.42.2.dev0+gad61a29.d20220919", "v37.42.2"),
-        ("37.42.3.dev73+gad61a29.d20220919", "v37.42.3"),
-        ("37.42.4a1", "v37.42.4"),
-        ("37.42.5b2", "v37.42.5"),
-        ("37.42.6rc3", "v37.42.6"),
-        ("37.42.7.post1", "v37.42.7"),
+        ("37.42.2.dev73+gad61a29.d20220919", "v37.42.2"),
+        ("37.42.3a1", "v37.42.3"),
+        ("37.42.4b2", "v37.42.4"),
+        ("37.42.5rc3", "v37.42.5"),
+        ("37.42.6.post1", "v37.42.6"),
     ],
 )
 def test_new_app(
@@ -114,14 +113,10 @@ def test_new_app_missing_template(monkeypatch, new_command, tmp_path):
     )
 
 
-@pytest.mark.parametrize(
-    "briefcase_version",
-    ("37.42.7.dev0+gad61a29.d20220919", "37.42.7.dev73+gad61a29.d20220919"),
-)
-def test_new_app_dev(monkeypatch, new_command, tmp_path, briefcase_version):
+def test_new_app_dev(monkeypatch, new_command, tmp_path):
     """In a dev version, template will fall back to the 'main' branch if a
     versioned template doesn't exist."""
-    monkeypatch.setattr(briefcase, "__version__", briefcase_version)
+    monkeypatch.setattr(briefcase, "__version__", "37.42.7.dev73+gad61a29.d20220919")
     app_context = {
         "formal_name": "My Application",
         "class_name": "MyApplication",
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_select_target_device.py b/tests/integrations/android_sdk/AndroidSDK/test_select_target_device.py
index 3d26a77a..e6e80112 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test_select_target_device.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test_select_target_device.py
@@ -196,7 +196,7 @@ def test_select_running_emulator(mock_tools, android_sdk, capsys):
 
     # A re-run prompt has been provided
     out = capsys.readouterr().out
-    assert 'briefcase run android -d "@runningEmulator"' in out
+    assert "briefcase run android -d @runningEmulator" in out
 
 
 def test_select_idle_emulator(mock_tools, android_sdk, capsys):
@@ -214,7 +214,7 @@ def test_select_idle_emulator(mock_tools, android_sdk, capsys):
 
     # A re-run prompt has been provided
     out = capsys.readouterr().out
-    assert 'briefcase run android -d "@idleEmulator"' in out
+    assert "briefcase run android -d @idleEmulator" in out
 
 
 def test_select_create_emulator(mock_tools, android_sdk, capsys):
diff --git a/tests/integrations/android_sdk/AndroidSDK/test_start_emulator.py b/tests/integrations/android_sdk/AndroidSDK/test_start_emulator.py
index 6389fba2..bf508edf 100644
--- a/tests/integrations/android_sdk/AndroidSDK/test_start_emulator.py
+++ b/tests/integrations/android_sdk/AndroidSDK/test_start_emulator.py
@@ -94,8 +94,7 @@ def test_start_emulator(mock_tools, android_sdk):
     ]
 
     # poll() on the process continues to return None, indicating no problem.
-    emu_popen = MagicMock(spec=subprocess.Popen)
-    emu_popen.args = [android_sdk.emulator_path, "@idleEmulator"]
+    emu_popen = MagicMock(spec_set=subprocess.Popen)
     emu_popen.poll.return_value = None
     mock_tools.subprocess.Popen.return_value = emu_popen
 
@@ -168,15 +167,14 @@ def test_start_emulator_fast_start(mock_tools, android_sdk):
         # emu avd_name
         subprocess.CalledProcessError(
             returncode=1, cmd="emu avd name"
-        ),  # physical device
+        ),  # phyiscal device
         "idleEmulator\nOK",  # running emulator
         # shell getprop sys.boot_completed
         "1\n",
     ]
 
     # poll() on the process continues to return None, indicating no problem.
-    emu_popen = MagicMock(spec=subprocess.Popen)
-    emu_popen.args = [android_sdk.emulator_path, "@idleEmulator"]
+    emu_popen = MagicMock(spec_set=subprocess.Popen)
     emu_popen.poll.return_value = None
     mock_tools.subprocess.Popen.return_value = emu_popen
 
@@ -274,12 +272,12 @@ def test_emulator_fail_to_start(mock_tools, android_sdk):
     # poll() on the process returns None for the first two attempts, but then
     # returns 1 indicating failure.
     emu_popen = MagicMock(spec=subprocess.Popen)
-    emu_popen.args = [android_sdk.emulator_path, "@idleEmulator"]
     emu_popen.poll.side_effect = [None, None, 1]
+    emu_popen.args = [android_sdk.emulator_path, "@idleEmulator"]
     mock_tools.subprocess.Popen.return_value = emu_popen
 
     # Start the emulator
-    with pytest.raises(BriefcaseCommandError) as exc_info:
+    with pytest.raises(BriefcaseCommandError):
         android_sdk.start_emulator("idleEmulator")
 
     # The process was started.
@@ -308,9 +306,6 @@ def test_emulator_fail_to_start(mock_tools, android_sdk):
     # Took a total of 2 naps before failing.
     assert android_sdk.sleep.call_count == 2
 
-    # Expected error message was printed
-    assert "Android emulator was unable to start!" in exc_info.value.msg
-
 
 def test_emulator_fail_to_boot(mock_tools, android_sdk):
     """If the emulator fails to boot, and error is displayed."""
@@ -370,7 +365,6 @@ def test_emulator_fail_to_boot(mock_tools, android_sdk):
 
     # poll() on the process returns failure during simulator boot
     emu_popen = MagicMock(spec=subprocess.Popen)
-    emu_popen.args = [android_sdk.emulator_path, "@idleEmulator"]
     emu_popen.poll.side_effect = [
         None,  # in start loop without emulator in device list
         None,  # in start loop without emulator in device list
@@ -383,7 +377,7 @@ def test_emulator_fail_to_boot(mock_tools, android_sdk):
     mock_tools.subprocess.Popen.return_value = emu_popen
 
     # Start the emulator
-    with pytest.raises(BriefcaseCommandError) as exc_info:
+    with pytest.raises(BriefcaseCommandError):
         android_sdk.start_emulator("idleEmulator")
 
     # The process was started.
@@ -418,47 +412,3 @@ def test_emulator_fail_to_boot(mock_tools, android_sdk):
 
     # Took a total of 4 naps.
     assert android_sdk.sleep.call_count == 4
-
-    # Expected error message was printed.
-    assert "Android emulator was unable to boot!" in exc_info.value.msg
-
-
-def test_emulator_ctrl_c(mock_tools, android_sdk, capsys):
-    """If emulator startup is interrupted by the user, an error is
-    displayed."""
-    # Short circuit device loop by returning no devices
-    android_sdk.devices = MagicMock(side_effect=[{}, {}])
-
-    # poll() on the process returns None for the first two attempts, but then
-    # raises KeyboardInterrupt to simulate the user sending CTRL-C
-    emu_popen = MagicMock(spec=subprocess.Popen)
-    emu_popen.args = [android_sdk.emulator_path, "@idleEmulator"]
-    emu_popen.poll.side_effect = [None, None, KeyboardInterrupt]
-    mock_tools.subprocess.Popen.return_value = emu_popen
-
-    # Start the emulator
-    with pytest.raises(KeyboardInterrupt):
-        android_sdk.start_emulator("idleEmulator")
-
-    # The process was started.
-    mock_tools.subprocess.Popen.assert_called_with(
-        [
-            os.fsdecode(android_sdk.emulator_path),
-            "@idleEmulator",
-            "-dns-server",
-            "8.8.8.8",
-        ],
-        env=android_sdk.env,
-        stdout=subprocess.PIPE,
-        stderr=subprocess.STDOUT,
-        bufsize=1,
-        start_new_session=True,
-    )
-
-    # Took a total of 2 naps before KeyboardInterrupt.
-    assert android_sdk.sleep.call_count == 2
-
-    # Expected error message was printed.
-    assert (
-        "Is the Android emulator not starting up properly?" in capsys.readouterr().out
-    )
diff --git a/tests/platforms/android/gradle/test_run.py b/tests/platforms/android/gradle/test_run.py
index e421e12a..93de4ce4 100644
--- a/tests/platforms/android/gradle/test_run.py
+++ b/tests/platforms/android/gradle/test_run.py
@@ -90,7 +90,7 @@ def test_run_existing_device(run_command, first_app_config):
 
     # select_target_device was invoked with a specific device
     run_command.tools.android_sdk.select_target_device.assert_called_once_with(
-        device_or_avd="exampleDevice"
+        "exampleDevice"
     )
 
     # The ADB wrapper is created
@@ -221,6 +221,28 @@ def test_run_idle_device(run_command, first_app_config):
     run_command.tools.mock_adb.logcat.assert_called_once_with("777")
 
 
+def test_run_ctrl_c(run_command, first_app_config, capsys):
+    """When CTRL-C is sent during logcat, Briefcase exits normally."""
+    # Set up device selection to return a running physical device.
+    run_command.tools.android_sdk.select_target_device = MagicMock(
+        return_value=("exampleDevice", "ExampleDevice", None)
+    )
+
+    run_command.tools.mock_adb.logcat.side_effect = KeyboardInterrupt
+
+    # Invoke run_app (and KeyboardInterrupt does not surface)
+    run_command.run_app(first_app_config, device_or_avd="exampleDevice")
+
+    # logcat is called and raised KeyboardInterrupt
+    run_command.tools.mock_adb.logcat.assert_called_once_with("777")
+
+    # Shows the try block for KeyboardInterrupt was entered
+    assert capsys.readouterr().out.endswith(
+        "[first-app] Following device log output (type CTRL-C to stop log)...\n"
+        "===========================================================================\n"
+    )
+
+
 def test_log_file_extra(run_command, monkeypatch):
     """Android commands register a log file extra to list SDK packages."""
     verify = MagicMock(return_value=run_command.tools.android_sdk)
diff --git a/tests/platforms/iOS/xcode/test_run.py b/tests/platforms/iOS/xcode/test_run.py
index 80972a94..97e7481b 100644
--- a/tests/platforms/iOS/xcode/test_run.py
+++ b/tests/platforms/iOS/xcode/test_run.py
@@ -118,7 +118,7 @@ def test_run_app_simulator_booted(run_command, first_app_config, tmp_path):
             ),
         ]
     )
-    # The log is being tailed; no process cleanup is triggered
+    # The log is being tailed and process cleanup is triggered
     run_command.tools.subprocess.Popen.assert_called_with(
         [
             "xcrun",
@@ -225,7 +225,7 @@ def test_run_app_simulator_shut_down(run_command, first_app_config, tmp_path):
             ),
         ]
     )
-    # The log is being tailed; no process cleanup is triggered
+    # The log is being tailed and process cleanup is triggered
     run_command.tools.subprocess.Popen.assert_called_with(
         [
             "xcrun",
@@ -345,7 +345,7 @@ def test_run_app_simulator_shutting_down(run_command, first_app_config, tmp_path
             ),
         ]
     )
-    # The log is being tailed; no process cleanup has occurred
+    # The log is being tailed and process cleanup is triggered
     run_command.tools.subprocess.Popen.assert_called_with(
         [
             "xcrun",
@@ -721,3 +721,36 @@ def test_run_app_simulator_launch_failure(run_command, first_app_config, tmp_pat
     run_command.tools.subprocess.cleanup.assert_called_once_with(
         "log stream", log_stream_process
     )
+
+
+def test_run_app_ctrl_c(run_command, first_app_config, tmp_path, capsys):
+    """When CTRL-C is sent during log streaming, Briefcase exits normally."""
+    # A valid target device will be selected.
+    run_command.select_target_device = mock.MagicMock(
+        return_value=("2D3503A3-6EB9-4B37-9B17-C7EFEF2FA32D", "13.2", "iPhone 11")
+    )
+
+    # Simulator is already booted
+    run_command.get_device_state = mock.MagicMock(return_value=DeviceState.BOOTED)
+
+    run_command.tools.subprocess = mock.MagicMock(spec_set=Subprocess)
+    log_stream_process = mock.MagicMock(spec_set=subprocess.Popen)
+    run_command.tools.subprocess.Popen.return_value = log_stream_process
+    run_command.tools.subprocess.stream_output.side_effect = KeyboardInterrupt
+
+    # Run the app (and KeyboardInterrupt does not surface)
+    run_command.run_app(first_app_config)
+
+    # The log is being tailed and process cleanup is triggered
+    run_command.tools.subprocess.stream_output.assert_called_with(
+        "log stream", log_stream_process
+    )
+    run_command.tools.subprocess.cleanup.assert_called_with(
+        "log stream", log_stream_process
+    )
+
+    # Shows the try block for KeyboardInterrupt was entered
+    assert capsys.readouterr().out.endswith(
+        "[first-app] Following simulator log output (type CTRL-C to stop log)...\n"
+        "===========================================================================\n"
+    )
diff --git a/tests/platforms/linux/appimage/test_run.py b/tests/platforms/linux/appimage/test_run.py
index 6c8dc993..04823d43 100644
--- a/tests/platforms/linux/appimage/test_run.py
+++ b/tests/platforms/linux/appimage/test_run.py
@@ -116,3 +116,42 @@ def test_run_app_failed(first_app_config, tmp_path):
         check=True,
         stream_output=True,
     )
+
+
+def test_run_app_ctrl_c(first_app_config, tmp_path, capsys):
+    """When CTRL-C is sent while the App is running, Briefcase exits
+    normally."""
+    command = LinuxAppImageRunCommand(
+        logger=Log(),
+        console=Console(),
+        base_path=tmp_path / "base_path",
+        data_path=tmp_path / "briefcase",
+    )
+    command.tools.home_path = tmp_path / "home"
+
+    # Set the host architecture for test purposes.
+    command.tools.host_arch = "wonky"
+
+    command.tools.subprocess = MagicMock(spec_set=Subprocess)
+    command.tools.subprocess.run.side_effect = KeyboardInterrupt
+
+    # Invoke run_app (and KeyboardInterrupt does not surface)
+    command.run_app(first_app_config)
+
+    # AppImage is run and raises KeyboardInterrupt
+    command.tools.subprocess.run.assert_called_with(
+        [
+            os.fsdecode(
+                tmp_path / "base_path" / "linux" / "First_App-0.0.1-wonky.AppImage"
+            )
+        ],
+        cwd=tmp_path / "home",
+        check=True,
+        stream_output=True,
+    )
+
+    # Shows the try block for KeyboardInterrupt was entered
+    assert capsys.readouterr().out.endswith(
+        "[first-app] Starting app...\n"
+        "===========================================================================\n"
+    )
diff --git a/tests/platforms/linux/flatpak/test_run.py b/tests/platforms/linux/flatpak/test_run.py
index 25f074be..ad70a9df 100644
--- a/tests/platforms/linux/flatpak/test_run.py
+++ b/tests/platforms/linux/flatpak/test_run.py
@@ -22,3 +22,31 @@ def test_run(first_app_config, tmp_path):
         bundle="com.example",
         app_name="first-app",
     )
+
+
+def test_run_ctrl_c(first_app_config, tmp_path, capsys):
+    """When CTRL-C is sent while the App is running, Briefcase exits
+    normally."""
+    command = LinuxFlatpakRunCommand(
+        logger=Log(),
+        console=Console(),
+        base_path=tmp_path / "base_path",
+        data_path=tmp_path / "briefcase",
+    )
+    command.tools.flatpak = mock.MagicMock(spec_set=Flatpak)
+    command.tools.flatpak.run.side_effect = KeyboardInterrupt
+
+    # Invoke run_app (and KeyboardInterrupt does not surface)
+    command.run_app(first_app_config)
+
+    # App is executed
+    command.tools.flatpak.run.assert_called_once_with(
+        bundle="com.example",
+        app_name="first-app",
+    )
+
+    # Shows the try block for KeyboardInterrupt was entered
+    assert capsys.readouterr().out.endswith(
+        "[first-app] Starting app...\n"
+        "===========================================================================\n"
+    )
diff --git a/tests/platforms/macOS/app/test_run.py b/tests/platforms/macOS/app/test_run.py
index 12767f07..a4f45ff5 100644
--- a/tests/platforms/macOS/app/test_run.py
+++ b/tests/platforms/macOS/app/test_run.py
@@ -1,5 +1,6 @@
 import os
 import subprocess
+from signal import SIGTERM
 from unittest import mock
 
 import pytest
@@ -22,6 +23,7 @@ def test_run_app(first_app_config, tmp_path, monkeypatch):
     command.tools.subprocess = mock.MagicMock(spec_set=Subprocess)
     log_stream_process = mock.MagicMock(spec_set=subprocess.Popen)
     command.tools.subprocess.Popen.return_value = log_stream_process
+    command.tools.os.kill = mock.MagicMock()
 
     # To satisfy coverage, the stop function must be invoked at least once
     # when invoking stream_output.
@@ -33,6 +35,7 @@ def test_run_app(first_app_config, tmp_path, monkeypatch):
     monkeypatch.setattr(
         "briefcase.platforms.macOS.get_process_id_by_command", lambda *a, **kw: 100
     )
+
     command.run_app(first_app_config)
 
     # Calls were made to start the app and to start a log stream.
@@ -63,6 +66,7 @@ def test_run_app(first_app_config, tmp_path, monkeypatch):
         log_stream_process,
         stop_func=mock.ANY,
     )
+    command.tools.os.kill.assert_called_with(100, SIGTERM)
     command.tools.subprocess.cleanup.assert_called_with(
         "log stream", log_stream_process
     )
@@ -136,7 +140,9 @@ def test_run_app_find_pid_failed(first_app_config, tmp_path, monkeypatch, capsys
         "briefcase.platforms.macOS.get_process_id_by_command",
         lambda *a, **kw: None,
     )
-    command.run_app(first_app_config)
+
+    with pytest.raises(BriefcaseCommandError) as exc_info:
+        command.run_app(first_app_config)
 
     # Calls were made to start the app and to start a log stream.
     bin_path = command.binary_path(first_app_config)
@@ -161,13 +167,46 @@ def test_run_app_find_pid_failed(first_app_config, tmp_path, monkeypatch, capsys
         cwd=tmp_path / "home",
         check=True,
     )
-    assert capsys.readouterr().out == (
-        "\n"
-        "[first-app] Starting app...\n"
-        "\n"
-        "Unable to find process for app first-app to start log streaming.\n"
+    assert exc_info.value.msg == (
+        "Unable to find process for app first-app to start log streaming."
     )
     command.tools.subprocess.stream_output.assert_not_called()
     command.tools.subprocess.cleanup.assert_called_with(
         "log stream", log_stream_process
     )
+
+
+def test_run_app_ctrl_c(first_app_config, tmp_path, monkeypatch, capsys):
+    """When CTRL-C is sent during log streaming, Briefcase exits normally."""
+    command = macOSAppRunCommand(
+        logger=Log(),
+        console=Console(),
+        base_path=tmp_path / "base_path",
+        data_path=tmp_path / "briefcase",
+    )
+    command.tools.home_path = tmp_path / "home"
+    command.tools.subprocess = mock.MagicMock(spec_set=Subprocess)
+    log_stream_process = mock.MagicMock(spec_set=subprocess.Popen)
+    command.tools.subprocess.Popen.return_value = log_stream_process
+    command.tools.os.kill = mock.MagicMock()
+    command.tools.subprocess.stream_output.side_effect = KeyboardInterrupt
+
+    monkeypatch.setattr(
+        "briefcase.platforms.macOS.get_process_id_by_command", lambda *a, **kw: 100
+    )
+
+    # Invoke run_app (and KeyboardInterrupt does not surface)
+    command.run_app(first_app_config)
+
+    # log streaming is started
+    command.tools.subprocess.stream_output.assert_called_with(
+        "log stream",
+        log_stream_process,
+        stop_func=mock.ANY,
+    )
+
+    # Shows the try block for KeyboardInterrupt was entered
+    assert capsys.readouterr().out.endswith(
+        "[first-app] Starting app...\n"
+        "===========================================================================\n"
+    )
diff --git a/tests/platforms/macOS/xcode/test_run.py b/tests/platforms/macOS/xcode/test_run.py
index 59a44e35..8979a52e 100644
--- a/tests/platforms/macOS/xcode/test_run.py
+++ b/tests/platforms/macOS/xcode/test_run.py
@@ -3,6 +3,7 @@
 # the app backend for exhaustive tests.
 import os
 import subprocess
+from signal import SIGTERM
 from unittest.mock import MagicMock
 
 from briefcase.console import Console, Log
@@ -10,7 +11,7 @@ from briefcase.integrations.subprocess import Subprocess
 from briefcase.platforms.macOS.xcode import macOSXcodeRunCommand
 
 
-def test_run_app(first_app_config, tmp_path):
+def test_run_app(first_app_config, tmp_path, monkeypatch):
     """A macOS Xcode app can be started."""
     command = macOSXcodeRunCommand(
         logger=Log(),
@@ -20,6 +21,14 @@ def test_run_app(first_app_config, tmp_path):
     )
     command.tools.home_path = tmp_path / "home"
     command.tools.subprocess = MagicMock(spec_set=Subprocess)
+    log_stream_process = MagicMock(spec_set=subprocess.Popen)
+    command.tools.subprocess.Popen.return_value = log_stream_process
+    command.tools.os.kill = MagicMock()
+
+    monkeypatch.setattr(
+        "briefcase.platforms.macOS.get_process_id_by_command", lambda *a, **kw: 100
+    )
+
     command.run_app(first_app_config)
 
     # Calls were made to start the app and to start a log stream.
@@ -45,3 +54,7 @@ def test_run_app(first_app_config, tmp_path):
         cwd=tmp_path / "home",
         check=True,
     )
+    command.tools.os.kill.assert_called_with(100, SIGTERM)
+    command.tools.subprocess.cleanup.assert_called_with(
+        "log stream", log_stream_process
+    )
diff --git a/tests/platforms/windows/app/test_run.py b/tests/platforms/windows/app/test_run.py
index ce526f2d..457f84ae 100644
--- a/tests/platforms/windows/app/test_run.py
+++ b/tests/platforms/windows/app/test_run.py
@@ -75,3 +75,44 @@ def test_run_app_failed(first_app_config, tmp_path):
         check=True,
         stream_output=True,
     )
+
+
+def test_run_app_ctrl_c(first_app_config, tmp_path, capsys):
+    """When CTRL-C is sent while the App is running, Briefcase exits
+    normally."""
+    command = WindowsAppRunCommand(
+        logger=Log(),
+        console=Console(),
+        base_path=tmp_path / "base_path",
+        data_path=tmp_path / "briefcase",
+    )
+    command.tools.home_path = tmp_path / "home"
+    command.tools.subprocess = mock.MagicMock(spec_set=Subprocess)
+    command.tools.subprocess.run.side_effect = KeyboardInterrupt
+
+    # Invoke run_app (and KeyboardInterrupt does not surface)
+    command.run_app(first_app_config)
+
+    # App is started
+    command.tools.subprocess.run.assert_called_with(
+        [
+            os.fsdecode(
+                tmp_path
+                / "base_path"
+                / "windows"
+                / "app"
+                / "First App"
+                / "src"
+                / "First App.exe"
+            ),
+        ],
+        cwd=tmp_path / "home",
+        check=True,
+        stream_output=True,
+    )
+
+    # Shows the try block for KeyboardInterrupt was entered
+    assert capsys.readouterr().out.endswith(
+        "[first-app] Starting app...\n"
+        "===========================================================================\n"
+    )