Repository: beeware/toga

Issue:
App crashes without logs/error message when camera permissions are required, but required metadata not added to pyproject.toml
### Describe the bug

If :
- `permission.camera` is missing from app's `pyproject.toml` file, and
- `self.camera.request_permission()` is called from within a `toga.App` instance, and
- the app is run using `briefcase run [-ur]`

Then it crashes and produces no logs.

If you dig around in the 'Report' dialog generated by macOS, you get the following message:

> This app has crashed because it attempted to access privacy-sensitive data without a usage description. The app's Info.plist must contain an NSCameraUsageDescription key with a string value explaining to the user how the app uses this data


### Steps to reproduce

1. Instantiate a briefcase app
2. Add a call to `self.camera.request_permission()` in a method
3. Add the method as a callback to a button
4. `briefcase run -ur` and push the button

If you run `briefcase dev` the app seems to work and does not warn that the permissions have not been set.

### Expected behavior

A warning during development, and an error when running a bundled app, that the `permission.camera` metadata is missing from the `pyproject.toml` file

### Screenshots

_No response_

### Environment

- Operating System: MacOS Sonoma 14.4.1
- Python version: 3.12.1
- Software versions:
  - Briefcase: 0.3.18
  - Toga: 0.4.4


### Logs

[briefcase.2024_05_22-05_55_05.run.log](https://github.com/beeware/toga/files/15401453/briefcase.2024_05_22-05_55_05.run.log)


### Additional context

_No response_


Discussion:

Resolving patch:
diff --git a/cocoa/src/toga_cocoa/hardware/camera.py b/cocoa/src/toga_cocoa/hardware/camera.py
--- a/cocoa/src/toga_cocoa/hardware/camera.py
+++ b/cocoa/src/toga_cocoa/hardware/camera.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import warnings
 from threading import Thread
 
 from rubicon.objc import Block, objc_method
@@ -22,6 +23,7 @@
     AVCaptureVideoPreviewLayer,
     AVLayerVideoGravityResizeAspectFill,
     AVMediaTypeVideo,
+    NSBundle,
 )
 
 
@@ -247,6 +249,23 @@ def photo_taken(self, photo):
 class Camera:
     def __init__(self, interface):
         self.interface = interface
+
+        if not NSBundle.mainBundle.objectForInfoDictionaryKey(
+            "NSCameraUsageDescription"
+        ):  # pragma: no cover
+            # The app doesn't have the NSCameraUsageDescription key (e.g., via
+            # `permission.camera` in Briefcase). No-cover because we can't manufacture
+            # this condition in testing.
+            msg = (
+                "Application metadata does not declare that the app will use "
+                "the camera. See "
+                "https://toga.readthedocs.io/en/stable/reference/api/hardware/camera.html"
+            )
+            if self.interface.app.is_bundled:
+                raise RuntimeError(msg)
+            else:
+                warnings.warn(msg)
+
         self.preview_windows = []
 
     def has_permission(self, allow_unknown=False):
diff --git a/core/src/toga/app.py b/core/src/toga/app.py
--- a/core/src/toga/app.py
+++ b/core/src/toga/app.py
@@ -16,6 +16,7 @@
     ValuesView,
 )
 from email.message import Message
+from pathlib import Path
 from typing import TYPE_CHECKING, Any, Protocol
 from warnings import warn
 from weakref import WeakValueDictionary
@@ -570,6 +571,15 @@ def version(self) -> str | None:
         """The version number of the app (read-only)."""
         return self._version
 
+    @property
+    def is_bundled(self) -> bool:
+        """Has the app been bundled as a standalone binary, or is it running as a Python script?"""
+        return Path(sys.executable).stem not in {
+            "python",
+            f"python{sys.version_info.major}",
+            f"python{sys.version_info.major}.{sys.version_info.minor}",
+        }
+
     ######################################################################
     # App lifecycle
     ######################################################################
diff --git a/core/src/toga/icons.py b/core/src/toga/icons.py
--- a/core/src/toga/icons.py
+++ b/core/src/toga/icons.py
@@ -136,11 +136,7 @@ def __init__(
             # icon, and this isn't running as a script, fall back to the application
             # binary
             if path is _APP_ICON:
-                if Path(sys.executable).stem not in {
-                    "python",
-                    f"python{sys.version_info.major}",
-                    f"python{sys.version_info.major}.{sys.version_info.minor}",
-                }:
+                if toga.App.app.is_bundled:
                     try:
                         # Use the application binary's icon
                         self._impl = self.factory.Icon(interface=self, path=None)


Test patch:
diff --git a/core/tests/app/test_app.py b/core/tests/app/test_app.py
--- a/core/tests/app/test_app.py
+++ b/core/tests/app/test_app.py
@@ -289,6 +289,7 @@ def test_app_metadata(monkeypatch, event_loop):
     assert app.version == "1.2.3"
     assert app.home_page == "https://example.com/test-app"
     assert app.description == "A test app"
+    assert app.is_bundled is False
 
 
 def test_explicit_app_metadata(monkeypatch, event_loop):