Repository: beeware/briefcase

Issue:
macOS app-package merging doesn't preserve executable permissions
### Describe the bug

When using Selenium WebDriver in a BeeWare app, auto-downloading of the Chrome WebDriver fails in Briefcase version 0.3.16. The same functionality works as expected in Briefcase v0.3.15.

### Steps to reproduce

1. Create a simple BeeWare app that uses Selenium WebDriver.
2. Package the app using Briefcase v0.3.16.
3. Run the app and trigger the Selenium code (e.g., by clicking a button in the app).

### Expected behavior

The Chrome WebDriver should auto-download and the Selenium code should execute without issues.

### Screenshots

_No response_

### Environment

- Operating System: macOS 13.4.1
- Platform: x86_64
- Python version: 3.11
- Software versions:
  - Briefcase: 0.3.16
  - Toga: 0.3.1
  - Selenium 4.14.0


### Logs

```
/Users/jay/Desktop/pythonProject/test1/build/test1/macos/app/test.app/Contents/Resources/app_packages/selenium/webdriver/common/driver_finder.py", line 41, in get_path
    raise NoSuchDriverException(msg) from err
selenium.common.exceptions.NoSuchDriverException: Message: Unable to obtain driver for chrome using Selenium Manager.; For documentation on this error, please visit: https://www.selenium.dev/documentation/webdriver/troubleshooting/errors/driver_location
"
```


### Additional context

- The issue does not occur when using Briefcase v0.3.15. I tested by installing v0.3.15 from the start and I also tested downgrading in the existing project. Both of these methods resolved the issue.
- Selenium is specified in pyproject.toml.

Discussion:

Patch:
diff --git a/changes/1510.bugfix.rst b/changes/1510.bugfix.rst
new file mode 100644
index 00000000..b015fd55
--- /dev/null
+++ b/changes/1510.bugfix.rst
@@ -0,0 +1,1 @@
+When merging dependencies on macOS, file permissions are now preserved.
diff --git a/src/briefcase/platforms/macOS/utils.py b/src/briefcase/platforms/macOS/utils.py
index 840de9fe..e0be7226 100644
--- a/src/briefcase/platforms/macOS/utils.py
+++ b/src/briefcase/platforms/macOS/utils.py
@@ -243,9 +243,9 @@ class AppPackagesMergeMixin:
                                     f"between sources; ignoring {source_app_packages.suffix[1:]} version."
                                 )
                         else:
-                            # The file doesn't exist yet; copy it as is, and store the
-                            # digest for later comparison
-                            self.tools.shutil.copyfile(source_path, target_path)
+                            # The file doesn't exist yet; copy it as is (including
+                            # permissions), and store the digest for later comparison
+                            self.tools.shutil.copy(source_path, target_path)
                             digests[relative_path] = sha256_file_digest(source_path)
 
         # Call lipo on each dylib that was found to create the fat version.


Test patch:
diff --git a/tests/platforms/macOS/test_AppPackagesMergeMixin__merge_app_packages.py b/tests/platforms/macOS/test_AppPackagesMergeMixin__merge_app_packages.py
index ddc73441..6e537ec6 100644
--- a/tests/platforms/macOS/test_AppPackagesMergeMixin__merge_app_packages.py
+++ b/tests/platforms/macOS/test_AppPackagesMergeMixin__merge_app_packages.py
@@ -1,3 +1,4 @@
+import os
 import subprocess
 from pathlib import Path
 
@@ -26,11 +27,13 @@ def test_merge(dummy_command, pre_existing, tmp_path):
         extra_content=[
             ("second/other.py", "# other python"),
             ("second/different.py", "# different python"),
+            ("second/some-binary", "# A file with executable permissions", 0o755),
             ("second/sub1/module1.dylib", "dylib-gothic"),
             ("second/sub1/module2.so", "dylib-gothic"),
             ("second/sub1/module3.dylib", "dylib-gothic"),
         ],
     )
+
     # Create 2 packages in the "modern" architecture app package sources
     # The first package is pure, so it won't exist in the second app_packages.
     # The "second" package:
@@ -110,6 +113,7 @@ def test_merge(dummy_command, pre_existing, tmp_path):
         (Path("second/__init__.py"), ""),
         (Path("second/app.py"), "# This is the app"),
         (Path("second/different.py"), "# different python"),
+        (Path("second/some-binary"), "# A file with executable permissions"),
         (Path("second/other.py"), "# other python"),
         (Path("second/sub1"), None),
         (Path("second/sub1/module1.dylib"), "dylib-merged"),
@@ -149,6 +153,9 @@ def test_merge(dummy_command, pre_existing, tmp_path):
         ),
     }
 
+    # Check that the embedded binary has executable permissions
+    assert os.access(merged_path / "second" / "some-binary", os.X_OK)
+
 
 def test_merge_problem(dummy_command, tmp_path):
     "If a binary cannot be merged, an exception is raised."
diff --git a/tests/utils.py b/tests/utils.py
index b9cb7e59..e39dde53 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -235,16 +235,22 @@ def create_installed_package(
     :param package: The name of the package in the wheel. Defaults to ``dummy``
     :param version: The version number of the package. Defaults to ``1.2.3``
     :param tag: The installation tag for the package. Defaults to a pure python wheel.
-    :param extra_content: Optional. A list of tuples of ``(path, content)`` that will be
-        added to the wheel.
+    :param extra_content: Optional. A list of tuples of ``(path, content)`` or
+        ``(path, content, chmod)`` that will be added to the wheel. If ``chmod`` is
+        not specified, default filesystem permissions will be used.
     """
-    for filename, content in installed_package_content(
+    for entry in installed_package_content(
         package=package,
         version=version,
         tag=tag,
         extra_content=extra_content,
     ):
-        create_file(path / filename, content=content)
+        try:
+            filename, content, chmod = entry
+        except ValueError:
+            filename, content = entry
+            chmod = None
+        create_file(path / filename, content=content, chmod=chmod)
 
 
 def create_wheel(
@@ -260,8 +266,9 @@ def create_wheel(
     :param package: The name of the package in the wheel. Defaults to ``dummy``
     :param version: The version number of the package. Defaults to ``1.2.3``
     :param tag: The installation tag for the package. Defaults to a pure python wheel.
-    :param extra_content: Optional. A list of tuples of ``(path, content)`` that
-        will be added to the wheel.
+    :param extra_content: Optional. A list of tuples of ``(path, content)`` or
+        ``(path, content, chmod)`` that will be added to the wheel. If ``chmod`` is
+        not specified, default filesystem permissions will be used.
     """
     wheel_filename = path / f"{package}-{version}-{tag}.whl"