diff --git a/changes/547.feature.rst b/changes/547.feature.rst new file mode 100644 index 000000000..93804de94 --- /dev/null +++ b/changes/547.feature.rst @@ -0,0 +1 @@ +App permissions can now be declared as part of an app's configuration. diff --git a/docs/reference/configuration.rst b/docs/reference/configuration.rst index 3d36d436e..28cf501fc 100644 --- a/docs/reference/configuration.rst +++ b/docs/reference/configuration.rst @@ -458,6 +458,38 @@ sources from all levels, starting from least to most specific. A URL where more details about the application can be found. +Permissions +=========== + +Applications may also need to declare the permissions they require. Permissions are +specified as sub-attributes of a ``permission`` property, defined at the level of an +project, app, or platform. Permission declarations are *cumulative*; if an application +defines permissions at the global level, application level, *and* platform level, the +final set of permissions will be the *merged* set of all permissions from all levels, +starting from least to most specific, with the most specific taking priority. + +Briefcase maintains a set of cross-platform permissions: + +* ``permission.camera`` - permission to access to the camera to take photos or video. +* ``permission.microphone`` - permission to access the microphone. +* ``permission.coarse_location`` - permission to determine a rough GPS location. +* ``permission.fine_location`` - permission to determine a precise GPS location. +* ``permission.background_location`` - permission to track GPS location while in the background. +* ``permission.photo_library`` - permission to access to the user's photo library. + +If a cross-platform permission is used, it will be mapped to platform-specific values in +whatever files are used to define permissions on that platform. + +Permissions can also be configured by adding platform-specific configuration items. See the documentation for the the platform backends to see the options that are available. + +The value for each permission is a short description of why that permission is required. +If the platform requires, the value may be displayed to the user as part of an +authorization dialog. This description should describe *why* the app requires the +permission, rather than a generic description of the permission being requested. + +The use of permissions may also imply other settings in your app. See the individual +platform backends for details on how cross-platform permissions are mapped. + Document types ============== @@ -498,7 +530,7 @@ will use ``resources/icon.icns`` on macOS, and ``resources/icon.ico`` on Windows. Some platforms also require different *variants* (e.g., both square and round -icons). These variants can be specified by qualifying the icon specification: +icons). These variants can be specified by qualifying the icon specification:: icon.round = "resource/round-icon" icon.square = "resource/square-icon" diff --git a/docs/reference/platforms/android/gradle.rst b/docs/reference/platforms/android/gradle.rst index d8246017e..c9d27cbdf 100644 --- a/docs/reference/platforms/android/gradle.rst +++ b/docs/reference/platforms/android/gradle.rst @@ -117,9 +117,85 @@ Android allows for some customization of the colors used by your app: Application configuration ========================= -The following options can be added to the -``tool.briefcase.app..android`` section of your ``pyproject.toml`` -file. +The following options can be added to the ``tool.briefcase.app..android`` +section of your ``pyproject.toml`` file. + +``android_manifest_attrs_extra_content`` +---------------------------------------- + +Additional attributes that will be added verbatim to the ```` declaration of +the ``AndroidManifest.xml`` of your app. + +``android_manifest_extra_content`` +---------------------------------- + +Additional content that will be added verbatim just before the closing ```` +declaration of the ``AndroidManifest.xml`` of your app. + +``android_manifest_application_attrs_extra_content`` +---------------------------------------------------- + +Additional attributes that will be added verbatim to the ```` declaration +of the ``AndroidManifest.xml`` of your app. + +``android_manifest_application_extra_content`` +---------------------------------------------- + +Additional content that will be added verbatim just before the closing +```` declaration of the ``AndroidManifest.xml`` of your app. + +``android_manifest_activity_attrs_extra_content`` +------------------------------------------------- + +Additional attributes that will be added verbatim to the ```` declaration of +the ``AndroidManifest.xml`` of your app. + +``android_manifest_activity_extra_content`` +------------------------------------------- + +Additional content that will be added verbatim just before the closing ```` +declaration of the ``AndroidManifest.xml`` of your app. + +``build_gradle_extra_content`` +------------------------------ + +A string providing additional Gradle settings to use when building your app. +This will be added verbatim to the end of your ``app/build.gradle`` file. + +``feature`` +----------- + +A property whose sub-properties define the features that will be marked as required by +the final app. Each entry will be converted into a ```` declaration in +your app's ``AndroidManifest.xml``, with the feature name matching the name of the +sub-attribute. + +For example, specifying:: + + feature."android.hardware.bluetooth" = true + +will result in an ``AndroidManifest.xml`` declaration of:: + + + +The use of some cross-platform permissions will imply the addition of features; see +:ref:`the discussion on Android permissions ` for more details. + +``permission`` +-------------- + +A property whose sub-properties define the platform-specific permissions that will be +marked as required by the final app. Each entry will be converted into a +```` declaration in your app's ``AndroidManifest.xml``, with the +feature name matching the name of the sub-attribute. + +For example, specifying:: + + permission."android.permission.HIGH_SAMPLING_RATE_SENSORS" = true + +will result in an ``AndroidManifest.xml`` declaration of:: + + ``version_code`` ---------------- @@ -197,18 +273,47 @@ continue to run in the background, but there will be no visual manifestation that it is running. It may also be useful as a cleanup mechanism when running in a CI configuration. -Application configuration -========================= +.. _android-permissions: + +Permissions +=========== -The following options can be added to the -``tool.briefcase.app..android`` section of your ``pyproject.toml`` -file: +Briefcase cross platform permissions map to ```` declarations in the +app's ``AppManifest.xml``: -``build_gradle_extra_content`` ------------------------------- +* ``camera``: ``android.permission.CAMERA`` +* ``microphone``: ``android.permission.RECORD_AUDIO`` +* ``coarse_location``: ``android.permission.ACCESS_COARSE_LOCATION`` +* ``fine_location``: ``android.permission.ACCESS_FINE_LOCATION`` +* ``background_location``: ``android.permission.ACCESS_BACKGROUND_LOCATION`` +* ``photo_library``: ``android.permission.READ_MEDIA_VISUAL_USER_SELECTED`` -A string providing additional Gradle settings to use when building your app. -This will be added verbatim to the end of your ``app/build.gradle`` file. +Every application will be automatically granted the ``android.permission.INTERNET`` and +``android.permission.NETWORK_STATE`` permissions. + +Specifying a ``camera`` permission will result in the following non-required ``feature`` +definitions being implicitly added to your app: + +* ``android.hardware.camera``, +* ``android.hardware.camera.any``, +* ``android.hardware.camera.front``, +* ``android.hardware.camera.external`` and +* ``android.hardware.camera.autofocus``. + +Specifying the ``coarse_location``, ``fine_location`` or ``background_location`` +permissions will result in the following non-required ``feature`` declarations being +implicitly added to your app: + +* ``android.hardware.location.network`` +* ``android.hardware.location.gps`` + +This is done to ensure that an app is not prevented from installing if the device +doesn't have the given features. You can make the feature explicitly required by +manually defining these feature requirements. For example, to make GPS hardware +required, you could add the following to the Android section of your +``pyproject.toml``:: + + feature."android.hardware.location.gps" = True Platform quirks =============== diff --git a/docs/reference/platforms/iOS/xcode.rst b/docs/reference/platforms/iOS/xcode.rst index ebc38d012..cef15c266 100644 --- a/docs/reference/platforms/iOS/xcode.rst +++ b/docs/reference/platforms/iOS/xcode.rst @@ -64,6 +64,42 @@ run The device simulator to target. Can be either a UDID, a device name (e.g., ``"iPhone 11"``), or a device name and OS version (``"iPhone 11::iOS 13.3"``). +Application configuration +========================= + +The following options can be added to the ``tool.briefcase.app..iOS.app`` +section of your ``pyproject.toml`` file. + +``info`` +-------- + +A property whose sub-attributes define keys that will be added to the app's +``Info.plist`` file. Each entry will be converted into a key in the entitlements +file. For example, specifying:: + + info."UIFileSharingEnabled" = true + +will result in an ``Info.plist`` declaration of:: + + UIFileSharingEnabled + +Any Boolean or string value can be used for an ``Info.plist`` value. + +Permissions +=========== + +Briefcase cross platform permissions map to the following ``info`` keys: + +* ``camera``: ``NSCameraUsageDescription`` +* ``microphone``: ``NSMicrophoneUsageDescription`` +* ``coarse_location``: ``NSLocationDefaultAccuracyReduced=True`` if ``fine_location`` is + not defined, plus ``NSLocationWhenInUseUsageDescription`` if ``background_location`` + is not defined +* ``fine_location``: ``NSLocationDefaultAccuracyReduced=False``, plus + ``NSLocationWhenInUseUsageDescription`` if ``background_location`` is not defined +* ``background_location``: ``NSLocationAlwaysAndWhenInUseUsageDescription`` +* ``photo_library``: ``NSPhotoLibraryAddUsageDescription`` + Platform quirks =============== diff --git a/docs/reference/platforms/linux/flatpak.rst b/docs/reference/platforms/linux/flatpak.rst index e4058cf17..09d30c6fb 100644 --- a/docs/reference/platforms/linux/flatpak.rst +++ b/docs/reference/platforms/linux/flatpak.rst @@ -77,6 +77,35 @@ The following options can be added to the ``tool.briefcase.app..linux.flatpak`` section of your ``pyproject.toml`` file: +``finish_arg`` +~~~~~~~~~~~~~~ + +The arguments used to configure the Flatpak sandbox. ``finish_arg`` is an attribute +that can have additional sub-attributes; each sub-attribute maps to a single property +that will be added to the app's manifest. For example, to add ``--allow=bluetooth`` as a +finish argument, you would specify:: + + finish_arg."allow=bluetooth" = True + +Briefcase adds the following finish arguments by default: + +* ``share=ipc`` +* ``socket=x11`` +* ``nosocket=wayland`` +* ``share=network`` +* ``device=dri`` +* ``socket=pulseaudio`` +* ``filesystem=xdg-cache`` +* ``filesystem=xdg-config`` +* ``filesystem=xdg-data`` +* ``filesystem=xdg-documents`` +* ``socket=session-bus`` + +These can be disabled by explicitly setting their value to ``False``; for example, to +disable audio access, you would specify:: + + finish_arg."socket=pulseaudio" = false + ``flatpak_runtime_repo_alias`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -115,6 +144,18 @@ build the Flatpak app. The Flatpak runtime and SDK are paired; so, both a ``flatpak_runtime`` and a corresponding ``flatpak_sdk`` must be defined. +``modules_extra_content`` +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Additional build instructions that will be inserted into the Flatpak manifest, *after* +Python has been installed and ``pip`` is guaranteed to exist, but *before* any app code +or app packages have been installed into the Flatpak. + +Permissions +=========== + +Permissions are not used for Flatpak packaging. + Compilation issues with Flatpak =============================== diff --git a/docs/reference/platforms/macOS/app.rst b/docs/reference/platforms/macOS/app.rst index f68633239..5f3284cfb 100644 --- a/docs/reference/platforms/macOS/app.rst +++ b/docs/reference/platforms/macOS/app.rst @@ -59,6 +59,46 @@ Application configuration The following options can be added to the ``tool.briefcase.app..macOS.app`` section of your ``pyproject.toml`` file. +``entitlement`` +~~~~~~~~~~~~~~~ + +A property whose sub-attributes define keys that will be added to the app's +``Entitlements.plist`` file. Each entry will be converted into a key in the entitlements +file. For example, specifying:: + + entitlement."com.apple.vm.networking" = true + +will result in an ``Entitlements.plist`` declaration of:: + + com.apple.vm.networking + +Any Boolean or string value can be used for an entitlement value. + +All macOS apps are automatically granted the following entitlements: + +* ``com.apple.security.cs.allow-unsigned-executable-memory`` +* ``com.apple.security.cs.disable-library-validation`` + +You can disable these default entitlements by defining them manually. For example, to +enable library validation, you could add the following to your ``pyproject.toml``:: + + entitlement."com.apple.security.cs.disable-library-validation" = false + +``info`` +~~~~~~~~ + +A property whose sub-attributes define keys that will be added to the app's +``Info.plist`` file. Each entry will be converted into a key in the entitlements +file. For example, specifying:: + + info."NSAppleScriptEnabled" = true + +will result in an ``Info.plist`` declaration of:: + + NSAppleScriptEnabled + +Any Boolean or string value can be used for an ``Info.plist`` value. + ``universal_build`` ~~~~~~~~~~~~~~~~~~~ @@ -68,6 +108,24 @@ only be executable on the host platform on which it was built - i.e., if you bui an x86_64 machine, you will produce an x86_65 binary; if you build on an ARM64 machine, you will produce an ARM64 binary. +Permissions +=========== + +Briefcase cross platform permissions map to a combination of ``info`` and ``entitlement`` +keys: + +* ``microphone``: an ``entitlement`` of ``com.apple.security.device.audio-input`` +* ``camera``: an ``entitlement`` of ``com.apple.security.device.camera`` +* ``coarse_location``: an ``info`` entry for ``NSLocationUsageDescription`` + (ignored if ``background_location`` or ``fine_location`` is defined); plus an + entitlement of ``com.apple.security.personal-information.location`` +* ``fine_location``: an ``info`` entry for ``NSLocationUsageDescription``(ignored + if ``background_location`` is defined); plus an ``entitlement`` of + ``com.apple.security.personal-information.location`` +* ``background_location``: an ``info`` entry for ``NSLocationUsageDescription``; + plus an ``entitlement`` of ``com.apple.security.personal-information.location`` +* ``photo_library``: an ``entitlement`` of ``com.apple.security.personal-information.photos-library`` + Platform quirks =============== diff --git a/docs/reference/platforms/macOS/xcode.rst b/docs/reference/platforms/macOS/xcode.rst index 49d756a04..007ea8d8a 100644 --- a/docs/reference/platforms/macOS/xcode.rst +++ b/docs/reference/platforms/macOS/xcode.rst @@ -65,6 +65,46 @@ Application configuration The following options can be added to the ``tool.briefcase.app..macOS.Xcode`` section of your ``pyproject.toml`` file. +``entitlement`` +~~~~~~~~~~~~~~~ + +A property whose sub-attributes define keys that will be added to the app's +``Entitlements.plist`` file. Each entry will be converted into a key in the entitlements +file. For example, specifying:: + + entitlement."com.apple.vm.networking" = true + +will result in an ``Entitlements.plist`` declaration of:: + + com.apple.vm.networking + +Any Boolean or string value can be used for an entitlement value. + +All macOS apps are automatically granted the following entitlements: + +* ``com.apple.security.cs.allow-unsigned-executable-memory`` +* ``com.apple.security.cs.disable-library-validation`` + +You can disable these default entitlements by defining them manually. For example, to +enable library validation, you could add the following to your ``pyproject.toml``:: + + entitlement."com.apple.security.cs.disable-library-validation" = false + +``info`` +~~~~~~~~ + +A property whose sub-attributes define keys that will be added to the app's +``Info.plist`` file. Each entry will be converted into a key in the entitlements +file. For example, specifying:: + + info."NSAppleScriptEnabled" = true + +will result in an ``Info.plist`` declaration of:: + + NSAppleScriptEnabled + +Any Boolean or string value can be used for an ``Info.plist`` value. + ``universal_build`` ~~~~~~~~~~~~~~~~~~~ @@ -74,6 +114,24 @@ only be executable on the host platform on which it was built - i.e., if you bui an x86_64 machine, you will produce an x86_65 binary; if you build on an ARM64 machine, you will produce an ARM64 binary. +Permissions +=========== + +Briefcase cross platform permissions map to a combination of ``info`` and ``entitlement`` +keys: + +* ``microphone``: an ``entitlement`` of ``com.apple.security.device.audio-input`` +* ``camera``: an ``entitlement`` of ``com.apple.security.device.camera`` +* ``coarse_location``: an ``info`` entry for ``NSLocationUsageDescription`` + (ignored if ``background_location`` or ``fine_location`` is defined); plus an + entitlement of ``com.apple.security.personal-information.location`` +* ``fine_location``: an ``info`` entry for ``NSLocationUsageDescription``(ignored + if ``background_location`` is defined); plus an ``entitlement`` of + ``com.apple.security.personal-information.location`` +* ``background_location``: an ``info`` entry for ``NSLocationUsageDescription``; + plus an ``entitlement`` of ``com.apple.security.personal-information.location`` +* ``photo_library``: an ``entitlement`` of ``com.apple.security.personal-information.photos-library`` + Platform quirks =============== diff --git a/src/briefcase/commands/create.py b/src/briefcase/commands/create.py index 4baa19caf..81e98697c 100644 --- a/src/briefcase/commands/create.py +++ b/src/briefcase/commands/create.py @@ -84,6 +84,9 @@ class CreateCommand(BaseCommand): command = "create" description = "Create a new app for a target platform." + # app properties that won't be exposed to the context + hidden_app_properties = {"permission"} + @property def app_template_url(self): """The URL for a cookiecutter repository to use when creating apps.""" @@ -166,6 +169,38 @@ def document_type_icon_targets(self, app: AppConfig): except KeyError: return {} + def _x_permissions(self, app: AppConfig): + """Extract the known cross-platform permission definitions from the app's + permissions definitions. + + After calling this method, the ``permissions`` declaration for the app will + only contain keys that are *not* cross-platform keys. + + :param app: The config object for the app + :returns: A dictionary of known cross-platform permission definitions. + """ + return { + key: app.permission.pop(key, None) + for key in [ + "camera", + "microphone", + "coarse_location", + "fine_location", + "background_location", + "photo_library", + ] + } + + def permissions_context(self, app: AppConfig, x_permissions: dict[str, str]): + """Additional template context for permissions. + + :param app: The config object for the app + :param x_permissions: The dictionary of known cross-platform permission + definitions. + :returns: The template context describing permissions for the app. + """ + return {} + def output_format_template_context(self, app: AppConfig): """Additional template context required by the output format. @@ -194,7 +229,11 @@ def generate_app_template(self, app: AppConfig): template_branch = app.template_branch # Construct a template context from the app configuration. - extra_context = app.__dict__.copy() + extra_context = { + key: value + for key, value in app.__dict__.items() + if key not in self.hidden_app_properties + } # Remove the context items that describe the template extra_context.pop("template") @@ -223,6 +262,9 @@ def generate_app_template(self, app: AppConfig): } ) + # Add in any extra template context to support permissions + extra_context.update(self.permissions_context(app, self._x_permissions(app))) + # Add in any extra template context required by the output format. extra_context.update(self.output_format_template_context(app)) diff --git a/src/briefcase/config.py b/src/briefcase/config.py index 77d0e30ed..a86f8f2ac 100644 --- a/src/briefcase/config.py +++ b/src/briefcase/config.py @@ -191,6 +191,7 @@ def __init__( icon=None, splash=None, document_type=None, + permission=None, template=None, template_branch=None, test_sources=None, @@ -218,6 +219,7 @@ def __init__( self.icon = icon self.splash = splash self.document_types = {} if document_type is None else document_type + self.permission = {} if permission is None else permission self.template = template self.template_branch = template_branch self.test_sources = test_sources @@ -346,12 +348,25 @@ def merge_config(config, data): situ. :param data: The new configuration data to merge into the configuration. """ - for option in ["requires", "sources", "test_requires", "test_sources"]: + # Properties that are cumulative lists + for option in [ + "requires", + "sources", + "test_requires", + "test_sources", + ]: value = data.pop(option, []) if value: config.setdefault(option, []).extend(value) + # Properties that are cumulative tables + for option in ["permission"]: + value = data.pop(option, {}) + + if value: + config.setdefault(option, {}).update(value) + config.update(data) diff --git a/src/briefcase/integrations/cookiecutter.py b/src/briefcase/integrations/cookiecutter.py index 64b766309..4c292af18 100644 --- a/src/briefcase/integrations/cookiecutter.py +++ b/src/briefcase/integrations/cookiecutter.py @@ -73,3 +73,37 @@ def escape_non_ascii(obj): environment.filters["escape_toml"] = escape_toml environment.filters["escape_non_ascii"] = escape_non_ascii + + +class PListExtension(Extension): + """Jinja2 extension for generating plist values.""" + + def __init__(self, environment): + """Initialize the extension with the given environment.""" + super().__init__(environment) + + def plist_value(obj): + """Render value in plist format.""" + if isinstance(obj, bool): + if obj: + return "" + else: + return "" + else: + return f"{obj}" + + environment.filters["plist_value"] = plist_value + + +class XMLExtension(Extension): + """Jinja2 extension for generating XML values.""" + + def __init__(self, environment): + """Initialize the extension with the given environment.""" + super().__init__(environment) + + def bool_attr(obj): + """Render value in XML format appropriate for an attribute.""" + return "true" if obj else "false" + + environment.filters["bool_attr"] = bool_attr diff --git a/src/briefcase/platforms/android/gradle.py b/src/briefcase/platforms/android/gradle.py index 3c0a38795..2cf904448 100644 --- a/src/briefcase/platforms/android/gradle.py +++ b/src/briefcase/platforms/android/gradle.py @@ -15,7 +15,7 @@ RunCommand, UpdateCommand, ) -from briefcase.config import BaseConfig, parsed_version +from briefcase.config import AppConfig, parsed_version from briefcase.exceptions import BriefcaseCommandError from briefcase.integrations.android_sdk import AndroidSDK from briefcase.integrations.subprocess import SubprocessArgT @@ -145,6 +145,7 @@ def verify_tools(self): class GradleCreateCommand(GradleMixin, CreateCommand): description = "Create and populate an Android Gradle project." + hidden_app_properties = {"permission", "feature"} def support_package_filename(self, support_revision): """The query arguments to use in a support package query request.""" @@ -152,7 +153,7 @@ def support_package_filename(self, support_revision): f"Python-{self.python_version_tag}-Android-support.b{support_revision}.zip" ) - def output_format_template_context(self, app: BaseConfig): + def output_format_template_context(self, app: AppConfig): """Additional template context required by the output format. :param app: The config object for the app @@ -181,6 +182,61 @@ def output_format_template_context(self, app: BaseConfig): ), } + def permissions_context(self, app: AppConfig, x_permissions: dict[str, str]): + """Additional template context for permissions. + + :param app: The config object for the app + :param x_permissions: The dictionary of known cross-platform permission + definitions. + :returns: The template context describing permissions for the app. + """ + # Default permissions for all Android apps + permissions = { + "android.permission.INTERNET": True, + "android.permission.ACCESS_NETWORK_STATE": True, + } + + # Default feature usage for all Android apps + features = {} + + if x_permissions["camera"]: + permissions["android.permission.CAMERA"] = True + features["android.hardware.camera"] = False + features["android.hardware.camera.any"] = False + features["android.hardware.camera.front"] = False + features["android.hardware.camera.external"] = False + features["android.hardware.camera.autofocus"] = False + + if x_permissions["microphone"]: + permissions["android.permission.RECORD_AUDIO"] = True + + if x_permissions["fine_location"]: + permissions["android.permission.ACCESS_FINE_LOCATION"] = True + features["android.hardware.location.network"] = False + features["android.hardware.location.gps"] = False + + if x_permissions["coarse_location"]: + permissions["android.permission.ACCESS_COARSE_LOCATION"] = True + features["android.hardware.location.network"] = False + features["android.hardware.location.gps"] = False + + if x_permissions["background_location"]: + permissions["android.permission.ACCESS_BACKGROUND_LOCATION"] = True + features["android.hardware.location.network"] = False + features["android.hardware.location.gps"] = False + + if x_permissions["photo_library"]: + permissions["android.permission.READ_MEDIA_VISUAL_USER_SELECTED"] = True + + # Override any permission and entitlement definitions with the platform specific definitions + permissions.update(app.permission) + features.update(getattr(app, "feature", {})) + + return { + "permissions": permissions, + "features": features, + } + class GradleUpdateCommand(GradleCreateCommand, UpdateCommand): description = "Update an existing Android Gradle project." @@ -193,10 +249,10 @@ class GradleOpenCommand(GradleMixin, OpenCommand): class GradleBuildCommand(GradleMixin, BuildCommand): description = "Build an Android debug APK." - def metadata_resource_path(self, app: BaseConfig): + def metadata_resource_path(self, app: AppConfig): return self.bundle_path(app) / self.path_index(app, "metadata_resource_path") - def update_app_metadata(self, app: BaseConfig, test_mode: bool): + def update_app_metadata(self, app: AppConfig, test_mode: bool): with self.input.wait_bar("Setting main module..."): with self.metadata_resource_path(app).open("w", encoding="utf-8") as f: # Set the name of the app's main module; this will depend @@ -209,7 +265,7 @@ def update_app_metadata(self, app: BaseConfig, test_mode: bool): """ ) - def build_app(self, app: BaseConfig, test_mode: bool, **kwargs): + def build_app(self, app: AppConfig, test_mode: bool, **kwargs): """Build an application. :param app: The application to build @@ -261,7 +317,7 @@ def add_options(self, parser): def run_app( self, - app: BaseConfig, + app: AppConfig, test_mode: bool, passthrough: list[str], device_or_avd=None, @@ -380,7 +436,7 @@ def run_app( class GradlePackageCommand(GradleMixin, PackageCommand): description = "Create an Android App Bundle and APK in release mode." - def package_app(self, app: BaseConfig, **kwargs): + def package_app(self, app: AppConfig, **kwargs): """Package the app for distribution. This involves building the release app bundle. diff --git a/src/briefcase/platforms/iOS/xcode.py b/src/briefcase/platforms/iOS/xcode.py index cb0a90cc1..ae88dc9f9 100644 --- a/src/briefcase/platforms/iOS/xcode.py +++ b/src/briefcase/platforms/iOS/xcode.py @@ -254,6 +254,48 @@ def select_target_device(self, udid_or_device=None): class iOSXcodeCreateCommand(iOSXcodePassiveMixin, CreateCommand): description = "Create and populate a iOS Xcode project." + def permissions_context(self, app: AppConfig, x_permissions: dict[str, str]): + """Additional template context for permissions. + + :param app: The config object for the app + :param x_permissions: The dictionary of known cross-platform permission + definitions. + :returns: The template context describing permissions for the app. + """ + # The collection of info.plist entries + info = {} + + if x_permissions["camera"]: + info["NSCameraUsageDescription"] = x_permissions["camera"] + if x_permissions["microphone"]: + info["NSMicrophoneUsageDescription"] = x_permissions["microphone"] + + if x_permissions["fine_location"]: + info["NSLocationDefaultAccuracyReduced"] = False + elif x_permissions["coarse_location"]: + info["NSLocationDefaultAccuracyReduced"] = True + + if x_permissions["background_location"]: + info["NSLocationAlwaysAndWhenInUseUsageDescription"] = x_permissions[ + "background_location" + ] + elif x_permissions["fine_location"]: + info["NSLocationWhenInUseUsageDescription"] = x_permissions["fine_location"] + elif x_permissions["coarse_location"]: + info["NSLocationWhenInUseUsageDescription"] = x_permissions[ + "coarse_location" + ] + + if x_permissions["photo_library"]: + info["NSPhotoLibraryAddUsageDescription"] = x_permissions["photo_library"] + + # Override any info.plist entries with the platform specific definitions + info.update(getattr(app, "info", {})) + + return { + "info": info, + } + def _extra_pip_args(self, app: AppConfig): """Any additional arguments that must be passed to pip when installing packages. diff --git a/src/briefcase/platforms/linux/flatpak.py b/src/briefcase/platforms/linux/flatpak.py index 9a124d5e6..94ccc27b3 100644 --- a/src/briefcase/platforms/linux/flatpak.py +++ b/src/briefcase/platforms/linux/flatpak.py @@ -1,4 +1,4 @@ -from typing import List +from __future__ import annotations from briefcase.commands import ( BuildCommand, @@ -102,6 +102,7 @@ def flatpak_sdk(self, app): class LinuxFlatpakCreateCommand(LinuxFlatpakMixin, CreateCommand): description = "Create and populate a Linux Flatpak." + hidden_app_properties = {"permission", "finish_arg"} def output_format_template_context(self, app: AppConfig): """Add flatpak runtime/SDK details to the app template.""" @@ -111,6 +112,42 @@ def output_format_template_context(self, app: AppConfig): "flatpak_sdk": self.flatpak_sdk(app), } + def permissions_context(self, app: AppConfig, x_permissions: dict[str, str]): + """Additional template context for permissions. + + :param app: The config object for the app + :param x_permissions: The dictionary of known cross-platform permission + definitions. + :returns: The template context describing permissions for the app. + """ + # The default finish arguments that Briefcase adds on every Flatpak. + finish_args = { + # X11 + XShm access + "share=ipc": True, + "socket=x11": True, + # Disable Wayland access + "nosocket=wayland": True, + # Network access + "share=network": True, + # GPU access + "device=dri": True, + # Sound access + "socket=pulseaudio": True, + # Host filesystem access + "filesystem=xdg-cache": True, + "filesystem=xdg-config": True, + "filesystem=xdg-data": True, + "filesystem=xdg-documents": True, + # DBus access + "socket=session-bus": True, + } + + finish_args.update(getattr(app, "finish_arg", {})) + + return { + "finish_args": finish_args, + } + class LinuxFlatpakUpdateCommand(LinuxFlatpakCreateCommand, UpdateCommand): description = "Update an existing Linux Flatpak." @@ -166,7 +203,7 @@ def run_app( self, app: AppConfig, test_mode: bool, - passthrough: List[str], + passthrough: list[str], **kwargs, ): """Start the application. diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py index fc468bed2..3bdd19c09 100644 --- a/src/briefcase/platforms/macOS/__init__.py +++ b/src/briefcase/platforms/macOS/__init__.py @@ -39,7 +39,9 @@ class macOSMixin: supported_host_os_reason = "macOS applications can only be built on macOS." -class macOSInstallMixin(AppPackagesMergeMixin): +class macOSCreateMixin(AppPackagesMergeMixin): + hidden_app_properties = {"permission", "entitlement"} + def _install_app_requirements( self, app: AppConfig, @@ -147,6 +149,50 @@ def _install_app_requirements( # libraries down to just the host architecture. self.thin_app_packages(app_packages_path, arch=self.tools.host_arch) + def permissions_context(self, app: AppConfig, cross_platform: dict[str, str]): + """Additional template context for permissions. + + :param app: The config object for the app + :param cross_platform: The dictionary of known cross-platform permission + definitions. + :returns: The template context describing permissions for the app. + """ + # The info.plist entries for the app + info = {} + + # Default entitlements for all macOS apps + entitlements = { + "com.apple.security.cs.allow-unsigned-executable-memory": True, + "com.apple.security.cs.disable-library-validation": True, + } + + if cross_platform["camera"]: + entitlements["com.apple.security.device.camera"] = True + if cross_platform["microphone"]: + entitlements["com.apple.security.device.microphone"] = True + + if cross_platform["background_location"]: + info["NSLocationUsageDescription"] = cross_platform["background_location"] + entitlements["com.apple.security.personal-information.location"] = True + elif cross_platform["fine_location"]: + info["NSLocationUsageDescription"] = cross_platform["fine_location"] + entitlements["com.apple.security.personal-information.location"] = True + elif cross_platform["coarse_location"]: + info["NSLocationUsageDescription"] = cross_platform["coarse_location"] + entitlements["com.apple.security.personal-information.location"] = True + + if cross_platform["photo_library"]: + entitlements["com.apple.security.personal-information.photo_library"] = True + + # Override any info and entitlement definitions with the platform specific definitions + info.update(getattr(app, "info", {})) + entitlements.update(getattr(app, "entitlement", {})) + + return { + "info": info, + "entitlements": entitlements, + } + class macOSRunMixin: def run_app( diff --git a/src/briefcase/platforms/macOS/app.py b/src/briefcase/platforms/macOS/app.py index 61e5932b7..34c5b504f 100644 --- a/src/briefcase/platforms/macOS/app.py +++ b/src/briefcase/platforms/macOS/app.py @@ -13,7 +13,7 @@ ) from briefcase.config import AppConfig from briefcase.platforms.macOS import ( - macOSInstallMixin, + macOSCreateMixin, macOSMixin, macOSPackageMixin, macOSRunMixin, @@ -31,7 +31,7 @@ def binary_path(self, app): return self.bundle_path(app) / f"{app.formal_name}.app" -class macOSAppCreateCommand(macOSAppMixin, macOSInstallMixin, CreateCommand): +class macOSAppCreateCommand(macOSAppMixin, macOSCreateMixin, CreateCommand): description = "Create and populate a macOS app." def support_path(self, app: AppConfig, runtime=False) -> Path: diff --git a/src/briefcase/platforms/macOS/filters.py b/src/briefcase/platforms/macOS/filters.py index eefd1dc63..fe51bcec4 100644 --- a/src/briefcase/platforms/macOS/filters.py +++ b/src/briefcase/platforms/macOS/filters.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import re MACOS_LOG_PREFIX_REGEX = re.compile( diff --git a/src/briefcase/platforms/macOS/xcode.py b/src/briefcase/platforms/macOS/xcode.py index 31f77d4ab..cd8a0e648 100644 --- a/src/briefcase/platforms/macOS/xcode.py +++ b/src/briefcase/platforms/macOS/xcode.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import subprocess from briefcase.commands import ( @@ -13,7 +15,7 @@ from briefcase.exceptions import BriefcaseCommandError from briefcase.integrations.xcode import Xcode from briefcase.platforms.macOS import ( - macOSInstallMixin, + macOSCreateMixin, macOSMixin, macOSPackageMixin, macOSRunMixin, @@ -42,7 +44,7 @@ def binary_path(self, app): return self.bundle_path(app) / "build/Release" / f"{app.formal_name}.app" -class macOSXcodeCreateCommand(macOSXcodeMixin, macOSInstallMixin, CreateCommand): +class macOSXcodeCreateCommand(macOSXcodeMixin, macOSCreateMixin, CreateCommand): description = "Create and populate a macOS Xcode project." diff --git a/tests/commands/create/conftest.py b/tests/commands/create/conftest.py index 87f432402..50f067f18 100644 --- a/tests/commands/create/conftest.py +++ b/tests/commands/create/conftest.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from unittest import mock import pytest @@ -46,6 +48,7 @@ class DummyCreateCommand(CreateCommand): platform = "Tester" output_format = "Dummy" description = "Dummy create command" + hidden_app_properties = {"permission", "request"} def __init__(self, *args, support_file=None, git=None, home_path=None, **kwargs): kwargs.setdefault("logger", Log()) @@ -89,6 +92,31 @@ def python_version_tag(self): def output_format_template_context(self, app): return {"output_format": "dummy"} + # Handle platform-specific permissions. + # Convert all the cross-platform permissions to upper case, prefixing DUMMY_. + # Add a "good lighting" request if the camera permission has been requested. + def permissions_context(self, app: AppConfig, x_permissions: dict[str, str]): + # We don't actually need anything from the superclass; but call it to ensure + # coverage. + context = super().permissions_context(app, x_permissions) + if context: + # Make sure the base class *isn't* doing anything. + return context + + permissions = { + f"DUMMY_{key.upper()}": value.upper() + for key, value in x_permissions.items() + if value + } + context["permissions"] = permissions + context["custom_permissions"] = app.permission + + requests = {"good.lighting": True} if x_permissions["camera"] else {} + requests.update(getattr(app, "request", {})) + context["requests"] = requests + + return context + class TrackingCreateCommand(DummyCreateCommand): """A dummy creation command that doesn't actually do anything. diff --git a/tests/commands/create/test_generate_app_template.py b/tests/commands/create/test_generate_app_template.py index 4800600f6..515ee4dbc 100644 --- a/tests/commands/create/test_generate_app_template.py +++ b/tests/commands/create/test_generate_app_template.py @@ -38,6 +38,9 @@ def full_context(): "icon": None, "splash": None, "supported": True, + "permissions": {}, + "custom_permissions": {}, + "requests": {}, "document_types": {}, # Properties of the generating environment "python_version": platform.python_version(), @@ -624,3 +627,71 @@ def test_cached_missing_branch_template(monkeypatch, create_command, myapp): # Generating the template under there conditions raises an error with pytest.raises(TemplateUnsupportedVersion): create_command.generate_app_template(myapp) + + +def test_x_permissions( + monkeypatch, + create_command, + myapp, + full_context, + tmp_path, +): + # Set the Briefcase version + monkeypatch.setattr(briefcase, "__version__", "37.42.7") + full_context["briefcase_version"] = "37.42.7" + + # Define some permissions and requests. The original "permission" and "request" + # definitions will be hidden from the final template context. + + myapp.permission = { + # Cross-platform permissions + "camera": "I need to see you", + "microphone": "I need to hear you", + "coarse_location": "I need to know approximately where you are", + "fine_location": "I need to know exactly where you are", + "background_location": "I need to know where you are constantly", + "photo_library": "I need to see your photos", + # Custom permissions + "DUMMY_sit": "I can't sit without an invitation", + "DUMMY.leave.the.dinner.table": "It would be impolite.", + } + myapp.request = {"tasty.beverage": True} + + # In the final context, all cross-platform permissions have been converted to upper + # case, prefixed with "DUMMY", and moved to the `permissions` key. Custom + # permissions have been moved to the "custom_permissions" key + full_context["permissions"] = { + "DUMMY_CAMERA": "I NEED TO SEE YOU", + "DUMMY_MICROPHONE": "I NEED TO HEAR YOU", + "DUMMY_COARSE_LOCATION": "I NEED TO KNOW APPROXIMATELY WHERE YOU ARE", + "DUMMY_FINE_LOCATION": "I NEED TO KNOW EXACTLY WHERE YOU ARE", + "DUMMY_BACKGROUND_LOCATION": "I NEED TO KNOW WHERE YOU ARE CONSTANTLY", + "DUMMY_PHOTO_LIBRARY": "I NEED TO SEE YOUR PHOTOS", + } + full_context["custom_permissions"] = { + "DUMMY_sit": "I can't sit without an invitation", + "DUMMY.leave.the.dinner.table": "It would be impolite.", + } + + # An extra request has been added because of the camera permission, and the + # custom request has been preserved. + full_context["requests"] = { + "good.lighting": True, + "tasty.beverage": True, + } + + # There won't be a cookiecutter cache, so there won't be + # a cache path (yet). + create_command.tools.git.Repo.side_effect = git_exceptions.NoSuchPathError + + # Generate the template. + create_command.generate_app_template(myapp) + + # Cookiecutter was invoked with the expected template name and context. + create_command.tools.cookiecutter.assert_called_once_with( + "https://github.com/beeware/briefcase-Tester-Dummy-template.git", + no_input=True, + checkout="v37.42.7", + output_dir=os.fsdecode(tmp_path / "base_path/build/my-app/tester"), + extra_context=full_context, + ) diff --git a/tests/config/test_merge_config.py b/tests/config/test_merge_config.py index 78f6b442b..ab3cf13e8 100644 --- a/tests/config/test_merge_config.py +++ b/tests/config/test_merge_config.py @@ -14,6 +14,7 @@ def test_merge_no_data(): """If there are no new options, nothing changes.""" config = { "requires": ["first", "second"], + "permission": {"up": True, "down": False}, "other": 1234, } @@ -21,6 +22,7 @@ def test_merge_no_data(): assert config == { "requires": ["first", "second"], + "permission": {"up": True, "down": False}, "other": 1234, } @@ -29,22 +31,66 @@ def test_merge_no_option(): """If there are no existing options, the new option become the entire value.""" config = {"other": 1234} - merge_config(config, {"requires": ["third", "fourth"]}) + merge_config( + config, + { + "requires": ["third", "fourth"], + "permission": {"left": True, "right": False}, + }, + ) assert config == { "requires": ["third", "fourth"], + "permission": {"left": True, "right": False}, "other": 1234, } def test_merge(): """If there are existing options and new options, merge.""" - config = {"requires": ["first", "second"], "other": 1234} + config = { + "requires": ["first", "second"], + "permission": {"up": True, "down": False}, + "other": 1234, + } - merge_config(config, {"requires": ["third", "fourth"], "other": 5678}) + merge_config( + config, + { + "requires": ["third", "fourth"], + "permission": {"left": True, "right": False}, + "other": 5678, + }, + ) assert config == { "requires": ["first", "second", "third", "fourth"], + "permission": {"up": True, "down": False, "left": True, "right": False}, + "other": 5678, + } + + +def test_merge_collision(): + """If there are repeated options, lists are appended, dictionaries are updated and + new options, merge.""" + config = { + "requires": ["first", "second"], + "permission": {"up": True, "down": False}, + "other": 1234, + } + + merge_config( + config, + { + "requires": ["second", "fourth"], + "permission": {"down": True, "right": False}, + "other": 5678, + }, + ) + + assert config == { + "requires": ["first", "second", "second", "fourth"], + "permission": {"up": True, "down": True, "right": False}, "other": 5678, } @@ -53,6 +99,7 @@ def test_convert_base_definition(): """The merge operation succeeds when called on itself.""" config = { "requires": ["first", "second"], + "permission": {"up": True, "down": False}, "other": 1234, } @@ -60,6 +107,7 @@ def test_convert_base_definition(): assert config == { "requires": ["first", "second"], + "permission": {"up": True, "down": False}, "other": 1234, } @@ -69,6 +117,7 @@ def test_merged_keys(): config = { "requires": ["first", "second"], "sources": ["a", "b"], + "permission": {"up": True, "down": False}, "non-merge": ["1", "2"], "other": 1234, } @@ -77,6 +126,7 @@ def test_merged_keys(): config, { "requires": ["third", "fourth"], + "permission": {"left": True, "right": False}, "sources": ["c", "d"], "non-merge": ["3", "4"], }, @@ -85,6 +135,7 @@ def test_merged_keys(): assert config == { "requires": ["first", "second", "third", "fourth"], "sources": ["a", "b", "c", "d"], + "permission": {"up": True, "down": False, "left": True, "right": False}, "non-merge": ["3", "4"], "other": 1234, } diff --git a/tests/integrations/cookiecutter/test_PListExtension.py b/tests/integrations/cookiecutter/test_PListExtension.py new file mode 100644 index 000000000..825bf4508 --- /dev/null +++ b/tests/integrations/cookiecutter/test_PListExtension.py @@ -0,0 +1,20 @@ +from unittest.mock import MagicMock + +import pytest + +from briefcase.integrations.cookiecutter import PListExtension + + +@pytest.mark.parametrize( + "value, expected", + [ + (True, ""), + (False, ""), + ("Hello world", "Hello world"), + ], +) +def test_plist_value(value, expected): + env = MagicMock() + env.filters = {} + PListExtension(env) + assert env.filters["plist_value"](value) == expected diff --git a/tests/integrations/cookiecutter/test_XMLExtension.py b/tests/integrations/cookiecutter/test_XMLExtension.py new file mode 100644 index 000000000..54b256a44 --- /dev/null +++ b/tests/integrations/cookiecutter/test_XMLExtension.py @@ -0,0 +1,26 @@ +from unittest.mock import MagicMock + +import pytest + +from briefcase.integrations.cookiecutter import XMLExtension + + +@pytest.mark.parametrize( + "value, expected", + [ + # Literal booleans + (True, "true"), + (False, "false"), + # True-ish values + (1, "true"), + ("Hello", "true"), + # False-ish values + (0, "false"), + ("", "false"), + ], +) +def test_bool_attr(value, expected): + env = MagicMock() + env.filters = {} + XMLExtension(env) + assert env.filters["bool_attr"](value) == expected diff --git a/tests/platforms/android/gradle/test_create.py b/tests/platforms/android/gradle/test_create.py index 2a5927e73..7441fecc9 100644 --- a/tests/platforms/android/gradle/test_create.py +++ b/tests/platforms/android/gradle/test_create.py @@ -145,3 +145,265 @@ def test_extract_packages(create_command, first_app_config, test_sources, expect first_app_config.test_sources = test_sources context = create_command.output_format_template_context(first_app_config) assert context["extract_packages"] == expected + + +@pytest.mark.parametrize( + "permissions, features, context", + [ + # No permissions + ( + {}, + {}, + { + "permissions": { + "android.permission.ACCESS_NETWORK_STATE": True, + "android.permission.INTERNET": True, + }, + "features": {}, + }, + ), + # Only custom permissions + ( + { + "android.permission.READ_CONTACTS": True, + }, + { + "android.hardware.bluetooth": True, + }, + { + "permissions": { + "android.permission.ACCESS_NETWORK_STATE": True, + "android.permission.INTERNET": True, + "android.permission.READ_CONTACTS": True, + }, + "features": { + "android.hardware.bluetooth": True, + }, + }, + ), + # Camera permissions + ( + { + "camera": "I need to see you", + }, + {}, + { + "permissions": { + "android.permission.ACCESS_NETWORK_STATE": True, + "android.permission.INTERNET": True, + "android.permission.CAMERA": True, + }, + "features": { + "android.hardware.camera": False, + "android.hardware.camera.any": False, + "android.hardware.camera.autofocus": False, + "android.hardware.camera.external": False, + "android.hardware.camera.front": False, + }, + }, + ), + # Microphone permissions + ( + { + "microphone": "I need to hear you", + }, + {}, + { + "permissions": { + "android.permission.ACCESS_NETWORK_STATE": True, + "android.permission.INTERNET": True, + "android.permission.RECORD_AUDIO": True, + }, + "features": {}, + }, + ), + # Coarse location permissions + ( + { + "coarse_location": "I need to know roughly where you are", + }, + {}, + { + "permissions": { + "android.permission.ACCESS_NETWORK_STATE": True, + "android.permission.INTERNET": True, + "android.permission.ACCESS_COARSE_LOCATION": True, + }, + "features": { + "android.hardware.location.gps": False, + "android.hardware.location.network": False, + }, + }, + ), + # Fine location permissions + ( + { + "fine_location": "I need to know exactly where you are", + }, + {}, + { + "permissions": { + "android.permission.ACCESS_NETWORK_STATE": True, + "android.permission.INTERNET": True, + "android.permission.ACCESS_FINE_LOCATION": True, + }, + "features": { + "android.hardware.location.gps": False, + "android.hardware.location.network": False, + }, + }, + ), + # Background location permissions + ( + { + "background_location": "I always need to know where you are", + }, + {}, + { + "permissions": { + "android.permission.ACCESS_NETWORK_STATE": True, + "android.permission.INTERNET": True, + "android.permission.ACCESS_BACKGROUND_LOCATION": True, + }, + "features": { + "android.hardware.location.gps": False, + "android.hardware.location.network": False, + }, + }, + ), + # Coarse location background permissions + ( + { + "coarse_location": "I need to know roughly where you are", + "background_location": "I always need to know where you are", + }, + {}, + { + "permissions": { + "android.permission.ACCESS_NETWORK_STATE": True, + "android.permission.INTERNET": True, + "android.permission.ACCESS_COARSE_LOCATION": True, + "android.permission.ACCESS_BACKGROUND_LOCATION": True, + }, + "features": { + "android.hardware.location.gps": False, + "android.hardware.location.network": False, + }, + }, + ), + # Fine location background permissions + ( + { + "fine_location": "I need to know exactly where you are", + "background_location": "I always need to know where you are", + }, + {}, + { + "permissions": { + "android.permission.ACCESS_NETWORK_STATE": True, + "android.permission.INTERNET": True, + "android.permission.ACCESS_FINE_LOCATION": True, + "android.permission.ACCESS_BACKGROUND_LOCATION": True, + }, + "features": { + "android.hardware.location.gps": False, + "android.hardware.location.network": False, + }, + }, + ), + # Coarse and fine location permissions + ( + { + "coarse_location": "I need to know roughly where you are", + "fine_location": "I need to know exactly where you are", + }, + {}, + { + "permissions": { + "android.permission.ACCESS_NETWORK_STATE": True, + "android.permission.INTERNET": True, + "android.permission.ACCESS_COARSE_LOCATION": True, + "android.permission.ACCESS_FINE_LOCATION": True, + }, + "features": { + "android.hardware.location.gps": False, + "android.hardware.location.network": False, + }, + }, + ), + # Coarse and fine background location permissions + ( + { + "coarse_location": "I need to know roughly where you are", + "fine_location": "I need to know exactly where you are", + "background_location": "I always need to know where you are", + }, + {}, + { + "permissions": { + "android.permission.ACCESS_NETWORK_STATE": True, + "android.permission.INTERNET": True, + "android.permission.ACCESS_COARSE_LOCATION": True, + "android.permission.ACCESS_FINE_LOCATION": True, + "android.permission.ACCESS_BACKGROUND_LOCATION": True, + }, + "features": { + "android.hardware.location.gps": False, + "android.hardware.location.network": False, + }, + }, + ), + # Photo library permissions + ( + { + "photo_library": "I need to see your library", + }, + {}, + { + "permissions": { + "android.permission.ACCESS_NETWORK_STATE": True, + "android.permission.INTERNET": True, + "android.permission.READ_MEDIA_VISUAL_USER_SELECTED": True, + }, + "features": {}, + }, + ), + # Override and augment by cross-platform definitions + ( + { + "camera": "I need to see you", + "android.permission.CAMERA": False, + "android.permission.READ_CONTACTS": True, + }, + { + "android.hardware.camera.external": True, + "android.hardware.bluetooth": True, + }, + { + "permissions": { + "android.permission.ACCESS_NETWORK_STATE": True, + "android.permission.INTERNET": True, + "android.permission.CAMERA": False, + "android.permission.READ_CONTACTS": True, + }, + "features": { + "android.hardware.camera": False, + "android.hardware.camera.any": False, + "android.hardware.camera.autofocus": False, + "android.hardware.camera.external": True, + "android.hardware.camera.front": False, + "android.hardware.bluetooth": True, + }, + }, + ), + ], +) +def test_permissions_context(create_command, first_app, permissions, features, context): + """Platform-specific permissions can be added to the context.""" + # Set the permission and entitlement value + first_app.permission = permissions + first_app.feature = features + # Extract the cross-platform permissions + x_permissions = create_command._x_permissions(first_app) + # Check that the final platform permissions are rendered as expected. + assert context == create_command.permissions_context(first_app, x_permissions) diff --git a/tests/platforms/iOS/xcode/test_create.py b/tests/platforms/iOS/xcode/test_create.py index 7572530f6..8b7d37b4e 100644 --- a/tests/platforms/iOS/xcode/test_create.py +++ b/tests/platforms/iOS/xcode/test_create.py @@ -120,3 +120,186 @@ def test_extra_pip_args(create_command, first_app_generated, tmp_path): }, ), ] + + +@pytest.mark.parametrize( + "permissions, info, context", + [ + # No permissions + ( + {}, + {}, + {"info": {}}, + ), + # Only custom permissions + ( + {}, + { + "NSCameraUsageDescription": "I need to see you", + "NSMicrophoneUsageDescription": "I need to hear you", + }, + { + "info": { + "NSCameraUsageDescription": "I need to see you", + "NSMicrophoneUsageDescription": "I need to hear you", + } + }, + ), + # Camera permissions + ( + { + "camera": "I need to see you", + }, + {}, + { + "info": { + "NSCameraUsageDescription": "I need to see you", + }, + }, + ), + # Microphone permissions + ( + { + "microphone": "I need to hear you", + }, + {}, + { + "info": { + "NSMicrophoneUsageDescription": "I need to hear you", + }, + }, + ), + # Coarse location permissions + ( + { + "coarse_location": "I need to know roughly where you are", + }, + {}, + { + "info": { + "NSLocationDefaultAccuracyReduced": True, + "NSLocationWhenInUseUsageDescription": "I need to know roughly where you are", + } + }, + ), + # Fine location permissions + ( + { + "fine_location": "I need to know exactly where you are", + }, + {}, + { + "info": { + "NSLocationDefaultAccuracyReduced": False, + "NSLocationWhenInUseUsageDescription": "I need to know exactly where you are", + } + }, + ), + # Background location permissions + ( + { + "background_location": "I always need to know where you are", + }, + {}, + { + "info": { + "NSLocationAlwaysAndWhenInUseUsageDescription": "I always need to know where you are", + } + }, + ), + # Coarse location background permissions + ( + { + "coarse_location": "I need to know roughly where you are", + "background_location": "I always need to know where you are", + }, + {}, + { + "info": { + "NSLocationDefaultAccuracyReduced": True, + "NSLocationAlwaysAndWhenInUseUsageDescription": "I always need to know where you are", + } + }, + ), + # Fine location background permissions + ( + { + "fine_location": "I need to know exactly where you are", + "background_location": "I always need to know where you are", + }, + {}, + { + "info": { + "NSLocationDefaultAccuracyReduced": False, + "NSLocationAlwaysAndWhenInUseUsageDescription": "I always need to know where you are", + } + }, + ), + # Coarse and fine location permissions + ( + { + "coarse_location": "I need to know roughly where you are", + "fine_location": "I need to know exactly where you are", + }, + {}, + { + "info": { + "NSLocationDefaultAccuracyReduced": False, + "NSLocationWhenInUseUsageDescription": "I need to know exactly where you are", + } + }, + ), + # Coarse and fine background location permissions + ( + { + "coarse_location": "I need to know roughly where you are", + "fine_location": "I need to know exactly where you are", + "background_location": "I always need to know where you are", + }, + {}, + { + "info": { + "NSLocationDefaultAccuracyReduced": False, + "NSLocationAlwaysAndWhenInUseUsageDescription": "I always need to know where you are", + } + }, + ), + # Photo library permissions + ( + { + "photo_library": "I need to see your library", + }, + {}, + { + "info": { + "NSPhotoLibraryAddUsageDescription": "I need to see your library" + } + }, + ), + # Override and augment by cross-platform definitions + ( + { + "camera": "I need to see you", + }, + { + "NSCameraUsageDescription": "Platform specific", + "NSCustomPermission": "Custom message", + }, + { + "info": { + "NSCameraUsageDescription": "Platform specific", + "NSCustomPermission": "Custom message", + } + }, + ), + ], +) +def test_permissions_context(create_command, first_app, permissions, info, context): + """Platform-specific permissions can be added to the context.""" + # Set the permissions value + first_app.permission = permissions + first_app.info = info + # Extract the cross-platform permissions + x_permissions = create_command._x_permissions(first_app) + # Check that the final platform permissions are rendered as expected. + assert context == create_command.permissions_context(first_app, x_permissions) diff --git a/tests/platforms/linux/flatpak/test_create.py b/tests/platforms/linux/flatpak/test_create.py index 0f977a8d3..b2d748df8 100644 --- a/tests/platforms/linux/flatpak/test_create.py +++ b/tests/platforms/linux/flatpak/test_create.py @@ -43,6 +43,204 @@ def test_output_format_template_context(create_command, first_app_config): } +DEFAULT_FINISH_ARGS = { + "share=ipc": True, + "socket=x11": True, + "nosocket=wayland": True, + "share=network": True, + "device=dri": True, + "socket=pulseaudio": True, + "filesystem=xdg-cache": True, + "filesystem=xdg-config": True, + "filesystem=xdg-data": True, + "filesystem=xdg-documents": True, + "socket=session-bus": True, +} + + +@pytest.mark.parametrize( + "permissions, finish_args, context", + [ + # No permissions + ( + {}, + {}, + { + "finish_args": DEFAULT_FINISH_ARGS, + }, + ), + # Only custom permissions + ( + { + "custom_permission": "Custom message", + }, + { + "allow=bluetooth": True, + }, + { + "finish_args": { + "share=ipc": True, + "socket=x11": True, + "nosocket=wayland": True, + "share=network": True, + "device=dri": True, + "socket=pulseaudio": True, + "filesystem=xdg-cache": True, + "filesystem=xdg-config": True, + "filesystem=xdg-data": True, + "filesystem=xdg-documents": True, + "socket=session-bus": True, + "allow=bluetooth": True, + }, + }, + ), + # Camera permissions + ( + { + "camera": "I need to see you", + }, + {}, + { + "finish_args": DEFAULT_FINISH_ARGS, + }, + ), + # Microphone permissions + ( + { + "microphone": "I need to hear you", + }, + {}, + { + "finish_args": DEFAULT_FINISH_ARGS, + }, + ), + # Coarse location permissions + ( + { + "coarse_location": "I need to know roughly where you are", + }, + {}, + { + "finish_args": DEFAULT_FINISH_ARGS, + }, + ), + # Fine location permissions + ( + { + "fine_location": "I need to know exactly where you are", + }, + {}, + { + "finish_args": DEFAULT_FINISH_ARGS, + }, + ), + # Background location permissions + ( + { + "background_location": "I always need to know where you are", + }, + {}, + { + "finish_args": DEFAULT_FINISH_ARGS, + }, + ), + # Coarse location background permissions + ( + { + "coarse_location": "I need to know roughly where you are", + "background_location": "I always need to know where you are", + }, + {}, + { + "finish_args": DEFAULT_FINISH_ARGS, + }, + ), + # Fine location background permissions + ( + { + "fine_location": "I need to know exactly where you are", + "background_location": "I always need to know where you are", + }, + {}, + { + "finish_args": DEFAULT_FINISH_ARGS, + }, + ), + # Coarse and fine location permissions + ( + { + "coarse_location": "I need to know roughly where you are", + "fine_location": "I need to know exactly where you are", + }, + {}, + { + "finish_args": DEFAULT_FINISH_ARGS, + }, + ), + # Coarse and fine background location permissions + ( + { + "coarse_location": "I need to know roughly where you are", + "fine_location": "I need to know exactly where you are", + "background_location": "I always need to know where you are", + }, + {}, + { + "finish_args": DEFAULT_FINISH_ARGS, + }, + ), + # Photo library permissions + ( + { + "photo_library": "I need to see your library", + }, + {}, + { + "finish_args": DEFAULT_FINISH_ARGS, + }, + ), + # Override and augment by cross-platform definitions + ( + { + "fine_location": "I need to know where you are", + "NSCustomMessage": "Custom message", + }, + { + "socket=pulseaudio": False, + "allow=bluetooth": True, + }, + { + "finish_args": { + "share=ipc": True, + "socket=x11": True, + "nosocket=wayland": True, + "share=network": True, + "device=dri": True, + "socket=pulseaudio": False, + "filesystem=xdg-cache": True, + "filesystem=xdg-config": True, + "filesystem=xdg-data": True, + "filesystem=xdg-documents": True, + "socket=session-bus": True, + "allow=bluetooth": True, + }, + }, + ), + ], +) +def test_permissions_context( + create_command, first_app, permissions, finish_args, context +): + """Platform-specific permissions can be added to the context.""" + # Set the permission and entitlement value + first_app.permission = permissions + first_app.finish_arg = finish_args + # Extract the cross-platform permissions + x_permissions = create_command._x_permissions(first_app) + # Check that the final platform permissions are rendered as expected. + assert context == create_command.permissions_context(first_app, x_permissions) + + def test_missing_runtime_config(create_command, first_app_config): """The app creation errors is a Flatpak runtime is not defined.""" create_command.tools.flatpak = MagicMock(spec_set=Flatpak) diff --git a/tests/platforms/macOS/app/test_create.py b/tests/platforms/macOS/app/test_create.py index dc5e594d2..b7e286741 100644 --- a/tests/platforms/macOS/app/test_create.py +++ b/tests/platforms/macOS/app/test_create.py @@ -27,6 +27,264 @@ def create_command(tmp_path, first_app_templated): return command +@pytest.mark.parametrize( + "permissions, info, entitlements, context", + [ + # No permissions + ( + {}, + {}, + {}, + { + "info": {}, + "entitlements": { + "com.apple.security.cs.allow-unsigned-executable-memory": True, + "com.apple.security.cs.disable-library-validation": True, + }, + }, + ), + # Only custom permissions + ( + {}, + { + "NSCustomPermission": "Custom message", + }, + { + "com.apple.vm.networking": True, + }, + { + "info": { + "NSCustomPermission": "Custom message", + }, + "entitlements": { + "com.apple.security.cs.allow-unsigned-executable-memory": True, + "com.apple.security.cs.disable-library-validation": True, + "com.apple.vm.networking": True, + }, + }, + ), + # Camera permissions + ( + { + "camera": "I need to see you", + }, + {}, + {}, + { + "info": {}, + "entitlements": { + "com.apple.security.cs.allow-unsigned-executable-memory": True, + "com.apple.security.cs.disable-library-validation": True, + "com.apple.security.device.camera": True, + }, + }, + ), + # Microphone permissions + ( + { + "microphone": "I need to hear you", + }, + {}, + {}, + { + "info": {}, + "entitlements": { + "com.apple.security.cs.allow-unsigned-executable-memory": True, + "com.apple.security.cs.disable-library-validation": True, + "com.apple.security.device.microphone": True, + }, + }, + ), + # Coarse location permissions + ( + { + "coarse_location": "I need to know roughly where you are", + }, + {}, + {}, + { + "info": { + "NSLocationUsageDescription": "I need to know roughly where you are", + }, + "entitlements": { + "com.apple.security.cs.allow-unsigned-executable-memory": True, + "com.apple.security.cs.disable-library-validation": True, + "com.apple.security.personal-information.location": True, + }, + }, + ), + # Fine location permissions + ( + { + "fine_location": "I need to know exactly where you are", + }, + {}, + {}, + { + "info": { + "NSLocationUsageDescription": "I need to know exactly where you are", + }, + "entitlements": { + "com.apple.security.cs.allow-unsigned-executable-memory": True, + "com.apple.security.cs.disable-library-validation": True, + "com.apple.security.personal-information.location": True, + }, + }, + ), + # Background location permissions + ( + { + "background_location": "I always need to know where you are", + }, + {}, + {}, + { + "info": { + "NSLocationUsageDescription": "I always need to know where you are", + }, + "entitlements": { + "com.apple.security.cs.allow-unsigned-executable-memory": True, + "com.apple.security.cs.disable-library-validation": True, + "com.apple.security.personal-information.location": True, + }, + }, + ), + # Coarse location background permissions + ( + { + "coarse_location": "I need to know roughly where you are", + "background_location": "I always need to know where you are", + }, + {}, + {}, + { + "info": { + "NSLocationUsageDescription": "I always need to know where you are", + }, + "entitlements": { + "com.apple.security.cs.allow-unsigned-executable-memory": True, + "com.apple.security.cs.disable-library-validation": True, + "com.apple.security.personal-information.location": True, + }, + }, + ), + # Fine location background permissions + ( + { + "fine_location": "I need to know exactly where you are", + "background_location": "I always need to know where you are", + }, + {}, + {}, + { + "info": { + "NSLocationUsageDescription": "I always need to know where you are", + }, + "entitlements": { + "com.apple.security.cs.allow-unsigned-executable-memory": True, + "com.apple.security.cs.disable-library-validation": True, + "com.apple.security.personal-information.location": True, + }, + }, + ), + # Coarse and fine location permissions + ( + { + "coarse_location": "I need to know roughly where you are", + "fine_location": "I need to know exactly where you are", + }, + {}, + {}, + { + "info": { + "NSLocationUsageDescription": "I need to know exactly where you are", + }, + "entitlements": { + "com.apple.security.cs.allow-unsigned-executable-memory": True, + "com.apple.security.cs.disable-library-validation": True, + "com.apple.security.personal-information.location": True, + }, + }, + ), + # Coarse and fine background location permissions + ( + { + "coarse_location": "I need to know roughly where you are", + "fine_location": "I need to know exactly where you are", + "background_location": "I always need to know where you are", + }, + {}, + {}, + { + "info": { + "NSLocationUsageDescription": "I always need to know where you are", + }, + "entitlements": { + "com.apple.security.cs.allow-unsigned-executable-memory": True, + "com.apple.security.cs.disable-library-validation": True, + "com.apple.security.personal-information.location": True, + }, + }, + ), + # Photo library permissions + ( + { + "photo_library": "I need to see your library", + }, + {}, + {}, + { + "info": {}, + "entitlements": { + "com.apple.security.cs.allow-unsigned-executable-memory": True, + "com.apple.security.cs.disable-library-validation": True, + "com.apple.security.personal-information.photo_library": True, + }, + }, + ), + # Override and augment by cross-platform definitions + ( + { + "fine_location": "I need to know where you are", + }, + { + "NSCustomMessage": "Custom message", + "NSLocationUsageDescription": "Platform specific", + }, + { + "com.apple.security.personal-information.location": False, + "com.apple.security.cs.disable-library-validation": False, + "com.apple.vm.networking": True, + }, + { + "info": { + "NSLocationUsageDescription": "Platform specific", + "NSCustomMessage": "Custom message", + }, + "entitlements": { + "com.apple.security.cs.allow-unsigned-executable-memory": True, + "com.apple.security.cs.disable-library-validation": False, + "com.apple.security.personal-information.location": False, + "com.apple.vm.networking": True, + }, + }, + ), + ], +) +def test_permissions_context( + create_command, first_app, permissions, info, entitlements, context +): + """Platform-specific permissions can be added to the context.""" + # Set the permission, info and entitlement values + first_app.permission = permissions + first_app.info = info + first_app.entitlement = entitlements + # Extract the cross-platform permissions + x_permissions = create_command._x_permissions(first_app) + # Check that the final platform permissions are rendered as expected. + assert context == create_command.permissions_context(first_app, x_permissions) + + @pytest.mark.parametrize( "host_arch, other_arch", [ diff --git a/tests/platforms/macOS/conftest.py b/tests/platforms/macOS/conftest.py index 00c7932a6..0c58839a0 100644 --- a/tests/platforms/macOS/conftest.py +++ b/tests/platforms/macOS/conftest.py @@ -5,11 +5,11 @@ from briefcase.commands.base import BaseCommand from briefcase.console import Console, Log from briefcase.integrations.subprocess import Subprocess -from briefcase.platforms.macOS.app import macOSAppMixin, macOSInstallMixin +from briefcase.platforms.macOS.app import macOSAppMixin, macOSCreateMixin from tests.utils import DummyConsole -class DummyInstallCommand(macOSAppMixin, macOSInstallMixin, BaseCommand): +class DummyInstallCommand(macOSAppMixin, macOSCreateMixin, BaseCommand): """A dummy command to expose package installation capabilities.""" command = "install"