Repository: beeware/toga

Issue:
Cannot run helloworld on macOS after 0.4.3 release
### Describe the bug

The helloworld app fails to run on macOS.

Briefcase CI is also failing: https://github.com/beeware/briefcase/actions/runs/8973799632/job/24646416726?pr=1778

Traceback:
```
[15:08:51] Application quit abnormally (Exit code -6)!                                                                                                             subprocess.py:195
           Traceback (most recent call last):                                                                                                                      subprocess.py:195
             File "/Users/user/tmp/beeware/helloworld/build/helloworld/macos/xcode/build/Release/Hello World.app/Contents/Resources/app_packages/toga/icons.py",   subprocess.py:195
           line 35, in __get__                                                                                                                                                      
               value = getattr(cls, f"__{self.f.__name__}")                                                                                                        subprocess.py:195
                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^                                                                                                        subprocess.py:195
           AttributeError: type object 'Icon' has no attribute '__APP_ICON'. Did you mean: 'APP_ICON'?                                                             subprocess.py:195
                                                                                                                                                                   subprocess.py:195
           During handling of the above exception, another exception occurred:                                                                                     subprocess.py:195
                                                                                                                                                                   subprocess.py:195
           Traceback (most recent call last):                                                                                                                      subprocess.py:195
             File "/Users/user/tmp/beeware/helloworld/build/helloworld/macos/xcode/build/Release/Hello World.app/Contents/Resources/app_packages/toga/icons.py",   subprocess.py:195
           line 126, in __init__                                                                                                                                                    
               full_path = self._full_path(                                                                                                                        subprocess.py:195
                           ^^^^^^^^^^^^^^^^                                                                                                                        subprocess.py:195
[15:08:52]   File "/Users/user/tmp/beeware/helloworld/build/helloworld/macos/xcode/build/Release/Hello World.app/Contents/Resources/app_packages/toga/icons.py",   subprocess.py:195
           line 182, in _full_path                                                                                                                                                  
               raise FileNotFoundError(f"Can't find icon {self.path}")                                                                                             subprocess.py:195
           FileNotFoundError: Can't find icon resources/helloworld                                                                                                 subprocess.py:195
                                                                                                                                                                   subprocess.py:195
           During handling of the above exception, another exception occurred:                                                                                     subprocess.py:195
                                                                                                                                                                   subprocess.py:195
           Traceback (most recent call last):                                                                                                                      subprocess.py:195
             File "<frozen runpy>", line 198, in _run_module_as_main                                                                                               subprocess.py:195
             File "<frozen runpy>", line 88, in _run_code                                                                                                          subprocess.py:195
             File "/Users/user/tmp/beeware/helloworld/build/helloworld/macos/xcode/build/Release/Hello World.app/Contents/Resources/app/helloworld/__main__.py",   subprocess.py:195
           line 4, in <module>                                                                                                                                                      
               main().main_loop()                                                                                                                                  subprocess.py:195
               ^^^^^^                                                                                                                                              subprocess.py:195
             File "/Users/user/tmp/beeware/helloworld/build/helloworld/macos/xcode/build/Release/Hello World.app/Contents/Resources/app/helloworld/app.py", line   subprocess.py:195
           26, in main                                                                                                                                                              
               return HelloWorld()                                                                                                                                 subprocess.py:195
                      ^^^^^^^^^^^^                                                                                                                                 subprocess.py:195
             File "/Users/user/tmp/beeware/helloworld/build/helloworld/macos/xcode/build/Release/Hello World.app/Contents/Resources/app_packages/toga/app.py",     subprocess.py:195
           line 474, in __init__                                                                                                                                                    
               self.icon = Icon.APP_ICON                                                                                                                           subprocess.py:195
                           ^^^^^^^^^^^^^                                                                                                                           subprocess.py:195
             File "/Users/user/tmp/beeware/helloworld/build/helloworld/macos/xcode/build/Release/Hello World.app/Contents/Resources/app_packages/toga/icons.py",   subprocess.py:195
           line 37, in __get__                                                                                                                                                      
               value = self.f(owner)                                                                                                                               subprocess.py:195
                       ^^^^^^^^^^^^^                                                                                                                               subprocess.py:195
             File "/Users/user/tmp/beeware/helloworld/build/helloworld/macos/xcode/build/Release/Hello World.app/Contents/Resources/app_packages/toga/icons.py",   subprocess.py:195
           line 70, in APP_ICON                                                                                                                                                     
               return Icon(_APP_ICON)                                                                                                                              subprocess.py:195
                      ^^^^^^^^^^^^^^^                                                                                                                              subprocess.py:195
             File "/Users/user/tmp/beeware/helloworld/build/helloworld/macos/xcode/build/Release/Hello World.app/Contents/Resources/app_packages/toga/icons.py",   subprocess.py:195
           line 145, in __init__                                                                                                                                                    
               self._impl = self.factory.Icon(interface=self, path=None)                                                                                           subprocess.py:195
                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^                                                                                           subprocess.py:195
             File "/Users/user/tmp/beeware/helloworld/build/helloworld/macos/xcode/build/Release/Hello                                                             subprocess.py:195
           World.app/Contents/Resources/app_packages/toga_cocoa/icons.py", line 43, in __init__                                                                                     
               raise ValueError(f"Unable to load icon from {path}")                                                                                                subprocess.py:195
           ValueError: Unable to load icon from None                                                                                                               subprocess.py:195
           Traceback (most recent call last):                                                                                                                      subprocess.py:195
             File "Hello World.app/Contents/Resources/app_packages/toga/icons.py", line 35, in __get__                                                             subprocess.py:195
               value = getattr(cls, f"__{self.f.__name__}")                                                                                                        subprocess.py:195
                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^                                                                                                        subprocess.py:195
           AttributeError: type object 'Icon' has no attribute '__APP_ICON'. Did you mean: 'APP_ICON'?                                                             subprocess.py:195
```

### Steps to reproduce

`briefcase new --no-input && cd helloworld && briefcase run macos xcode`

### Expected behavior

Tutorial helloworld app builds and runs on macOS.

### Screenshots

_No response_

### Environment

- Operating System: Sonoma
- Python version: 3.12.1
- Software versions:
  - Briefcase: `0.3.18`
  - Toga: `0.4.3`

### Logs

[briefcase.2024_05_06-15_08_56.run.log](https://github.com/beeware/toga/files/15225399/briefcase.2024_05_06-15_08_56.run.log)


### Additional context

_No response_


Discussion:
This only appears to affect the `macOS Xcode` target; AFAICT, the `briefcase run macOS` target works fine. @rmartin16 can you confirm this is the case for you?

Indeed; I had to target Xcode to trigger the error in my VM.
The issue appears to be in the Info.plist for the generated template; it's not adding the .icns extension to the CFBundleIconFile attribute. 
There's a secondary problem in that the ValueError being raised when an icon can't be loaded isn't being caught by the base Icon class.

I'll push a fix for both of these shortly. The Info.plist can be fixed by backporting the template change; that will be enough to avoid immediate problems. The base Icon class fix will need a new Toga release.

EDIT: To clarify - we would need to cut a new release to get the fix for this bug into the wild. I think we can live with just the template fix for the short term.

Resolving patch:
diff --git a/cocoa/src/toga_cocoa/icons.py b/cocoa/src/toga_cocoa/icons.py
--- a/cocoa/src/toga_cocoa/icons.py
+++ b/cocoa/src/toga_cocoa/icons.py
@@ -16,13 +16,20 @@ def __init__(self, interface, path):
         if path is None:
             # Look to the app bundle, and get the icon. Set self.path as None
             # as an indicator that this is the app's default icon.
+            # This bundle icon file definition might not contain an extension,
+            # even thought the actual file will; so force the .icns extension.
             bundle_icon = Path(
                 NSBundle.mainBundle.objectForInfoDictionaryKey("CFBundleIconFile")
             )
             path = NSBundle.mainBundle.pathForResource(
                 bundle_icon.stem,
-                ofType=bundle_icon.suffix,
+                ofType=".icns",
             )
+            # If the icon file doesn't exist, raise the problem as FileNotFoundError
+            # This can't be tested, as the app will always have an icon.
+            if not Path(path).is_file():
+                raise FileNotFoundError()  # pragma: no cover
+
             self.path = None
         else:
             self.path = path
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
@@ -91,11 +91,12 @@ def __init__(
             path, or a path relative to the module that defines your Toga application
             class. This base filename should *not* contain an extension. If an extension
             is specified, it will be ignored. If the icon cannot be found, the default
-            icon will be :attr:`~toga.Icon.DEFAULT_ICON`.
+            icon will be :attr:`~toga.Icon.DEFAULT_ICON`. If an icon file is found, but
+            it cannot be loaded (due to a file format or permission error), an exception
+            will be raised.
         :param system: **For internal use only**
         """
         self.factory = get_platform_factory()
-
         try:
             # Try to load the icon with the given path snippet. If the request is for the
             # app icon, use ``resources/<app name>`` as the path.
@@ -131,7 +132,7 @@ def __init__(
 
             self._impl = self.factory.Icon(interface=self, path=full_path)
         except FileNotFoundError:
-            # Icon path couldn't be loaded. If the path is the sentinel for the app
+            # Icon path couldn't be found. If the path is the sentinel for the app
             # icon, and this isn't running as a script, fall back to the application
             # binary
             if path is _APP_ICON:
diff --git a/dummy/src/toga_dummy/icons.py b/dummy/src/toga_dummy/icons.py
--- a/dummy/src/toga_dummy/icons.py
+++ b/dummy/src/toga_dummy/icons.py
@@ -2,18 +2,19 @@
 
 
 class Icon(LoggedObject):
-    ICON_EXISTS = True
+    ICON_FAILURE = None
     EXTENSIONS = [".png", ".ico"]
     SIZES = None
 
     def __init__(self, interface, path):
         super().__init__()
         self.interface = interface
-        if not self.ICON_EXISTS:
-            raise FileNotFoundError("Couldn't find icon")
-        elif path is None:
-            self.path = "<APP ICON>"
-        elif path == {}:
-            raise FileNotFoundError("No image variants found")
+        if self.ICON_FAILURE:
+            raise self.ICON_FAILURE
         else:
-            self.path = path
+            if path is None:
+                self.path = "<APP ICON>"
+            elif path == {}:
+                raise FileNotFoundError("No image variants found")
+            else:
+                self.path = path


Test patch:
diff --git a/core/tests/test_icons.py b/core/tests/test_icons.py
--- a/core/tests/test_icons.py
+++ b/core/tests/test_icons.py
@@ -113,8 +113,15 @@ def test_create(monkeypatch, app, path, system, sizes, extensions, final_paths):
     assert icon._impl.path == final_paths
 
 
-def test_create_fallback(app, capsys):
+def test_create_fallback_missing(monkeypatch, app, capsys):
     """If a resource doesn't exist, a fallback icon is used."""
+    # Prime the dummy so the app icon cannot be loaded
+    monkeypatch.setattr(
+        DummyIcon,
+        "ICON_FAILURE",
+        FileNotFoundError(),
+    )
+
     icon = toga.Icon("resources/missing")
 
     assert icon._impl is not None
@@ -127,6 +134,19 @@ def test_create_fallback(app, capsys):
     )
 
 
+def test_create_fallback_unloadable(monkeypatch, app, capsys):
+    """If a resource exists, but can't be loaded, an error is raised."""
+    # Prime the dummy so the app icon cannot be loaded
+    monkeypatch.setattr(
+        DummyIcon,
+        "ICON_FAILURE",
+        ValueError("Icon could not be loaded"),
+    )
+
+    with pytest.raises(ValueError):
+        toga.Icon("resources/sample")
+
+
 def test_create_fallback_variants(monkeypatch, app, capsys):
     """If a resource with size variants doesn't exist, a fallback icon is used."""
     monkeypatch.setattr(DummyIcon, "SIZES", [32, 72])
@@ -164,7 +184,7 @@ def test_create_app_icon(monkeypatch, app, capsys):
 
 
 def test_create_app_icon_missing(monkeypatch, app, capsys):
-    """The app icon can be constructed"""
+    """If the app icon is missing, a fallback is used"""
     # When running under pytest, code will identify as running as a script
 
     # Load the app default icon.
@@ -196,9 +216,13 @@ def test_create_app_icon_non_script(monkeypatch, app, capsys):
 
 
 def test_create_app_icon_missing_non_script(monkeypatch, app, capsys):
-    """The binary executableThe app icon can be reset to the default"""
-    # Prime the dummy so the app icon cannot be loaded
-    monkeypatch.setattr(DummyIcon, "ICON_EXISTS", False)
+    """If the icon from binary executable cannot be found, the app icon is reset to the default"""
+    # Prime the dummy so the app icon cannot be found
+    monkeypatch.setattr(
+        DummyIcon,
+        "ICON_FAILURE",
+        FileNotFoundError(),
+    )
 
     # Patch sys.executable so the test looks like it's running as a packaged binary
     monkeypatch.setattr(sys, "executable", "/path/to/App")