Skip to content

Commit e80edff

Browse files
Improve macOS file associations (#2284)
Adds macOS-specific file association settings that allows the registration of core types, as well as making package types optional. --------- Co-authored-by: Russell Keith-Magee <[email protected]>
1 parent e9d3e34 commit e80edff

9 files changed

Lines changed: 438 additions & 7 deletions

File tree

changes/2284.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Document type and file associations have been improved on macOS.

docs/reference/configuration.rst

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,8 @@ permission, rather than a generic description of the permission being requested.
593593
The use of permissions may also imply other settings in your app. See the individual
594594
platform backends for details on how cross-platform permissions are mapped.
595595

596+
.. _document-types:
597+
596598
Document types
597599
==============
598600

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

660+
``mime_type``
661+
-------------
662+
663+
A MIME type for the document format. This is used to register the document type with the
664+
operating system. For example, ``image/png`` for PNG image files, or ``application/pdf``
665+
for PDF files. A list of common MIME types is found in `Mozilla's list
666+
<https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/MIME_types/Common_types>`__. A
667+
full list is available at `IANA
668+
<https://www.iana.org/assignments/media-types/media-types.xhtml>`__. Where platforms allow,
669+
this MIME type will be used to determine other details about the document type.
670+
671+
If you do not specify a MIME type, Briefcase will generate a default MIME type of the
672+
*unregistered* type ``application/x-<app name>-<document type id>``, e.g.
673+
``application/x-myapp-data``. The ``x-`` prefix is specified by `RFC 2046
674+
<https://www.rfc-editor.org/rfc/rfc2046.html>`__ for "private" MIME types. If you are
675+
not using a formally registered mime type, you *must* use the ``x-`` prefix, or
676+
`formally apply to IANA <https://www.iana.org/form/media-types>`__ for a new registered
677+
MIME type.
678+
658679
``url``
659680
-------
660681

661682
A URL for help related to the document format.
662683

684+
Platform support
685+
----------------
686+
687+
Some platforms have specific configuration options that are only relevant to that
688+
platform. In particular, Apple platforms (macOS, iOS) have a more elaborate system for
689+
document types, and require additional configuration to use document types. If you want
690+
to support document types on these platforms, you will need to read the macOS
691+
:ref:`macOS-document-types` section for more information.
692+
663693
PEP621 compatibility
664694
====================
665695

docs/reference/platforms/macOS/index.rst

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,109 @@ only be executable on the host platform on which it was built - i.e., if you bui
151151
an x86_64 machine, you will produce an x86_65 binary; if you build on an ARM64 machine,
152152
you will produce an ARM64 binary.
153153

154+
.. _macOS-document-types:
155+
156+
Document types
157+
==============
158+
159+
Internally, macOS uses Uniform Type Identifiers (UTIs) to track document types. UTIs are
160+
strings that uniquely identify a type of data. They are similar to MIME types, but they
161+
form a type hierarchy that allows for more complex relationships between types. For
162+
example, PDF files have the UTI ``com.adobe.pdf``, which conforms to the UTI
163+
``public.data``, indicating that PDF files are a specific type of data, and also
164+
conforms to ``public.content``, indicating that they are a type of document that can be
165+
shared via e.g. Airdrop. There is a long list of `standard UTIs defined by macOS
166+
<https://developer.apple.com/documentation/uniformtypeidentifiers/system-declared-uniform-type-identifiers>`_.
167+
168+
These UTIs are then used to declare document types in an application's ``Info.plist``.
169+
Briefcase will determine an appropriate declarations based on the MIME type that has
170+
been provided (or generated) for a document type. However, there are also some
171+
macOS-specific configuration items that can be used to override this default behavior
172+
to control how document types are presented on macOS.
173+
174+
Configuration options
175+
~~~~~~~~~~~~~~~~~~~~~
176+
177+
The following macOS-specific configuration keys can be used in a document type
178+
declaration:
179+
180+
``macOS.CFBundleTypeRole``
181+
--------------------------
182+
183+
`CFBundleTypeRole
184+
<https://developer.apple.com/documentation/bundleresources/information-property-list/cfbundledocumenttypes/cfbundletyperole>`_
185+
declares the role the application plays with respect to the document type. Valid values
186+
are ``Editor``, ``Viewer``, ``Shell``, ``QLGenerator``, and ``None``.
187+
188+
Briefcase will default to a role of ``Viewer`` for all document types.
189+
190+
``macOS.LSHandlerRank``
191+
-----------------------
192+
193+
`LSHandlerRank
194+
<https://developer.apple.com/documentation/bundleresources/information-property-list/cfbundledocumenttypes/lshandlerrank>`_
195+
defines the relative priority of this application when it comes to determining which
196+
application should open an application. Valid values are ``Owner``, ``Alternate``,
197+
``Default`` and ``None``.
198+
199+
Briefcase will default to a role of ``Alternate`` for any known MIME type, and ``Owner``
200+
for any custom MIME type.
201+
202+
``macOS.LSItemContentTypes``
203+
----------------------------
204+
205+
`LSItemContentTypes <https://developer.apple.com/documentation/bundleresources/information-property-list/cfbundledocumenttypes/lsitemcontenttypes>`_ define the
206+
UTI content types that the app can handle.
207+
208+
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.
209+
210+
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.
211+
212+
``macOS.UTTypeConformsTo``
213+
--------------------------
214+
215+
`UTTypeConformsTo
216+
<https://developer.apple.com/documentation/BundleResources/Information-Property-List/UTExportedTypeDeclarations/UTTypeConformsTo>`_
217+
defines the list of UTIs that the document type conforms to. Each entry is a string.
218+
219+
Briefcase will assume a default of ``["public.data", "public.content"]`` for unknown
220+
MIME types. The value is not used for known mime types (as the operating system knows
221+
the conforming types).
222+
223+
``macOS.is_core_type``
224+
----------------------
225+
226+
A Boolean, used to explicitly declare a content type as a core type. This flag is used
227+
to determine whether a ``UTImportedTypeDeclarations`` entry is required in macOS app
228+
metadata.
229+
230+
You shouldn't need to set this value. Briefcase is able to determine whether a type is
231+
core or not based using data provided by the operating system.
232+
233+
Packages
234+
~~~~~~~~
235+
236+
macOS provides for document types that are *packages*. A package document is structured
237+
as a directory on disk, but presents to the user as a single icon. An ``.app`` bundle is
238+
an example of a package document type.
239+
240+
To define a package type, set ``macOS.UTTypeConformsTo`` to ``["com.apple.package",
241+
"public.content"]``. If other UTI types apply, they can also be added to this list.
242+
243+
Further customization
244+
~~~~~~~~~~~~~~~~~~~~~
245+
246+
For more details on macOS document type declarations, see the following web pages from
247+
Apple provide more background information. They may be helpful in determining how to
248+
expose content types for your application:
249+
250+
* `Defining file and data types for your app
251+
<https://developer.apple.com/documentation/uniformtypeidentifiers/defining-file-and-data-types-for-your-app>`_
252+
* `Uniform Type Identifiers — a reintroduction
253+
<https://developer.apple.com/videos/play/tech-talks/10696/?time=549>`_
254+
* `Core Foundation Keys (archived)
255+
<https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html>`_
256+
154257
Permissions
155258
===========
156259

src/briefcase/config.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,50 @@ def validate_document_type_config(document_type_id, document_type):
146146
f"The URL associated with document type {document_type_id!r} is invalid: {e}"
147147
)
148148

149+
if sys.platform == "darwin": # pragma: no-cover-if-not-macos
150+
from briefcase.platforms.macOS.utils import is_uti_core_type, mime_type_to_uti
151+
152+
macOS = document_type.setdefault("macOS", {})
153+
content_types = macOS.get("LSItemContentTypes", None)
154+
mime_type = document_type.get("mime_type", None)
155+
156+
if isinstance(content_types, list):
157+
if len(content_types) > 1:
158+
raise BriefcaseConfigError(
159+
f"""
160+
Document type {document_type_id!r} has multiple content types. Specifying
161+
multiple values in a LSItemContentTypes key is only valid when multiple document
162+
types are manually grouped together in the Info.plist file. For Briefcase apps,
163+
document types are always separately declared in the configuration file, so only
164+
a single value should be provided.
165+
"""
166+
)
167+
168+
macOS["LSItemContentTypes"] = content_types
169+
uti = content_types[0]
170+
elif isinstance(content_types, str):
171+
# If the content type is a string, convert it to a list
172+
macOS["LSItemContentTypes"] = [content_types]
173+
uti = content_types
174+
else:
175+
uti = None
176+
177+
# If an UTI is provided in LSItemContentTypes, that takes precedence over a MIME type
178+
if is_uti_core_type(uti) or ((uti := mime_type_to_uti(mime_type)) is not None):
179+
macOS.setdefault("is_core_type", True)
180+
macOS.setdefault("LSItemContentTypes", [uti])
181+
macOS.setdefault("LSHandlerRank", "Alternate")
182+
else:
183+
# LSItemContentTypes will default to bundle.app_name.document_type_id
184+
# in the Info.plist template if it is not provided.
185+
macOS.setdefault("is_core_type", False)
186+
macOS.setdefault("LSHandlerRank", "Owner")
187+
macOS.setdefault("UTTypeConformsTo", ["public.data", "public.content"])
188+
189+
macOS.setdefault("CFBundleTypeRole", "Viewer")
190+
else: # pragma: no-cover-if-is-macos
191+
pass
192+
149193

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

src/briefcase/platforms/macOS/utils.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@
33
import concurrent
44
import email
55
import hashlib
6+
import pathlib
7+
import plistlib
68
import subprocess
79
from pathlib import Path
810

911
from briefcase.exceptions import BriefcaseCommandError
1012

13+
CORETYPES_PATH = "/System/Library/CoreServices/CoreTypes.bundle/Contents/Info.plist"
14+
1115

1216
def sha256_file_digest(path: Path) -> str:
1317
"""Compute a sha256 checksum digest of a file.
@@ -272,3 +276,81 @@ def merge_app_packages(
272276
raise future.exception()
273277
else:
274278
self.console.info("No libraries require merging.")
279+
280+
281+
def is_uti_core_type(uti: str) -> bool: # pragma: no-cover-if-not-macos
282+
"""Check if a UTI is a built-in Core Type.
283+
284+
This function checks if a given UTI is a built-in Core Type by reading the
285+
system's CoreTypes Info.plist file. If the file is not found, it assumes
286+
that the system is not macOS or the file has been moved in recent macOS
287+
versions, and returns False.
288+
289+
Args:
290+
uti: The UTI to check.
291+
292+
Returns:
293+
True if the UTI is a built-in Core Type, False otherwise.
294+
"""
295+
try:
296+
plist_data = pathlib.Path(CORETYPES_PATH).read_bytes()
297+
except FileNotFoundError:
298+
# If the file is not found, we assume that the system is not macOS
299+
# or the file has been moved in recent macOS versions.
300+
# In this case, we return False to indicate that the UTI is not built-in.
301+
return False
302+
plist = plistlib.loads(plist_data)
303+
return uti in {
304+
type_declaration["UTTypeIdentifier"]
305+
for type_declaration in plist["UTExportedTypeDeclarations"]
306+
+ plist["UTImportedTypeDeclarations"]
307+
}
308+
309+
310+
def mime_type_to_uti(mime_type: str) -> str | None: # pragma: no-cover-if-not-macos
311+
"""Convert a MIME type to a Uniform Type Identifier (UTI).
312+
313+
This function reads the system's CoreTypes Info.plist file to determine the
314+
UTI for a given MIME type.
315+
316+
Args:
317+
mime_type: The MIME type to convert.
318+
319+
Returns:
320+
The UTI for the MIME type, or None if the UTI cannot be determined.
321+
"""
322+
try:
323+
plist_data = pathlib.Path(CORETYPES_PATH).read_bytes()
324+
except FileNotFoundError:
325+
# If the file is not found, we assume that the system is not macOS
326+
# or the file has been moved in recent macOS versions.
327+
# In this case, we return None to indicate that the UTI cannot be determined.
328+
return None
329+
plist = plistlib.loads(plist_data)
330+
for type_declaration in (
331+
plist["UTExportedTypeDeclarations"] + plist["UTImportedTypeDeclarations"]
332+
):
333+
# We check both the system built-in types (exported) and the known
334+
# third-party types (imported) to find the UTI for the given MIME type.
335+
# Most type declarations will have a UTTypeTagSpecification dictionary
336+
# with a "public.mime-type" key. That can be either a list of MIME types
337+
# or a single MIME type. We check if the MIME type is in the list or
338+
# matches the single MIME type. If we find a match, we return the UTI
339+
# identifier. If we don't find a match, we return None.
340+
341+
mime_types = type_declaration.get("UTTypeTagSpecification", {}).get(
342+
"public.mime-type", []
343+
)
344+
if isinstance(mime_types, list):
345+
# Most MIME types are declared as a list even if they are a
346+
# single type. Some types define multiple closely-related MIME
347+
# types.
348+
if mime_type in mime_types:
349+
return type_declaration["UTTypeIdentifier"]
350+
else:
351+
# some MIME types are declared as a single type
352+
if mime_types == mime_type:
353+
return type_declaration["UTTypeIdentifier"]
354+
355+
# If no match is found in the entire list, return None
356+
return None

tests/config/test_AppConfig.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import sys
2+
13
import pytest
24

35
from briefcase.config import AppConfig
@@ -62,6 +64,7 @@ def test_extra_attrs():
6264
"extension": "doc",
6365
"description": "A document",
6466
"url": "https://testurl.com",
67+
"mime_type": "application/x-my-doc-type",
6568
}
6669
},
6770
first="value 1",
@@ -80,14 +83,36 @@ def test_extra_attrs():
8083
# Properties that are derived by default have been set explicitly
8184
assert config.formal_name == "My App!"
8285
assert config.class_name == "MyApp"
83-
assert config.document_types == {
84-
"document": {
85-
"icon": "icon",
86-
"extension": "doc",
87-
"description": "A document",
88-
"url": "https://testurl.com",
86+
87+
if sys.platform == "darwin":
88+
assert config.document_types == {
89+
"document": {
90+
"icon": "icon",
91+
"extension": "doc",
92+
"description": "A document",
93+
"url": "https://testurl.com",
94+
"mime_type": "application/x-my-doc-type",
95+
"macOS": {
96+
"CFBundleTypeRole": "Viewer",
97+
"LSHandlerRank": "Owner",
98+
"UTTypeConformsTo": [
99+
"public.data",
100+
"public.content",
101+
],
102+
"is_core_type": False,
103+
},
104+
}
105+
}
106+
else:
107+
assert config.document_types == {
108+
"document": {
109+
"icon": "icon",
110+
"extension": "doc",
111+
"description": "A document",
112+
"url": "https://testurl.com",
113+
"mime_type": "application/x-my-doc-type",
114+
}
89115
}
90-
}
91116

92117
# Explicit additional properties have been set
93118
assert config.first == "value 1"
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import sys
2+
3+
import pytest
4+
5+
from briefcase.platforms.macOS import utils
6+
7+
8+
@pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS")
9+
@pytest.mark.parametrize(
10+
"uti, result",
11+
[
12+
(None, False),
13+
("com.unknown.data", False),
14+
("public.data", True),
15+
("public.content", True),
16+
("com.adobe.pdf", True),
17+
],
18+
)
19+
def test_is_uti_core_type(uti, result):
20+
"""Check if a UTI is a core type."""
21+
assert utils.is_uti_core_type(uti) == result
22+
23+
24+
@pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS")
25+
def test_is_uti_core_type_with_nonexisting_coretypes_file(monkeypatch):
26+
"""Test that is_uti_core_type returns None if the coretypes file doesn't exist."""
27+
monkeypatch.setattr(utils, "CORETYPES_PATH", "/does/not/exist")
28+
assert utils.is_uti_core_type("com.adobe.pdf") is False

0 commit comments

Comments
 (0)