Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
8ece0d5
WIP: determining correct UTI from mime type
davidfokkema May 11, 2025
4264d3b
Set plist default values from UTI
davidfokkema May 13, 2025
4b01f9d
Remove print statements
davidfokkema May 13, 2025
4625cbe
Merge remote-tracking branch 'upstream/main' into file-associations
davidfokkema May 13, 2025
1327b07
Replace match-statement with if-statement
davidfokkema May 13, 2025
f82f484
Added change note
davidfokkema May 13, 2025
5c8faed
Add macOS doctype default variables to test
davidfokkema May 14, 2025
c70ebf6
Added tests and no coverage pragmas
davidfokkema May 14, 2025
03f6b55
Split out extraction of MIME type and rename variable
davidfokkema May 14, 2025
b61fbe6
If plist file is moved in OS update, catch exception
davidfokkema May 14, 2025
904b12b
Merge branch 'file-associations' of https://github.com/davidfokkema/b…
davidfokkema May 14, 2025
b493106
Add descriptions of tests
davidfokkema May 14, 2025
6c4b3c2
Complete code coverage
davidfokkema May 14, 2025
9323eba
Run macOS-specific tests only on macOS
davidfokkema May 14, 2025
a7f5620
Built-in types should have attribute set to True
davidfokkema May 16, 2025
a3c8efd
Renamed builtin_type -> is_core_type
davidfokkema May 18, 2025
3799ca9
Fix inconsistent naming and perform validity check
davidfokkema May 18, 2025
07c1565
Fix failing test
davidfokkema May 18, 2025
41cdc7a
Add tests to complete coverage
davidfokkema May 18, 2025
31f8342
Pass pre-commit, but fail coverage
davidfokkema May 18, 2025
5b28ded
Remove old test code
davidfokkema May 18, 2025
a810a4c
Fix coverage
davidfokkema May 22, 2025
fdbf75d
No-op to satisfy coverage checker
davidfokkema May 22, 2025
7412a90
Update changes/2284.feature.rst
davidfokkema May 23, 2025
2a3b44d
More test cases and improved LSItemContentTypes handling
davidfokkema May 23, 2025
1533af9
Add test to fix coverage
davidfokkema May 23, 2025
46d2cb6
Skip coverage if not macOS
davidfokkema May 23, 2025
fd48660
Reshuffled document type tests
davidfokkema May 25, 2025
06c12d5
Document the new document type support
davidfokkema May 25, 2025
df5909d
Rewrap docs
davidfokkema May 25, 2025
8203397
Trim trailing whitespace
davidfokkema May 25, 2025
7d96377
Simplify documentation to focus on the Briefcase use cases.
freakboy3742 Jun 13, 2025
7131e6e
Small cleanup of logic in macOS doctype processing.
freakboy3742 Jun 13, 2025
bae3210
Cleanup of test cases.
freakboy3742 Jun 13, 2025
d51b53b
Tweak release note.
freakboy3742 Jun 13, 2025
9374c63
Merge branch 'main' into file-associations
freakboy3742 Jun 13, 2025
63f594f
Restore some additional reading links on macOS document types.
freakboy3742 Jun 13, 2025
21ee20d
Correct spelling.
freakboy3742 Jun 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/2284.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Document type and file associations have been improved on macOS.
30 changes: 30 additions & 0 deletions docs/reference/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,8 @@ 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:

Document types
==============

Expand Down Expand Up @@ -655,11 +657,39 @@ round and square icons, in sizes ranging from 48px to 192px; Briefcase will
look for ``resource/round-icon-42.png``, ``resource/square-icon-42.png``,
``resource/round-icon-192.png``, and so on.

``mime_type``
-------------

A MIME type for the document format. This is used to register the document type with the
operating system. For example, ``image/png`` for PNG image files, or ``application/pdf``
for PDF files. A list of common MIME types is found in `Mozilla's list
<https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/MIME_types/Common_types>`__. A
full list is available at `IANA
<https://www.iana.org/assignments/media-types/media-types.xhtml>`__. Where platforms allow,
this MIME type will be used to determine other details about the document type.

If you do not specify a MIME type, Briefcase will generate a default MIME type of the
*unregistered* type ``application/x-<app name>-<document type id>``, e.g.
``application/x-myapp-data``. The ``x-`` prefix is specified by `RFC 2046
<https://www.rfc-editor.org/rfc/rfc2046.html>`__ for "private" MIME types. If you are
not using a formally registered mime type, you *must* use the ``x-`` prefix, or
`formally apply to IANA <https://www.iana.org/form/media-types>`__ for a new registered
MIME type.

``url``
-------

A URL for help related to the document format.

Platform support
----------------

Some platforms have specific configuration options that are only relevant to that
platform. In particular, Apple platforms (macOS, iOS) have a more elaborate system for
document types, and require additional configuration to use document types. If you want
to support document types on these platforms, you will need to read the macOS
:ref:`macOS-document-types` section for more information.

PEP621 compatibility
====================

Expand Down
103 changes: 103 additions & 0 deletions docs/reference/platforms/macOS/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,109 @@ 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.

.. _macOS-document-types:

Document types
==============

Internally, macOS uses Uniform Type Identifiers (UTIs) to track document types. UTIs are
strings that uniquely identify a type of data. They are similar to MIME types, but they
form a type hierarchy that allows for more complex relationships between types. For
example, PDF files have the UTI ``com.adobe.pdf``, which conforms to the UTI
``public.data``, indicating that PDF files are a specific type of data, and also
conforms to ``public.content``, indicating that they are a type of document that can be
shared via e.g. Airdrop. There is a long list of `standard UTIs defined by macOS
<https://developer.apple.com/documentation/uniformtypeidentifiers/system-declared-uniform-type-identifiers>`_.

These UTIs are then used to declare document types in an application's ``Info.plist``.
Briefcase will determine an appropriate declarations based on the MIME type that has
been provided (or generated) for a document type. However, there are also some
macOS-specific configuration items that can be used to override this default behavior
to control how document types are presented on macOS.

Configuration options
~~~~~~~~~~~~~~~~~~~~~

The following macOS-specific configuration keys can be used in a document type
declaration:

``macOS.CFBundleTypeRole``
--------------------------

`CFBundleTypeRole
<https://developer.apple.com/documentation/bundleresources/information-property-list/cfbundledocumenttypes/cfbundletyperole>`_
declares the role the application plays with respect to the document type. Valid values
are ``Editor``, ``Viewer``, ``Shell``, ``QLGenerator``, and ``None``.

Briefcase will default to a role of ``Viewer`` for all document types.

``macOS.LSHandlerRank``
-----------------------

`LSHandlerRank
<https://developer.apple.com/documentation/bundleresources/information-property-list/cfbundledocumenttypes/lshandlerrank>`_
defines the relative priority of this application when it comes to determining which
application should open an application. Valid values are ``Owner``, ``Alternate``,
``Default`` and ``None``.

Briefcase will default to a role of ``Alternate`` for any known MIME type, and ``Owner``
for any custom MIME type.

``macOS.LSItemContentTypes``
----------------------------

`LSItemContentTypes <https://developer.apple.com/documentation/bundleresources/information-property-list/cfbundledocumenttypes/lsitemcontenttypes>`_ define the
UTI content types that the app can handle.

Briefcase defaults to the the registered UTI type for known MIME types. It will construct a UTI of the form ``<bundle id>.<app name>.<document type id>`` (e.g., ``org.beeware.helloworld.document``) for unknown MIME types.

Although macOS technically allows an application to support multiple UTIs per document types, Briefcase can only assign a single content type. The value of ``macOS.LSItemContentTypes`` must be a string, or a list containing a single value.

``macOS.UTTypeConformsTo``
--------------------------

`UTTypeConformsTo
<https://developer.apple.com/documentation/BundleResources/Information-Property-List/UTExportedTypeDeclarations/UTTypeConformsTo>`_
defines the list of UTIs that the document type conforms to. Each entry is a string.

Briefcase will assume a default of ``["public.data", "public.content"]`` for unknown
MIME types. The value is not used for known mime types (as the operating system knows
the conforming types).

``macOS.is_core_type``
----------------------

A Boolean, used to explicitly declare a content type as a core type. This flag is used
to determine whether a ``UTImportedTypeDeclarations`` entry is required in macOS app
metadata.

You shouldn't need to set this value. Briefcase is able to determine whether a type is
core or not based using data provided by the operating system.

Packages
~~~~~~~~

macOS provides for document types that are *packages*. A package document is structured
as a directory on disk, but presents to the user as a single icon. An ``.app`` bundle is
an example of a package document type.

To define a package type, set ``macOS.UTTypeConformsTo`` to ``["com.apple.package",
"public.content"]``. If other UTI types apply, they can also be added to this list.

Further customization
~~~~~~~~~~~~~~~~~~~~~

For more details on macOS document type declarations, see the following web pages from
Apple provide more background information. They may be helpful in determining how to
expose content types for your application:

* `Defining file and data types for your app
<https://developer.apple.com/documentation/uniformtypeidentifiers/defining-file-and-data-types-for-your-app>`_
* `Uniform Type Identifiers — a reintroduction
<https://developer.apple.com/videos/play/tech-talks/10696/?time=549>`_
* `Core Foundation Keys (archived)
<https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html>`_

Permissions
===========

Expand Down
44 changes: 44 additions & 0 deletions src/briefcase/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,50 @@ def validate_document_type_config(document_type_id, document_type):
f"The URL associated with document type {document_type_id!r} is invalid: {e}"
)

if sys.platform == "darwin": # pragma: no-cover-if-not-macos
from briefcase.platforms.macOS.utils import is_uti_core_type, mime_type_to_uti

macOS = document_type.setdefault("macOS", {})
content_types = macOS.get("LSItemContentTypes", None)
mime_type = document_type.get("mime_type", None)

if isinstance(content_types, list):
if len(content_types) > 1:
raise BriefcaseConfigError(
f"""
Document type {document_type_id!r} has multiple content types. Specifying
multiple values in a LSItemContentTypes key is only valid when multiple document
types are manually grouped together in the Info.plist file. For Briefcase apps,
document types are always separately declared in the configuration file, so only
a single value should be provided.
"""
)

macOS["LSItemContentTypes"] = content_types
uti = content_types[0]
elif isinstance(content_types, str):
# If the content type is a string, convert it to a list
macOS["LSItemContentTypes"] = [content_types]
uti = content_types
else:
uti = None

# If an UTI is provided in LSItemContentTypes, that takes precedence over a MIME type
if is_uti_core_type(uti) or ((uti := mime_type_to_uti(mime_type)) is not None):
macOS.setdefault("is_core_type", True)
macOS.setdefault("LSItemContentTypes", [uti])
macOS.setdefault("LSHandlerRank", "Alternate")
else:
# LSItemContentTypes will default to bundle.app_name.document_type_id
# in the Info.plist template if it is not provided.
macOS.setdefault("is_core_type", False)
macOS.setdefault("LSHandlerRank", "Owner")
macOS.setdefault("UTTypeConformsTo", ["public.data", "public.content"])

macOS.setdefault("CFBundleTypeRole", "Viewer")
else: # pragma: no-cover-if-is-macos
pass


VALID_BUNDLE_RE = re.compile(r"[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+$")

Expand Down
82 changes: 82 additions & 0 deletions src/briefcase/platforms/macOS/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@
import concurrent
import email
import hashlib
import pathlib
import plistlib
import subprocess
from pathlib import Path

from briefcase.exceptions import BriefcaseCommandError

CORETYPES_PATH = "/System/Library/CoreServices/CoreTypes.bundle/Contents/Info.plist"


def sha256_file_digest(path: Path) -> str:
"""Compute a sha256 checksum digest of a file.
Expand Down Expand Up @@ -272,3 +276,81 @@ def merge_app_packages(
raise future.exception()
else:
self.console.info("No libraries require merging.")


def is_uti_core_type(uti: str) -> bool: # pragma: no-cover-if-not-macos
"""Check if a UTI is a built-in Core Type.

This function checks if a given UTI is a built-in Core Type by reading the
system's CoreTypes Info.plist file. If the file is not found, it assumes
that the system is not macOS or the file has been moved in recent macOS
versions, and returns False.

Args:
uti: The UTI to check.

Returns:
True if the UTI is a built-in Core Type, False otherwise.
"""
try:
plist_data = pathlib.Path(CORETYPES_PATH).read_bytes()
except FileNotFoundError:
# If the file is not found, we assume that the system is not macOS
# or the file has been moved in recent macOS versions.
# In this case, we return False to indicate that the UTI is not built-in.
return False
plist = plistlib.loads(plist_data)
return uti in {
type_declaration["UTTypeIdentifier"]
for type_declaration in plist["UTExportedTypeDeclarations"]
+ plist["UTImportedTypeDeclarations"]
}


def mime_type_to_uti(mime_type: str) -> str | None: # pragma: no-cover-if-not-macos
"""Convert a MIME type to a Uniform Type Identifier (UTI).

This function reads the system's CoreTypes Info.plist file to determine the
UTI for a given MIME type.

Args:
mime_type: The MIME type to convert.

Returns:
The UTI for the MIME type, or None if the UTI cannot be determined.
"""
try:
plist_data = pathlib.Path(CORETYPES_PATH).read_bytes()
except FileNotFoundError:
# If the file is not found, we assume that the system is not macOS
# or the file has been moved in recent macOS versions.
# In this case, we return None to indicate that the UTI cannot be determined.
return None
plist = plistlib.loads(plist_data)
for type_declaration in (
plist["UTExportedTypeDeclarations"] + plist["UTImportedTypeDeclarations"]
):
# We check both the system built-in types (exported) and the known
# third-party types (imported) to find the UTI for the given MIME type.
# Most type declarations will have a UTTypeTagSpecification dictionary
# with a "public.mime-type" key. That can be either a list of MIME types
# or a single MIME type. We check if the MIME type is in the list or
# matches the single MIME type. If we find a match, we return the UTI
# identifier. If we don't find a match, we return None.

mime_types = type_declaration.get("UTTypeTagSpecification", {}).get(
"public.mime-type", []
)
if isinstance(mime_types, list):
# Most MIME types are declared as a list even if they are a
# single type. Some types define multiple closely-related MIME
# types.
if mime_type in mime_types:
return type_declaration["UTTypeIdentifier"]
else:
# some MIME types are declared as a single type
if mime_types == mime_type:
return type_declaration["UTTypeIdentifier"]

# If no match is found in the entire list, return None
return None
39 changes: 32 additions & 7 deletions tests/config/test_AppConfig.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import sys

import pytest

from briefcase.config import AppConfig
Expand Down Expand Up @@ -62,6 +64,7 @@ def test_extra_attrs():
"extension": "doc",
"description": "A document",
"url": "https://testurl.com",
"mime_type": "application/x-my-doc-type",
}
},
first="value 1",
Expand All @@ -80,14 +83,36 @@ def test_extra_attrs():
# Properties that are derived by default have been set explicitly
assert config.formal_name == "My App!"
assert config.class_name == "MyApp"
assert config.document_types == {
"document": {
"icon": "icon",
"extension": "doc",
"description": "A document",
"url": "https://testurl.com",

if sys.platform == "darwin":
assert config.document_types == {
"document": {
"icon": "icon",
"extension": "doc",
"description": "A document",
"url": "https://testurl.com",
"mime_type": "application/x-my-doc-type",
"macOS": {
"CFBundleTypeRole": "Viewer",
"LSHandlerRank": "Owner",
"UTTypeConformsTo": [
"public.data",
"public.content",
],
"is_core_type": False,
},
}
}
else:
assert config.document_types == {
"document": {
"icon": "icon",
"extension": "doc",
"description": "A document",
"url": "https://testurl.com",
"mime_type": "application/x-my-doc-type",
}
}
}

# Explicit additional properties have been set
assert config.first == "value 1"
Expand Down
28 changes: 28 additions & 0 deletions tests/config/test_is_uti_core_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import sys

import pytest

from briefcase.platforms.macOS import utils


@pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS")
@pytest.mark.parametrize(
"uti, result",
[
(None, False),
("com.unknown.data", False),
("public.data", True),
("public.content", True),
("com.adobe.pdf", True),
],
)
def test_is_uti_core_type(uti, result):
"""Check if a UTI is a core type."""
assert utils.is_uti_core_type(uti) == result


@pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS")
def test_is_uti_core_type_with_nonexisting_coretypes_file(monkeypatch):
"""Test that is_uti_core_type returns None if the coretypes file doesn't exist."""
monkeypatch.setattr(utils, "CORETYPES_PATH", "/does/not/exist")
assert utils.is_uti_core_type("com.adobe.pdf") is False
Loading