From 8ece0d5dd6cfe1dd7c18eb84717d7035618a7d9c Mon Sep 17 00:00:00 2001 From: David Fokkema Date: Sun, 11 May 2025 11:05:18 +0200 Subject: [PATCH 01/35] WIP: determining correct UTI from mime type --- pyproject.toml | 2 +- src/briefcase/config.py | 11 +++++++++ src/briefcase/platforms/macOS/utils.py | 32 ++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7d694e289..224ba2156 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ dynamic = ["version"] name = "briefcase" description = "Tools to support converting a Python project into a standalone native application." readme = "README.rst" -requires-python = ">= 3.9" +requires-python = ">= 3.10" license.text = "New BSD" authors = [ {name="Russell Keith-Magee", email="russell@keith-magee.com"}, diff --git a/src/briefcase/config.py b/src/briefcase/config.py index 2c245870b..350b49be3 100644 --- a/src/briefcase/config.py +++ b/src/briefcase/config.py @@ -146,6 +146,17 @@ 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": + from briefcase.platforms.macOS.utils import mime_type_to_UTI + + if UTI := mime_type_to_UTI(document_type["mime_type"]) is not None: + document_type.setdefault("macOS.LSItemContentType", UTI) + document_type.setdefault("macOS.LSHandlerRank", "Alternate") + else: + # LSItemContentType will default to bundle.app_name.document_type_id + # in the Info.plist template if it is not provided. + document_type.setdefault("macOS.LSHandlerRank", "Owner") + VALID_BUNDLE_RE = re.compile(r"[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+$") diff --git a/src/briefcase/platforms/macOS/utils.py b/src/briefcase/platforms/macOS/utils.py index 02e48e4d5..ffa7f0ab6 100644 --- a/src/briefcase/platforms/macOS/utils.py +++ b/src/briefcase/platforms/macOS/utils.py @@ -3,6 +3,8 @@ import concurrent import email import hashlib +import pathlib +import plistlib import subprocess from pathlib import Path @@ -272,3 +274,33 @@ def merge_app_packages( raise future.exception() else: self.console.info("No libraries require merging.") + + +def mime_type_to_UTI(mime_type: str) -> str | None: + """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. + """ + plist_data = pathlib.Path( + "/System/Library/CoreServices/CoreTypes.bundle/Contents/Info.plist" + ).read_bytes() + plist = plistlib.loads(plist_data) + for type_declaration in ( + plist["UTExportedTypeDeclarations"] + plist["UTImportedTypeDeclarations"] + ): + match type_declaration.get("UTTypeTagSpecification", {}).get( + "public.mime-type", [] + ): + case [*types]: + if mime_type in types: + return type_declaration["UTTypeIdentifier"] + case type_: + if type_ == mime_type: + return type_declaration["UTTypeIdentifier"] From 4264d3bcf632650e9199ecaacf49b23d5d444949 Mon Sep 17 00:00:00 2001 From: David Fokkema Date: Tue, 13 May 2025 21:59:47 +0200 Subject: [PATCH 02/35] Set plist default values from UTI --- src/briefcase/config.py | 13 +++++++++---- src/briefcase/platforms/macOS/utils.py | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/briefcase/config.py b/src/briefcase/config.py index 350b49be3..e1b9c7bd6 100644 --- a/src/briefcase/config.py +++ b/src/briefcase/config.py @@ -149,13 +149,18 @@ def validate_document_type_config(document_type_id, document_type): if sys.platform == "darwin": from briefcase.platforms.macOS.utils import mime_type_to_UTI - if UTI := mime_type_to_UTI(document_type["mime_type"]) is not None: - document_type.setdefault("macOS.LSItemContentType", UTI) - document_type.setdefault("macOS.LSHandlerRank", "Alternate") + macOS = document_type.setdefault("macOS", {}) + if (UTI := mime_type_to_UTI(document_type.get("mime_type", None))) is not None: + macOS.setdefault("LSItemContentType", UTI) + macOS.setdefault("LSHandlerRank", "Alternate") + macOS.setdefault("UTTypeConformsTo", ["public.xyz"]) else: # LSItemContentType will default to bundle.app_name.document_type_id # in the Info.plist template if it is not provided. - document_type.setdefault("macOS.LSHandlerRank", "Owner") + macOS.setdefault("LSHandlerRank", "Owner") + macOS.setdefault("UTTypeConformsTo", ["public.data", "public.content"]) + + macOS.setdefault("CFBundleTypeRole", "Viewer") VALID_BUNDLE_RE = re.compile(r"[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+$") diff --git a/src/briefcase/platforms/macOS/utils.py b/src/briefcase/platforms/macOS/utils.py index ffa7f0ab6..a5f84fc69 100644 --- a/src/briefcase/platforms/macOS/utils.py +++ b/src/briefcase/platforms/macOS/utils.py @@ -295,12 +295,29 @@ def mime_type_to_UTI(mime_type: str) -> str | None: 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. + match type_declaration.get("UTTypeTagSpecification", {}).get( "public.mime-type", [] ): case [*types]: + # 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 types: + print(f"{type_declaration['UTTypeIdentifier']=}") return type_declaration["UTTypeIdentifier"] case type_: + # some MIME types are declared as a single type if type_ == mime_type: + print(f"{type_declaration['UTTypeIdentifier']=}") return type_declaration["UTTypeIdentifier"] + + # If no match is found in the entire list, return None + return None From 4b01f9d699f1f1d6a66ffba98049ad29d4f49d57 Mon Sep 17 00:00:00 2001 From: David Fokkema Date: Tue, 13 May 2025 22:01:15 +0200 Subject: [PATCH 03/35] Remove print statements --- src/briefcase/platforms/macOS/utils.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/briefcase/platforms/macOS/utils.py b/src/briefcase/platforms/macOS/utils.py index a5f84fc69..a7cb78b6f 100644 --- a/src/briefcase/platforms/macOS/utils.py +++ b/src/briefcase/platforms/macOS/utils.py @@ -311,12 +311,10 @@ def mime_type_to_UTI(mime_type: str) -> str | None: # single type. Some types define multiple closely-related MIME # types. if mime_type in types: - print(f"{type_declaration['UTTypeIdentifier']=}") return type_declaration["UTTypeIdentifier"] case type_: # some MIME types are declared as a single type if type_ == mime_type: - print(f"{type_declaration['UTTypeIdentifier']=}") return type_declaration["UTTypeIdentifier"] # If no match is found in the entire list, return None From 1327b07c0311ecbd667d0341399f242e6ba61392 Mon Sep 17 00:00:00 2001 From: David Fokkema Date: Tue, 13 May 2025 22:11:39 +0200 Subject: [PATCH 04/35] Replace match-statement with if-statement --- src/briefcase/platforms/macOS/utils.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/briefcase/platforms/macOS/utils.py b/src/briefcase/platforms/macOS/utils.py index a7cb78b6f..1b6df8d45 100644 --- a/src/briefcase/platforms/macOS/utils.py +++ b/src/briefcase/platforms/macOS/utils.py @@ -303,19 +303,19 @@ def mime_type_to_UTI(mime_type: str) -> str | None: # 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. - match type_declaration.get("UTTypeTagSpecification", {}).get( + mime_types = type_declaration.get("UTTypeTagSpecification", {}).get( "public.mime-type", [] - ): - case [*types]: - # 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 types: - return type_declaration["UTTypeIdentifier"] - case type_: - # some MIME types are declared as a single type - if type_ == mime_type: - return type_declaration["UTTypeIdentifier"] + ) + 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 From f82f484c76cb255489afbb7152e22a35ede62239 Mon Sep 17 00:00:00 2001 From: David Fokkema Date: Tue, 13 May 2025 22:34:26 +0200 Subject: [PATCH 05/35] Added change note --- changes/2284.feature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/2284.feature.rst diff --git a/changes/2284.feature.rst b/changes/2284.feature.rst new file mode 100644 index 000000000..f6e0ed41d --- /dev/null +++ b/changes/2284.feature.rst @@ -0,0 +1 @@ +Added improved document types (file associations) for Windows and macOS platforms From 5c8faeda6f8c9c73154fdd63456d1f195629b78b Mon Sep 17 00:00:00 2001 From: David Fokkema Date: Wed, 14 May 2025 11:52:04 +0200 Subject: [PATCH 06/35] Add macOS doctype default variables to test --- tests/config/test_AppConfig.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/config/test_AppConfig.py b/tests/config/test_AppConfig.py index 6fa8b8e79..d44e8fe78 100644 --- a/tests/config/test_AppConfig.py +++ b/tests/config/test_AppConfig.py @@ -84,6 +84,14 @@ def test_extra_attrs(): "extension": "doc", "description": "A document", "url": "https://testurl.com", + "macOS": { + "CFBundleTypeRole": "Viewer", + "LSHandlerRank": "Owner", + "UTTypeConformsTo": [ + "public.data", + "public.content", + ], + }, } } From c70ebf6f4a0078610b5642176f890f58edd85782 Mon Sep 17 00:00:00 2001 From: David Fokkema Date: Wed, 14 May 2025 12:40:27 +0200 Subject: [PATCH 07/35] Added tests and no coverage pragmas --- src/briefcase/config.py | 3 +-- src/briefcase/platforms/macOS/utils.py | 2 +- tests/config/test_document_type_config.py | 23 +++++++++++++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/briefcase/config.py b/src/briefcase/config.py index e086b8d6b..c811337f9 100644 --- a/src/briefcase/config.py +++ b/src/briefcase/config.py @@ -146,14 +146,13 @@ 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": + if sys.platform == "darwin": # pragma: no-cover-if-not-macos from briefcase.platforms.macOS.utils import mime_type_to_UTI macOS = document_type.setdefault("macOS", {}) if (UTI := mime_type_to_UTI(document_type.get("mime_type", None))) is not None: macOS.setdefault("LSItemContentType", UTI) macOS.setdefault("LSHandlerRank", "Alternate") - macOS.setdefault("UTTypeConformsTo", ["public.xyz"]) else: # LSItemContentType will default to bundle.app_name.document_type_id # in the Info.plist template if it is not provided. diff --git a/src/briefcase/platforms/macOS/utils.py b/src/briefcase/platforms/macOS/utils.py index 1b6df8d45..f40783b17 100644 --- a/src/briefcase/platforms/macOS/utils.py +++ b/src/briefcase/platforms/macOS/utils.py @@ -276,7 +276,7 @@ def merge_app_packages( self.console.info("No libraries require merging.") -def mime_type_to_UTI(mime_type: str) -> str | None: +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 diff --git a/tests/config/test_document_type_config.py b/tests/config/test_document_type_config.py index 84ec64492..f8d2162f6 100644 --- a/tests/config/test_document_type_config.py +++ b/tests/config/test_document_type_config.py @@ -160,3 +160,26 @@ def test_validate_document_invalid_extension(invalid_extension, valid_document): match=r"The extension provided for document type .* is not alphanumeric.", ): validate_document_type_config("ext", valid_document) + + +def test_document_type_macOS_config_with_mimetype_single(valid_document): + """Valid document types don't raise an exception when validated.""" + valid_document["mime_type"] = "application/pdf" + validate_document_type_config("ext", valid_document) + assert "LSItemContentType" in valid_document["macOS"].keys() + assert valid_document["macOS"]["LSItemContentType"] == "com.adobe.pdf" + + +def test_document_type_macOS_config_with_mimetype_list(valid_document): + """Valid document types don't raise an exception when validated.""" + valid_document["mime_type"] = "text/vcard" + validate_document_type_config("ext", valid_document) + assert "LSItemContentType" in valid_document["macOS"].keys() + assert valid_document["macOS"]["LSItemContentType"] == "public.vcard" + + +def test_document_type_macOS_config_with_unknown_mimetype(valid_document): + """Valid document types don't raise an exception when validated.""" + valid_document["mime_type"] = "custom/mytype" + validate_document_type_config("ext", valid_document) + assert "LSItemContentType" not in valid_document["macOS"].keys() From 03f6b5575c465ed515019b02563c326f994d3fdd Mon Sep 17 00:00:00 2001 From: David Fokkema Date: Wed, 14 May 2025 12:41:54 +0200 Subject: [PATCH 08/35] Split out extraction of MIME type and rename variable Co-authored-by: Russell Keith-Magee --- src/briefcase/config.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/briefcase/config.py b/src/briefcase/config.py index c811337f9..1d3798fdc 100644 --- a/src/briefcase/config.py +++ b/src/briefcase/config.py @@ -150,8 +150,9 @@ def validate_document_type_config(document_type_id, document_type): from briefcase.platforms.macOS.utils import mime_type_to_UTI macOS = document_type.setdefault("macOS", {}) - if (UTI := mime_type_to_UTI(document_type.get("mime_type", None))) is not None: - macOS.setdefault("LSItemContentType", UTI) + mime_type = document_type.get("mime_type", None) + if (uti := mime_type_to_UTI(mime_type)) is not None: + macOS.setdefault("LSItemContentType", uti) macOS.setdefault("LSHandlerRank", "Alternate") else: # LSItemContentType will default to bundle.app_name.document_type_id From b61fbe609105f0dfee253f3825d7a529821a52d3 Mon Sep 17 00:00:00 2001 From: David Fokkema Date: Wed, 14 May 2025 12:44:50 +0200 Subject: [PATCH 09/35] If plist file is moved in OS update, catch exception --- src/briefcase/platforms/macOS/utils.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/briefcase/platforms/macOS/utils.py b/src/briefcase/platforms/macOS/utils.py index f40783b17..b64870bda 100644 --- a/src/briefcase/platforms/macOS/utils.py +++ b/src/briefcase/platforms/macOS/utils.py @@ -288,9 +288,15 @@ def mime_type_to_UTI(mime_type: str) -> str | None: # pragma: no-cover-if-not-m Returns: The UTI for the MIME type, or None if the UTI cannot be determined. """ - plist_data = pathlib.Path( - "/System/Library/CoreServices/CoreTypes.bundle/Contents/Info.plist" - ).read_bytes() + try: + plist_data = pathlib.Path( + "/System/Library/CoreServices/CoreTypes.bundle/Contents/Info.plist" + ).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"] From b49310684b5e69d74004683484c8f2587a3d6d2f Mon Sep 17 00:00:00 2001 From: David Fokkema Date: Wed, 14 May 2025 12:54:17 +0200 Subject: [PATCH 10/35] Add descriptions of tests --- tests/config/test_document_type_config.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/config/test_document_type_config.py b/tests/config/test_document_type_config.py index f8d2162f6..3c59b3860 100644 --- a/tests/config/test_document_type_config.py +++ b/tests/config/test_document_type_config.py @@ -163,7 +163,10 @@ def test_validate_document_invalid_extension(invalid_extension, valid_document): def test_document_type_macOS_config_with_mimetype_single(valid_document): - """Valid document types don't raise an exception when validated.""" + """Valid document types don't raise an exception when validated. + + application/pdf is the only valid MIME type for PDF files. + """ valid_document["mime_type"] = "application/pdf" validate_document_type_config("ext", valid_document) assert "LSItemContentType" in valid_document["macOS"].keys() @@ -171,7 +174,12 @@ def test_document_type_macOS_config_with_mimetype_single(valid_document): def test_document_type_macOS_config_with_mimetype_list(valid_document): - """Valid document types don't raise an exception when validated.""" + """Valid document types don't raise an exception when validated. + + text/vcard is _not_ the only valid MIME type for vCard files, others are + text/directory and text/x-vcard so a list if MIME types is returned + internally but should still resolve to public.vcard + """ valid_document["mime_type"] = "text/vcard" validate_document_type_config("ext", valid_document) assert "LSItemContentType" in valid_document["macOS"].keys() @@ -179,7 +187,11 @@ def test_document_type_macOS_config_with_mimetype_list(valid_document): def test_document_type_macOS_config_with_unknown_mimetype(valid_document): - """Valid document types don't raise an exception when validated.""" + """Valid document types don't raise an exception when validated. + + Here, a MIME type is provided that is not known to be valid for any file. + That means that LSItemContentType should _not_ be set. + """ valid_document["mime_type"] = "custom/mytype" validate_document_type_config("ext", valid_document) assert "LSItemContentType" not in valid_document["macOS"].keys() From 6c4b3c2c9c7fa52a91cd4a4c6259e6ad95a1cdef Mon Sep 17 00:00:00 2001 From: David Fokkema Date: Wed, 14 May 2025 22:17:23 +0200 Subject: [PATCH 11/35] Complete code coverage --- src/briefcase/config.py | 2 ++ src/briefcase/platforms/macOS/utils.py | 6 +++--- tests/config/test_document_type_config.py | 7 +++++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/briefcase/config.py b/src/briefcase/config.py index 1d3798fdc..4850bd516 100644 --- a/src/briefcase/config.py +++ b/src/briefcase/config.py @@ -161,6 +161,8 @@ def validate_document_type_config(document_type_id, document_type): 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-]+)+$") diff --git a/src/briefcase/platforms/macOS/utils.py b/src/briefcase/platforms/macOS/utils.py index b64870bda..2aac734a3 100644 --- a/src/briefcase/platforms/macOS/utils.py +++ b/src/briefcase/platforms/macOS/utils.py @@ -10,6 +10,8 @@ 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. @@ -289,9 +291,7 @@ def mime_type_to_UTI(mime_type: str) -> str | None: # pragma: no-cover-if-not-m The UTI for the MIME type, or None if the UTI cannot be determined. """ try: - plist_data = pathlib.Path( - "/System/Library/CoreServices/CoreTypes.bundle/Contents/Info.plist" - ).read_bytes() + 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. diff --git a/tests/config/test_document_type_config.py b/tests/config/test_document_type_config.py index 3c59b3860..ba34037e9 100644 --- a/tests/config/test_document_type_config.py +++ b/tests/config/test_document_type_config.py @@ -2,6 +2,7 @@ from briefcase.config import validate_document_type_config from briefcase.exceptions import BriefcaseConfigError +from briefcase.platforms.macOS import utils @pytest.fixture @@ -195,3 +196,9 @@ def test_document_type_macOS_config_with_unknown_mimetype(valid_document): valid_document["mime_type"] = "custom/mytype" validate_document_type_config("ext", valid_document) assert "LSItemContentType" not in valid_document["macOS"].keys() + + +def test_mime_type_to_uti_with_nonexisting_coretypes_file(monkeypatch): + """Test that mime_type_to_UTI returns None if the coretypes file doesn't exist.""" + monkeypatch.setattr(utils, "CORETYPES_PATH", "/does/not/exist") + assert utils.mime_type_to_UTI("application/pdf") is None From 9323ebab088941afa80a4cdebaf377d93e189559 Mon Sep 17 00:00:00 2001 From: David Fokkema Date: Wed, 14 May 2025 22:38:52 +0200 Subject: [PATCH 12/35] Run macOS-specific tests only on macOS --- tests/config/test_AppConfig.py | 43 +++++++++++++++-------- tests/config/test_document_type_config.py | 6 ++++ 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/tests/config/test_AppConfig.py b/tests/config/test_AppConfig.py index d44e8fe78..e7f309680 100644 --- a/tests/config/test_AppConfig.py +++ b/tests/config/test_AppConfig.py @@ -1,3 +1,5 @@ +import sys + import pytest from briefcase.config import AppConfig @@ -78,22 +80,33 @@ 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", - "macOS": { - "CFBundleTypeRole": "Viewer", - "LSHandlerRank": "Owner", - "UTTypeConformsTo": [ - "public.data", - "public.content", - ], - }, + + if sys.platform == "darwin": + assert config.document_types == { + "document": { + "icon": "icon", + "extension": "doc", + "description": "A document", + "url": "https://testurl.com", + "macOS": { + "CFBundleTypeRole": "Viewer", + "LSHandlerRank": "Owner", + "UTTypeConformsTo": [ + "public.data", + "public.content", + ], + }, + } + } + else: + assert config.document_types == { + "document": { + "icon": "icon", + "extension": "doc", + "description": "A document", + "url": "https://testurl.com", + } } - } # Explicit additional properties have been set assert config.first == "value 1" diff --git a/tests/config/test_document_type_config.py b/tests/config/test_document_type_config.py index ba34037e9..44084a5e8 100644 --- a/tests/config/test_document_type_config.py +++ b/tests/config/test_document_type_config.py @@ -1,3 +1,5 @@ +import sys + import pytest from briefcase.config import validate_document_type_config @@ -163,6 +165,7 @@ def test_validate_document_invalid_extension(invalid_extension, valid_document): validate_document_type_config("ext", valid_document) +@pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS") def test_document_type_macOS_config_with_mimetype_single(valid_document): """Valid document types don't raise an exception when validated. @@ -174,6 +177,7 @@ def test_document_type_macOS_config_with_mimetype_single(valid_document): assert valid_document["macOS"]["LSItemContentType"] == "com.adobe.pdf" +@pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS") def test_document_type_macOS_config_with_mimetype_list(valid_document): """Valid document types don't raise an exception when validated. @@ -187,6 +191,7 @@ def test_document_type_macOS_config_with_mimetype_list(valid_document): assert valid_document["macOS"]["LSItemContentType"] == "public.vcard" +@pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS") def test_document_type_macOS_config_with_unknown_mimetype(valid_document): """Valid document types don't raise an exception when validated. @@ -198,6 +203,7 @@ def test_document_type_macOS_config_with_unknown_mimetype(valid_document): assert "LSItemContentType" not in valid_document["macOS"].keys() +@pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS") def test_mime_type_to_uti_with_nonexisting_coretypes_file(monkeypatch): """Test that mime_type_to_UTI returns None if the coretypes file doesn't exist.""" monkeypatch.setattr(utils, "CORETYPES_PATH", "/does/not/exist") From a7f562049dbb0637017540ef45a727c9fabe99ce Mon Sep 17 00:00:00 2001 From: David Fokkema Date: Fri, 16 May 2025 17:20:22 +0200 Subject: [PATCH 13/35] Built-in types should have attribute set to True --- src/briefcase/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/briefcase/config.py b/src/briefcase/config.py index 4850bd516..76172169f 100644 --- a/src/briefcase/config.py +++ b/src/briefcase/config.py @@ -154,6 +154,7 @@ def validate_document_type_config(document_type_id, document_type): if (uti := mime_type_to_UTI(mime_type)) is not None: macOS.setdefault("LSItemContentType", uti) macOS.setdefault("LSHandlerRank", "Alternate") + macOS.setdefault("builtin_type", True) else: # LSItemContentType will default to bundle.app_name.document_type_id # in the Info.plist template if it is not provided. From a3c8efd7b36c3940254a7f8cab9925748a3b9593 Mon Sep 17 00:00:00 2001 From: David Fokkema Date: Sun, 18 May 2025 12:00:43 +0200 Subject: [PATCH 14/35] Renamed builtin_type -> is_core_type --- src/briefcase/config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/briefcase/config.py b/src/briefcase/config.py index 76172169f..dadf771bb 100644 --- a/src/briefcase/config.py +++ b/src/briefcase/config.py @@ -152,12 +152,13 @@ def validate_document_type_config(document_type_id, document_type): macOS = document_type.setdefault("macOS", {}) mime_type = document_type.get("mime_type", None) if (uti := mime_type_to_UTI(mime_type)) is not None: + macOS.setdefault("is_core_type", True) macOS.setdefault("LSItemContentType", uti) macOS.setdefault("LSHandlerRank", "Alternate") - macOS.setdefault("builtin_type", True) else: # LSItemContentType 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"]) From 3799ca98028060f1a19b83b423a41a99e3d7f6f1 Mon Sep 17 00:00:00 2001 From: David Fokkema Date: Sun, 18 May 2025 15:25:15 +0200 Subject: [PATCH 15/35] Fix inconsistent naming and perform validity check 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 must be provided. Because the key name is plural and is an array in the Info.plist file, a list *is* accepted, but must have only one element. --- src/briefcase/config.py | 17 +++++++++++++++-- tests/config/test_document_type_config.py | 12 ++++++------ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/briefcase/config.py b/src/briefcase/config.py index dadf771bb..1bac4defe 100644 --- a/src/briefcase/config.py +++ b/src/briefcase/config.py @@ -153,16 +153,29 @@ def validate_document_type_config(document_type_id, document_type): mime_type = document_type.get("mime_type", None) if (uti := mime_type_to_UTI(mime_type)) is not None: macOS.setdefault("is_core_type", True) - macOS.setdefault("LSItemContentType", uti) + macOS.setdefault("LSItemContentTypes", uti) macOS.setdefault("LSHandlerRank", "Alternate") else: - # LSItemContentType will default to bundle.app_name.document_type_id + # 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") + + content_types = macOS.get("LSItemContentTypes", 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. + """) + else: + macOS["LSItemContentTypes"] = content_types[0] else: # pragma: no-cover-if-is-macos pass diff --git a/tests/config/test_document_type_config.py b/tests/config/test_document_type_config.py index 44084a5e8..c81f235e8 100644 --- a/tests/config/test_document_type_config.py +++ b/tests/config/test_document_type_config.py @@ -173,8 +173,8 @@ def test_document_type_macOS_config_with_mimetype_single(valid_document): """ valid_document["mime_type"] = "application/pdf" validate_document_type_config("ext", valid_document) - assert "LSItemContentType" in valid_document["macOS"].keys() - assert valid_document["macOS"]["LSItemContentType"] == "com.adobe.pdf" + assert "LSItemContentTypes" in valid_document["macOS"].keys() + assert valid_document["macOS"]["LSItemContentTypes"] == "com.adobe.pdf" @pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS") @@ -187,8 +187,8 @@ def test_document_type_macOS_config_with_mimetype_list(valid_document): """ valid_document["mime_type"] = "text/vcard" validate_document_type_config("ext", valid_document) - assert "LSItemContentType" in valid_document["macOS"].keys() - assert valid_document["macOS"]["LSItemContentType"] == "public.vcard" + assert "LSItemContentTypes" in valid_document["macOS"].keys() + assert valid_document["macOS"]["LSItemContentTypes"] == "public.vcard" @pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS") @@ -196,11 +196,11 @@ def test_document_type_macOS_config_with_unknown_mimetype(valid_document): """Valid document types don't raise an exception when validated. Here, a MIME type is provided that is not known to be valid for any file. - That means that LSItemContentType should _not_ be set. + That means that LSItemContentTypes should _not_ be set. """ valid_document["mime_type"] = "custom/mytype" validate_document_type_config("ext", valid_document) - assert "LSItemContentType" not in valid_document["macOS"].keys() + assert "LSItemContentTypes" not in valid_document["macOS"].keys() @pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS") From 07c15656c78d2c272ef25130ad6ba9d1f28a3f70 Mon Sep 17 00:00:00 2001 From: David Fokkema Date: Sun, 18 May 2025 15:47:21 +0200 Subject: [PATCH 16/35] Fix failing test --- tests/config/test_AppConfig.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/config/test_AppConfig.py b/tests/config/test_AppConfig.py index e7f309680..45e4fa009 100644 --- a/tests/config/test_AppConfig.py +++ b/tests/config/test_AppConfig.py @@ -95,6 +95,7 @@ def test_extra_attrs(): "public.data", "public.content", ], + "is_core_type": False, }, } } From 41cdc7a61870ab71f0900c6df8bb7b2f12bfcb02 Mon Sep 17 00:00:00 2001 From: David Fokkema Date: Sun, 18 May 2025 16:41:47 +0200 Subject: [PATCH 17/35] Add tests to complete coverage --- src/briefcase/config.py | 4 +++ tests/config/test_document_type_config.py | 33 +++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/briefcase/config.py b/src/briefcase/config.py index 1bac4defe..2b2ede949 100644 --- a/src/briefcase/config.py +++ b/src/briefcase/config.py @@ -176,6 +176,10 @@ def validate_document_type_config(document_type_id, document_type): """) else: macOS["LSItemContentTypes"] = content_types[0] + else: + # if I don't include an assignment here, coverage complains that + # the branch is never reached + reached = True else: # pragma: no-cover-if-is-macos pass diff --git a/tests/config/test_document_type_config.py b/tests/config/test_document_type_config.py index c81f235e8..980f0f707 100644 --- a/tests/config/test_document_type_config.py +++ b/tests/config/test_document_type_config.py @@ -208,3 +208,36 @@ def test_mime_type_to_uti_with_nonexisting_coretypes_file(monkeypatch): """Test that mime_type_to_UTI returns None if the coretypes file doesn't exist.""" monkeypatch.setattr(utils, "CORETYPES_PATH", "/does/not/exist") assert utils.mime_type_to_UTI("application/pdf") is None + + +@pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS") +def test_document_type_macOS_config_with_list_of_content_types(valid_document): + """Multiple content types are not allowed. + + If a document type has multiple content types, an exception is raised. + """ + valid_document.setdefault("macOS", {})["LSItemContentTypes"] = [ + "com.adobe.pdf", + "public.vcard", + ] + with pytest.raises( + BriefcaseConfigError, + match="Document type 'ext' has multiple content types.", + ): + validate_document_type_config("ext", valid_document) + + +@pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS") +def test_document_type_macOS_config_with_list_of_single_content_type(valid_document): + """Single content type is allowed, even if a list is provided. + + If a document type has a single content type, it is converted to a string. + """ + valid_document.setdefault("macOS", {})["LSItemContentTypes"] = "com.adobe.pdf" + validate_document_type_config("ext", valid_document) + assert valid_document["macOS"]["LSItemContentTypes"] == "com.adobe.pdf" + # assert valid_document["macOS"]["else_reached"] is True + + valid_document["macOS"]["LSItemContentTypes"] = ["com.adobe.pdf"] + validate_document_type_config("ext", valid_document) + assert valid_document["macOS"]["LSItemContentTypes"] == "com.adobe.pdf" From 31f8342f364baaee199d990392f4f754c6e79311 Mon Sep 17 00:00:00 2001 From: David Fokkema Date: Sun, 18 May 2025 16:52:51 +0200 Subject: [PATCH 18/35] Pass pre-commit, but fail coverage Since the code to pass coverage was ugly and should be removed in any case, I'm now passing pre-commit. I'm really not sure why coverage.py keeps complaining the branch is never reached, when it clearly _is_. --- src/briefcase/config.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/briefcase/config.py b/src/briefcase/config.py index 2b2ede949..88323179c 100644 --- a/src/briefcase/config.py +++ b/src/briefcase/config.py @@ -167,19 +167,17 @@ def validate_document_type_config(document_type_id, document_type): content_types = macOS.get("LSItemContentTypes", None) if isinstance(content_types, list): if len(content_types) > 1: - raise BriefcaseConfigError(f""" + 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. - """) + """ + ) else: macOS["LSItemContentTypes"] = content_types[0] - else: - # if I don't include an assignment here, coverage complains that - # the branch is never reached - reached = True else: # pragma: no-cover-if-is-macos pass From 5b28dedaa630f15edf4e48999e58dee313485ed6 Mon Sep 17 00:00:00 2001 From: David Fokkema Date: Sun, 18 May 2025 17:04:12 +0200 Subject: [PATCH 19/35] Remove old test code --- tests/config/test_document_type_config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/config/test_document_type_config.py b/tests/config/test_document_type_config.py index 980f0f707..c0903b26c 100644 --- a/tests/config/test_document_type_config.py +++ b/tests/config/test_document_type_config.py @@ -236,7 +236,6 @@ def test_document_type_macOS_config_with_list_of_single_content_type(valid_docum valid_document.setdefault("macOS", {})["LSItemContentTypes"] = "com.adobe.pdf" validate_document_type_config("ext", valid_document) assert valid_document["macOS"]["LSItemContentTypes"] == "com.adobe.pdf" - # assert valid_document["macOS"]["else_reached"] is True valid_document["macOS"]["LSItemContentTypes"] = ["com.adobe.pdf"] validate_document_type_config("ext", valid_document) From a810a4c377b7ee34644603b5d8884fd544f0f092 Mon Sep 17 00:00:00 2001 From: David Fokkema Date: Thu, 22 May 2025 12:24:00 +0200 Subject: [PATCH 20/35] Fix coverage ...but I don't like the fix. --- src/briefcase/config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/briefcase/config.py b/src/briefcase/config.py index 88323179c..1f5893039 100644 --- a/src/briefcase/config.py +++ b/src/briefcase/config.py @@ -178,6 +178,8 @@ def validate_document_type_config(document_type_id, document_type): ) else: macOS["LSItemContentTypes"] = content_types[0] + else: + True is True else: # pragma: no-cover-if-is-macos pass From fdbf75df28fdabd6a9b995c815bd2c8c6dff146c Mon Sep 17 00:00:00 2001 From: David Fokkema Date: Thu, 22 May 2025 15:02:34 +0200 Subject: [PATCH 21/35] No-op to satisfy coverage checker --- src/briefcase/config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/briefcase/config.py b/src/briefcase/config.py index 1f5893039..ca7a10dbd 100644 --- a/src/briefcase/config.py +++ b/src/briefcase/config.py @@ -179,7 +179,8 @@ def validate_document_type_config(document_type_id, document_type): else: macOS["LSItemContentTypes"] = content_types[0] else: - True is True + # This is basically a no-op to satisfy coverage checkers + content_types = "string or None" else: # pragma: no-cover-if-is-macos pass From 7412a90fe5b4c3cbdea98238ffe92cb7b26f1473 Mon Sep 17 00:00:00 2001 From: David Fokkema Date: Fri, 23 May 2025 12:40:39 +0200 Subject: [PATCH 22/35] Update changes/2284.feature.rst Co-authored-by: Russell Keith-Magee --- changes/2284.feature.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/2284.feature.rst b/changes/2284.feature.rst index f6e0ed41d..e5200bed8 100644 --- a/changes/2284.feature.rst +++ b/changes/2284.feature.rst @@ -1 +1 @@ -Added improved document types (file associations) for Windows and macOS platforms +Improved document types and file associations for macOS platforms. From 2a3b44d36c8beb3c88ad4dd896b0b1f3d9e8d9cf Mon Sep 17 00:00:00 2001 From: David Fokkema Date: Fri, 23 May 2025 16:49:48 +0200 Subject: [PATCH 23/35] More test cases and improved LSItemContentTypes handling --- src/briefcase/config.py | 22 +++++++++-- src/briefcase/platforms/macOS/utils.py | 31 ++++++++++++++- tests/config/test_AppConfig.py | 3 ++ tests/config/test_document_type_config.py | 48 ++++++++++++++++++----- 4 files changed, 89 insertions(+), 15 deletions(-) diff --git a/src/briefcase/config.py b/src/briefcase/config.py index ca7a10dbd..d87f2599a 100644 --- a/src/briefcase/config.py +++ b/src/briefcase/config.py @@ -147,11 +147,21 @@ def validate_document_type_config(document_type_id, document_type): ) if sys.platform == "darwin": # pragma: no-cover-if-not-macos - from briefcase.platforms.macOS.utils import mime_type_to_UTI + 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 (uti := mime_type_to_UTI(mime_type)) is not None: + + if isinstance(content_types, list): + uti = content_types[0] + elif isinstance(content_types, str): + 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") @@ -177,10 +187,14 @@ def validate_document_type_config(document_type_id, document_type): """ ) else: - macOS["LSItemContentTypes"] = content_types[0] + # This is basically a no-op to satisfy coverage checkers + content_types = "is a list with a single value" + elif isinstance(content_types, str): + # If the content type is a string, convert it to a list + macOS["LSItemContentTypes"] = [content_types] else: # This is basically a no-op to satisfy coverage checkers - content_types = "string or None" + content_types = "is None or something unexpected" else: # pragma: no-cover-if-is-macos pass diff --git a/src/briefcase/platforms/macOS/utils.py b/src/briefcase/platforms/macOS/utils.py index 2aac734a3..7253892b0 100644 --- a/src/briefcase/platforms/macOS/utils.py +++ b/src/briefcase/platforms/macOS/utils.py @@ -278,7 +278,36 @@ def merge_app_packages( self.console.info("No libraries require merging.") -def mime_type_to_UTI(mime_type: str) -> str | None: # pragma: no-cover-if-not-macos +def is_uti_core_type(uti: str) -> bool: + """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 diff --git a/tests/config/test_AppConfig.py b/tests/config/test_AppConfig.py index 45e4fa009..4666f92f2 100644 --- a/tests/config/test_AppConfig.py +++ b/tests/config/test_AppConfig.py @@ -62,6 +62,7 @@ def test_extra_attrs(): "extension": "doc", "description": "A document", "url": "https://testurl.com", + "mime_type": "text/x.my-doc-type", } }, first="value 1", @@ -88,6 +89,7 @@ def test_extra_attrs(): "extension": "doc", "description": "A document", "url": "https://testurl.com", + "mime_type": "text/x.my-doc-type", "macOS": { "CFBundleTypeRole": "Viewer", "LSHandlerRank": "Owner", @@ -106,6 +108,7 @@ def test_extra_attrs(): "extension": "doc", "description": "A document", "url": "https://testurl.com", + "mime_type": "text/x.my-doc-type", } } diff --git a/tests/config/test_document_type_config.py b/tests/config/test_document_type_config.py index c0903b26c..3745a5c57 100644 --- a/tests/config/test_document_type_config.py +++ b/tests/config/test_document_type_config.py @@ -174,7 +174,10 @@ def test_document_type_macOS_config_with_mimetype_single(valid_document): valid_document["mime_type"] = "application/pdf" validate_document_type_config("ext", valid_document) assert "LSItemContentTypes" in valid_document["macOS"].keys() - assert valid_document["macOS"]["LSItemContentTypes"] == "com.adobe.pdf" + assert valid_document["macOS"]["LSItemContentTypes"] == ["com.adobe.pdf"] + assert valid_document["macOS"]["is_core_type"] is True + assert valid_document["macOS"]["LSHandlerRank"] == "Alternate" + assert "UTTypeConformsTo" not in valid_document["macOS"].keys() @pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS") @@ -188,7 +191,10 @@ def test_document_type_macOS_config_with_mimetype_list(valid_document): valid_document["mime_type"] = "text/vcard" validate_document_type_config("ext", valid_document) assert "LSItemContentTypes" in valid_document["macOS"].keys() - assert valid_document["macOS"]["LSItemContentTypes"] == "public.vcard" + assert valid_document["macOS"]["LSItemContentTypes"] == ["public.vcard"] + assert valid_document["macOS"]["is_core_type"] is True + assert valid_document["macOS"]["LSHandlerRank"] == "Alternate" + assert "UTTypeConformsTo" not in valid_document["macOS"].keys() @pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS") @@ -201,13 +207,30 @@ def test_document_type_macOS_config_with_unknown_mimetype(valid_document): valid_document["mime_type"] = "custom/mytype" validate_document_type_config("ext", valid_document) assert "LSItemContentTypes" not in valid_document["macOS"].keys() + assert valid_document["macOS"]["is_core_type"] is False + assert valid_document["macOS"]["LSHandlerRank"] == "Owner" + # default to a 'normal' document type + assert valid_document["macOS"]["UTTypeConformsTo"] == [ + "public.data", + "public.content", + ] + + +@pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS") +def test_is_uti_core_type(): + """Check if a UTI is a core type.""" + assert utils.is_uti_core_type(None) is False + assert utils.is_uti_core_type("public.data") is True + assert utils.is_uti_core_type("public.content") is True + assert utils.is_uti_core_type("com.adobe.pdf") is True + assert utils.is_uti_core_type("com.unknown.data") is False @pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS") def test_mime_type_to_uti_with_nonexisting_coretypes_file(monkeypatch): """Test that mime_type_to_UTI returns None if the coretypes file doesn't exist.""" monkeypatch.setattr(utils, "CORETYPES_PATH", "/does/not/exist") - assert utils.mime_type_to_UTI("application/pdf") is None + assert utils.mime_type_to_uti("application/pdf") is None @pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS") @@ -228,15 +251,20 @@ def test_document_type_macOS_config_with_list_of_content_types(valid_document): @pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS") -def test_document_type_macOS_config_with_list_of_single_content_type(valid_document): +@pytest.mark.parametrize( + "ls_item_content_types", + ["com.adobe.pdf", ["com.adobe.pdf"]], +) +def test_document_type_macOS_config_with_list_of_single_content_type( + valid_document, ls_item_content_types +): """Single content type is allowed, even if a list is provided. If a document type has a single content type, it is converted to a string. """ - valid_document.setdefault("macOS", {})["LSItemContentTypes"] = "com.adobe.pdf" - validate_document_type_config("ext", valid_document) - assert valid_document["macOS"]["LSItemContentTypes"] == "com.adobe.pdf" - - valid_document["macOS"]["LSItemContentTypes"] = ["com.adobe.pdf"] + valid_document.setdefault("macOS", {})["LSItemContentTypes"] = ls_item_content_types validate_document_type_config("ext", valid_document) - assert valid_document["macOS"]["LSItemContentTypes"] == "com.adobe.pdf" + assert valid_document["macOS"]["LSItemContentTypes"] == ["com.adobe.pdf"] + assert valid_document["macOS"]["is_core_type"] is True + assert valid_document["macOS"]["LSHandlerRank"] == "Alternate" + assert "UTTypeConformsTo" not in valid_document["macOS"].keys() From 1533af96d6d2aa42cfbcbb055917e138c9bcbc18 Mon Sep 17 00:00:00 2001 From: David Fokkema Date: Fri, 23 May 2025 16:59:15 +0200 Subject: [PATCH 24/35] Add test to fix coverage --- tests/config/test_document_type_config.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/config/test_document_type_config.py b/tests/config/test_document_type_config.py index 3745a5c57..bc65f735e 100644 --- a/tests/config/test_document_type_config.py +++ b/tests/config/test_document_type_config.py @@ -233,6 +233,13 @@ def test_mime_type_to_uti_with_nonexisting_coretypes_file(monkeypatch): assert utils.mime_type_to_uti("application/pdf") is None +@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 mime_type_to_UTI 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 + + @pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS") def test_document_type_macOS_config_with_list_of_content_types(valid_document): """Multiple content types are not allowed. From 46d2cb600ab3909d485dc79b6b21d6e93f538aba Mon Sep 17 00:00:00 2001 From: David Fokkema Date: Fri, 23 May 2025 17:03:16 +0200 Subject: [PATCH 25/35] Skip coverage if not macOS --- src/briefcase/platforms/macOS/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/briefcase/platforms/macOS/utils.py b/src/briefcase/platforms/macOS/utils.py index 7253892b0..051efc56e 100644 --- a/src/briefcase/platforms/macOS/utils.py +++ b/src/briefcase/platforms/macOS/utils.py @@ -278,7 +278,7 @@ def merge_app_packages( self.console.info("No libraries require merging.") -def is_uti_core_type(uti: str) -> bool: +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 From fd486609e705a491023a1906bbee3cb9cf1e6004 Mon Sep 17 00:00:00 2001 From: David Fokkema Date: Sun, 25 May 2025 10:48:17 +0200 Subject: [PATCH 26/35] Reshuffled document type tests --- tests/config/test_is_uti_core_type.py | 22 ++++++++++++++++ tests/config/test_mime_type_to_uti.py | 22 ++++++++++++++++ ... => test_validate_document_type_config.py} | 25 ------------------- 3 files changed, 44 insertions(+), 25 deletions(-) create mode 100644 tests/config/test_is_uti_core_type.py create mode 100644 tests/config/test_mime_type_to_uti.py rename tests/config/{test_document_type_config.py => test_validate_document_type_config.py} (87%) diff --git a/tests/config/test_is_uti_core_type.py b/tests/config/test_is_uti_core_type.py new file mode 100644 index 000000000..62b865dbf --- /dev/null +++ b/tests/config/test_is_uti_core_type.py @@ -0,0 +1,22 @@ +import sys + +import pytest + +from briefcase.platforms.macOS import utils + + +@pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS") +def test_is_uti_core_type(): + """Check if a UTI is a core type.""" + assert utils.is_uti_core_type(None) is False + assert utils.is_uti_core_type("public.data") is True + assert utils.is_uti_core_type("public.content") is True + assert utils.is_uti_core_type("com.adobe.pdf") is True + assert utils.is_uti_core_type("com.unknown.data") is False + + +@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 diff --git a/tests/config/test_mime_type_to_uti.py b/tests/config/test_mime_type_to_uti.py new file mode 100644 index 000000000..e2732f620 --- /dev/null +++ b/tests/config/test_mime_type_to_uti.py @@ -0,0 +1,22 @@ +import sys + +import pytest + +from briefcase.platforms.macOS import utils + + +@pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS") +def test_mime_type_to_uti(): + """Check if a MIME type can be converted to a UTI.""" + assert utils.mime_type_to_uti(None) is None + assert utils.mime_type_to_uti("application/pdf") == "com.adobe.pdf" + assert utils.mime_type_to_uti("text/plain") == "public.plain-text" + assert utils.mime_type_to_uti("image/png") == "public.png" + assert utils.mime_type_to_uti("application/unknown") is None + + +@pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS") +def test_mime_type_to_uti_with_nonexisting_coretypes_file(monkeypatch): + """Test that mime_type_to_UTI returns None if the coretypes file doesn't exist.""" + monkeypatch.setattr(utils, "CORETYPES_PATH", "/does/not/exist") + assert utils.mime_type_to_uti("application/pdf") is None diff --git a/tests/config/test_document_type_config.py b/tests/config/test_validate_document_type_config.py similarity index 87% rename from tests/config/test_document_type_config.py rename to tests/config/test_validate_document_type_config.py index bc65f735e..122dc986f 100644 --- a/tests/config/test_document_type_config.py +++ b/tests/config/test_validate_document_type_config.py @@ -4,7 +4,6 @@ from briefcase.config import validate_document_type_config from briefcase.exceptions import BriefcaseConfigError -from briefcase.platforms.macOS import utils @pytest.fixture @@ -216,30 +215,6 @@ def test_document_type_macOS_config_with_unknown_mimetype(valid_document): ] -@pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS") -def test_is_uti_core_type(): - """Check if a UTI is a core type.""" - assert utils.is_uti_core_type(None) is False - assert utils.is_uti_core_type("public.data") is True - assert utils.is_uti_core_type("public.content") is True - assert utils.is_uti_core_type("com.adobe.pdf") is True - assert utils.is_uti_core_type("com.unknown.data") is False - - -@pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS") -def test_mime_type_to_uti_with_nonexisting_coretypes_file(monkeypatch): - """Test that mime_type_to_UTI returns None if the coretypes file doesn't exist.""" - monkeypatch.setattr(utils, "CORETYPES_PATH", "/does/not/exist") - assert utils.mime_type_to_uti("application/pdf") is None - - -@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 mime_type_to_UTI 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 - - @pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS") def test_document_type_macOS_config_with_list_of_content_types(valid_document): """Multiple content types are not allowed. From 06c12d5549b4368736e564c1f42c2d60908239ea Mon Sep 17 00:00:00 2001 From: David Fokkema Date: Sun, 25 May 2025 21:35:15 +0200 Subject: [PATCH 27/35] Document the new document type support --- docs/reference/configuration.rst | 31 ++++++ docs/reference/platforms/macOS/index.rst | 122 +++++++++++++++++++++++ 2 files changed, 153 insertions(+) diff --git a/docs/reference/configuration.rst b/docs/reference/configuration.rst index 5a0e2bdbb..b139f1a3a 100644 --- a/docs/reference/configuration.rst +++ b/docs/reference/configuration.rst @@ -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 ============== @@ -655,11 +657,40 @@ 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 +`__. +A full list is available at `IANA +`__. Please +specify the MIME type of existing document types since they can be used by the +operating system to find more information 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--``, e.g. +``application/x-myapp-data``. This is not a valid MIME type, and should not be +used for production applications. It is only used for testing purposes, to allow +applications to be registered as document handlers without requiring a +registered MIME type. You can apply for a registered MIME type at `IANA +`__, but be sure to read all the RFCs. + + ``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. Currently Apple platforms (macOS, iOS) have a more elaborate system for 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 ==================== diff --git a/docs/reference/platforms/macOS/index.rst b/docs/reference/platforms/macOS/index.rst index 0915c8b41..f356e0a3c 100644 --- a/docs/reference/platforms/macOS/index.rst +++ b/docs/reference/platforms/macOS/index.rst @@ -151,6 +151,128 @@ 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 +============== + +General usage of document types is described in the :ref:`document-types` +section. That may be all you need! However, macOS has some additional quirks +that you may need to be aware of when defining document types for your app. + +Background +~~~~~~~~~~ + +First, macOS document types are defined in the ``Info.plist`` file, and the +amount of information that is required depends on whether the document type is a +custom document type or a standard document type already defined by macOS. The +system keeps track of document types using Uniform Type Identifiers (UTIs), +which 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 +`_. +To find the identifier you'll have to click on the type in the list, and then +look it up under the *Discussion* section. + +When you define a document type for your app, you need to provide information +about the type, such as its UTI, the file extensions it supports, and whether +your app can edit or view files of that type. This information is used by macOS +to determine which apps can open which types of files. + +If it is a so-called *core type*, then you only need to tell macOS that you +support that type. If it is a not a core type, but a file type defined by +another application, you need to *import* that type definition but also provide +some basic information about the type for the case that the user has not (yet) +installed the official app for that document type. If the document type is +custom for you app, you need to provide all information and *export* the type +definition so that other apps can use it. Briefcase handles all of this for you +automatically, depending on your configuration. You may need to provide whether +the document type is custom for your app, or defined by another app. You can +also change some default assumptions, like whether your app can *edit* the +document type or not. We will discuss this in more detail below. + +For reference, the following web pages from Apple provide more background information, while the implications for Briefcase are described below: + +* `Standard UTIs defined by macOS `_ +* `Defining file and data types for your app `_ +* `Building a document-based app with SwiftUI `_ +* `Uniform Type Identifiers — a reintroduction `_ +* `Core Foundation Keys (archived) `_ + +Configuration +~~~~~~~~~~~~~ + +By defining a MIME type in your app's configuration, Briefcase will automatically determine whether the type is a core type, a type defined by another app, or a custom type for your app. It will then generate the appropriate entries in the ``Info.plist`` file for your app. For example, if you provide a MIME type of ``application/pdf``, Briefcase will determine that this is a core type and has a UTI ``com.adobe.pdf``. You can override the generated values by defining the following keys in your app's configuration: + +.. list-table:: + :header-rows: 1 + :widths: 20 40 40 + + * - Key + - Possible Values + - Description + * - ``macOS.is_core_type`` + - ``True``, ``False`` + - Whether the document type is a core type defined by macOS. + * - `macOS.CFBundleTypeRole `_ + - ``Editor``, ``Viewer``, ``Shell``, ``QLGenerator``, ``None`` + - The role of the app with respect to the document type. + * - `macOS.LSHandlerRank `_ + - ``Owner``, ``Alternate``, ``Default``, ``None`` + - The rank of the app with respect to the document type. + * - `macOS.LSItemContentTypes `_ + - A list of strings, each representing a UTI. + - A list of Uniform Type Identifiers (UTIs) that the app can handle. + * - `macOS.UTTypeConformsTo `_ + - A list of strings, each representing a UTI. + - A list of Uniform Type Identifiers (UTIs) that the document type conforms to. + +In all cases Briefcase will set the ``macOS.CFBundleTypeRole`` to ``Viewer`` to +indicate that your app can view files of that type. If you want your app to be +able to edit files of that type, you can set the ``macOS.CFBundleTypeRole`` to +``Editor``. + +Core Types +---------- + +When the document type is a core type, Briefcase will automatically set the +``macOS.is_core_type`` key to ``True``, ``macOS.LSItemContentTypes`` to the UTI of the core type, and ``macOS.LSHandlerRank`` to ``Alternate`` since your app is not the primary handler for that type. Since core types are defined by macOS, you do not need to provide any other information about the type. + +Custom Types +------------ + +When the document type is *not* a core type, Briefcase will set the +``macOS.is_core_type`` key to ``False``, and will set the +``macOS.LSItemContentTypes`` to ``..`` as +the UTI for the document type. You can override this by providing your own +value, e.g. in the case where the document type is defined by *another app* and +you can look up the UTI. The ``macOS.LSHandlerRank`` will be set to ``Owner`` by +default, indicating that your app is the primary handler for that type. If you +want your app to be an alternate handler for the type, e.g. in the case that the +document type is defined by another app, you can set the ``macOS.LSHandlerRank`` +to ``Alternate``. Finally, the ``macOS.UTTypeConformsTo`` will be set to +``["public.data", "public.content"]`` by default, indicating that the document +type is a type of data document. If you define an image format, you can conform +to ``public.image``, which itself conforms to ``public.data`` and +``public.content``. Check the list of `standard UTIs defined by macOS +`_ +to find the appropriate UTI for your document type. + +Packages +-------- + +A special case is when the document type is a *package*. That is a directory +that contains files, but is treated as a single file by the operating system. +For example, the ``.app`` bundle is a package, as is the ``.pkg`` installer. If +you want to define such a document type, set ``macOS.UTTypeConformsTo`` to +``["com.apple.package", "public.content"]``, optionally including other UTIs +like ``public.image``. + + Permissions =========== From df5909d28791fd1b610eed82cd5a7fc7d7bad043 Mon Sep 17 00:00:00 2001 From: David Fokkema Date: Sun, 25 May 2025 21:42:08 +0200 Subject: [PATCH 28/35] Rewrap docs --- docs/reference/configuration.rst | 7 +++++-- docs/reference/platforms/macOS/index.rst | 16 +++++++++++++--- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/reference/configuration.rst b/docs/reference/configuration.rst index b139f1a3a..685bf3036 100644 --- a/docs/reference/configuration.rst +++ b/docs/reference/configuration.rst @@ -687,8 +687,11 @@ A URL for help related to the document format. Platform support ---------------- -Some platforms have specific configuration options that are only relevant to that -platform. Currently Apple platforms (macOS, iOS) have a more elaborate system for 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. +Some platforms have specific configuration options that are only relevant to +that platform. Currently Apple platforms (macOS, iOS) have a more elaborate +system for 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 diff --git a/docs/reference/platforms/macOS/index.rst b/docs/reference/platforms/macOS/index.rst index f356e0a3c..923d5587c 100644 --- a/docs/reference/platforms/macOS/index.rst +++ b/docs/reference/platforms/macOS/index.rst @@ -195,7 +195,8 @@ the document type is custom for your app, or defined by another app. You can also change some default assumptions, like whether your app can *edit* the document type or not. We will discuss this in more detail below. -For reference, the following web pages from Apple provide more background information, while the implications for Briefcase are described below: +For reference, the following web pages from Apple provide more background +information, while the implications for Briefcase are described below: * `Standard UTIs defined by macOS `_ * `Defining file and data types for your app `_ @@ -206,7 +207,13 @@ For reference, the following web pages from Apple provide more background inform Configuration ~~~~~~~~~~~~~ -By defining a MIME type in your app's configuration, Briefcase will automatically determine whether the type is a core type, a type defined by another app, or a custom type for your app. It will then generate the appropriate entries in the ``Info.plist`` file for your app. For example, if you provide a MIME type of ``application/pdf``, Briefcase will determine that this is a core type and has a UTI ``com.adobe.pdf``. You can override the generated values by defining the following keys in your app's configuration: +By defining a MIME type in your app's configuration, Briefcase will +automatically determine whether the type is a core type, a type defined by +another app, or a custom type for your app. It will then generate the +appropriate entries in the ``Info.plist`` file for your app. For example, if you +provide a MIME type of ``application/pdf``, Briefcase will determine that this +is a core type and has a UTI ``com.adobe.pdf``. You can override the generated +values by defining the following keys in your app's configuration: .. list-table:: :header-rows: 1 @@ -240,7 +247,10 @@ Core Types ---------- When the document type is a core type, Briefcase will automatically set the -``macOS.is_core_type`` key to ``True``, ``macOS.LSItemContentTypes`` to the UTI of the core type, and ``macOS.LSHandlerRank`` to ``Alternate`` since your app is not the primary handler for that type. Since core types are defined by macOS, you do not need to provide any other information about the type. +``macOS.is_core_type`` key to ``True``, ``macOS.LSItemContentTypes`` to the UTI +of the core type, and ``macOS.LSHandlerRank`` to ``Alternate`` since your app is +not the primary handler for that type. Since core types are defined by macOS, +you do not need to provide any other information about the type. Custom Types ------------ From 82033972fb0debe477ebff4270adeee31ffc28c2 Mon Sep 17 00:00:00 2001 From: David Fokkema Date: Sun, 25 May 2025 21:47:32 +0200 Subject: [PATCH 29/35] Trim trailing whitespace --- docs/reference/platforms/macOS/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/platforms/macOS/index.rst b/docs/reference/platforms/macOS/index.rst index 923d5587c..7bd4547f0 100644 --- a/docs/reference/platforms/macOS/index.rst +++ b/docs/reference/platforms/macOS/index.rst @@ -223,7 +223,7 @@ values by defining the following keys in your app's configuration: - Possible Values - Description * - ``macOS.is_core_type`` - - ``True``, ``False`` + - ``True``, ``False`` - Whether the document type is a core type defined by macOS. * - `macOS.CFBundleTypeRole `_ - ``Editor``, ``Viewer``, ``Shell``, ``QLGenerator``, ``None`` From 7d963777fd375ad3c6eb1de616d63c00bb2fea88 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 13 Jun 2025 14:57:07 +0800 Subject: [PATCH 30/35] Simplify documentation to focus on the Briefcase use cases. --- docs/reference/configuration.rst | 44 +++-- docs/reference/platforms/macOS/index.rst | 199 +++++++++-------------- 2 files changed, 98 insertions(+), 145 deletions(-) diff --git a/docs/reference/configuration.rst b/docs/reference/configuration.rst index 685bf3036..3283babf3 100644 --- a/docs/reference/configuration.rst +++ b/docs/reference/configuration.rst @@ -660,24 +660,21 @@ look for ``resource/round-icon-42.png``, ``resource/square-icon-42.png``, ``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 -`__. -A full list is available at `IANA -`__. Please -specify the MIME type of existing document types since they can be used by the -operating system to find more information 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--``, e.g. -``application/x-myapp-data``. This is not a valid MIME type, and should not be -used for production applications. It is only used for testing purposes, to allow -applications to be registered as document handlers without requiring a -registered MIME type. You can apply for a registered MIME type at `IANA -`__, but be sure to read all the RFCs. - +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 +`__. A +full list is available at `IANA +`__. 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--``, e.g. +``application/x-myapp-data``. The ``x-`` prefix is specified by `RFC 2046 +`__ 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 `__ for a new registered +MIME type. ``url`` ------- @@ -687,12 +684,11 @@ A URL for help related to the document format. Platform support ---------------- -Some platforms have specific configuration options that are only relevant to -that platform. Currently Apple platforms (macOS, iOS) have a more elaborate -system for 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. - +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 ==================== diff --git a/docs/reference/platforms/macOS/index.rst b/docs/reference/platforms/macOS/index.rst index 7bd4547f0..aa62e6472 100644 --- a/docs/reference/platforms/macOS/index.rst +++ b/docs/reference/platforms/macOS/index.rst @@ -156,132 +156,89 @@ you will produce an ARM64 binary. Document types ============== -General usage of document types is described in the :ref:`document-types` -section. That may be all you need! However, macOS has some additional quirks -that you may need to be aware of when defining document types for your app. - -Background -~~~~~~~~~~ - -First, macOS document types are defined in the ``Info.plist`` file, and the -amount of information that is required depends on whether the document type is a -custom document type or a standard document type already defined by macOS. The -system keeps track of document types using Uniform Type Identifiers (UTIs), -which 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 +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 `_. -To find the identifier you'll have to click on the type in the list, and then -look it up under the *Discussion* section. - -When you define a document type for your app, you need to provide information -about the type, such as its UTI, the file extensions it supports, and whether -your app can edit or view files of that type. This information is used by macOS -to determine which apps can open which types of files. - -If it is a so-called *core type*, then you only need to tell macOS that you -support that type. If it is a not a core type, but a file type defined by -another application, you need to *import* that type definition but also provide -some basic information about the type for the case that the user has not (yet) -installed the official app for that document type. If the document type is -custom for you app, you need to provide all information and *export* the type -definition so that other apps can use it. Briefcase handles all of this for you -automatically, depending on your configuration. You may need to provide whether -the document type is custom for your app, or defined by another app. You can -also change some default assumptions, like whether your app can *edit* the -document type or not. We will discuss this in more detail below. - -For reference, the following web pages from Apple provide more background -information, while the implications for Briefcase are described below: - -* `Standard UTIs defined by macOS `_ -* `Defining file and data types for your app `_ -* `Building a document-based app with SwiftUI `_ -* `Uniform Type Identifiers — a reintroduction `_ -* `Core Foundation Keys (archived) `_ - -Configuration -~~~~~~~~~~~~~ - -By defining a MIME type in your app's configuration, Briefcase will -automatically determine whether the type is a core type, a type defined by -another app, or a custom type for your app. It will then generate the -appropriate entries in the ``Info.plist`` file for your app. For example, if you -provide a MIME type of ``application/pdf``, Briefcase will determine that this -is a core type and has a UTI ``com.adobe.pdf``. You can override the generated -values by defining the following keys in your app's configuration: - -.. list-table:: - :header-rows: 1 - :widths: 20 40 40 - - * - Key - - Possible Values - - Description - * - ``macOS.is_core_type`` - - ``True``, ``False`` - - Whether the document type is a core type defined by macOS. - * - `macOS.CFBundleTypeRole `_ - - ``Editor``, ``Viewer``, ``Shell``, ``QLGenerator``, ``None`` - - The role of the app with respect to the document type. - * - `macOS.LSHandlerRank `_ - - ``Owner``, ``Alternate``, ``Default``, ``None`` - - The rank of the app with respect to the document type. - * - `macOS.LSItemContentTypes `_ - - A list of strings, each representing a UTI. - - A list of Uniform Type Identifiers (UTIs) that the app can handle. - * - `macOS.UTTypeConformsTo `_ - - A list of strings, each representing a UTI. - - A list of Uniform Type Identifiers (UTIs) that the document type conforms to. - -In all cases Briefcase will set the ``macOS.CFBundleTypeRole`` to ``Viewer`` to -indicate that your app can view files of that type. If you want your app to be -able to edit files of that type, you can set the ``macOS.CFBundleTypeRole`` to -``Editor``. - -Core Types ----------- - -When the document type is a core type, Briefcase will automatically set the -``macOS.is_core_type`` key to ``True``, ``macOS.LSItemContentTypes`` to the UTI -of the core type, and ``macOS.LSHandlerRank`` to ``Alternate`` since your app is -not the primary handler for that type. Since core types are defined by macOS, -you do not need to provide any other information about the type. - -Custom Types ------------- - -When the document type is *not* a core type, Briefcase will set the -``macOS.is_core_type`` key to ``False``, and will set the -``macOS.LSItemContentTypes`` to ``..`` as -the UTI for the document type. You can override this by providing your own -value, e.g. in the case where the document type is defined by *another app* and -you can look up the UTI. The ``macOS.LSHandlerRank`` will be set to ``Owner`` by -default, indicating that your app is the primary handler for that type. If you -want your app to be an alternate handler for the type, e.g. in the case that the -document type is defined by another app, you can set the ``macOS.LSHandlerRank`` -to ``Alternate``. Finally, the ``macOS.UTTypeConformsTo`` will be set to -``["public.data", "public.content"]`` by default, indicating that the document -type is a type of data document. If you define an image format, you can conform -to ``public.image``, which itself conforms to ``public.data`` and -``public.content``. Check the list of `standard UTIs defined by macOS -`_ -to find the appropriate UTI for your document type. + +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 +`_ +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 +`_ +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 `_ 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 ``..`` (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 +`_ +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 --------- +~~~~~~~~ -A special case is when the document type is a *package*. That is a directory -that contains files, but is treated as a single file by the operating system. -For example, the ``.app`` bundle is a package, as is the ``.pkg`` installer. If -you want to define such a document type, set ``macOS.UTTypeConformsTo`` to -``["com.apple.package", "public.content"]``, optionally including other UTIs -like ``public.image``. +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. Permissions =========== From 7131e6e44271bba6bfcdf12aeb89c0bded0371d7 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 13 Jun 2025 15:03:58 +0800 Subject: [PATCH 31/35] Small cleanup of logic in macOS doctype processing. --- src/briefcase/config.py | 40 ++++++++++++++++------------------------ 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/src/briefcase/config.py b/src/briefcase/config.py index d87f2599a..09f82fedd 100644 --- a/src/briefcase/config.py +++ b/src/briefcase/config.py @@ -154,16 +154,30 @@ def validate_document_type_config(document_type_id, document_type): 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 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("LSItemContentTypes", [uti]) macOS.setdefault("LSHandlerRank", "Alternate") else: # LSItemContentTypes will default to bundle.app_name.document_type_id @@ -173,28 +187,6 @@ def validate_document_type_config(document_type_id, document_type): macOS.setdefault("UTTypeConformsTo", ["public.data", "public.content"]) macOS.setdefault("CFBundleTypeRole", "Viewer") - - content_types = macOS.get("LSItemContentTypes", 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. - """ - ) - else: - # This is basically a no-op to satisfy coverage checkers - content_types = "is a list with a single value" - elif isinstance(content_types, str): - # If the content type is a string, convert it to a list - macOS["LSItemContentTypes"] = [content_types] - else: - # This is basically a no-op to satisfy coverage checkers - content_types = "is None or something unexpected" else: # pragma: no-cover-if-is-macos pass From bae3210a7eb97159803c6f97ca22e7f2ae475ebc Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 13 Jun 2025 15:04:54 +0800 Subject: [PATCH 32/35] Cleanup of test cases. --- tests/config/test_AppConfig.py | 6 +++--- tests/config/test_is_uti_core_type.py | 18 ++++++++++++------ tests/config/test_mime_type_to_uti.py | 18 ++++++++++++------ 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/tests/config/test_AppConfig.py b/tests/config/test_AppConfig.py index 4666f92f2..9afb45295 100644 --- a/tests/config/test_AppConfig.py +++ b/tests/config/test_AppConfig.py @@ -62,7 +62,7 @@ def test_extra_attrs(): "extension": "doc", "description": "A document", "url": "https://testurl.com", - "mime_type": "text/x.my-doc-type", + "mime_type": "application/x-my-doc-type", } }, first="value 1", @@ -89,7 +89,7 @@ def test_extra_attrs(): "extension": "doc", "description": "A document", "url": "https://testurl.com", - "mime_type": "text/x.my-doc-type", + "mime_type": "application/x-my-doc-type", "macOS": { "CFBundleTypeRole": "Viewer", "LSHandlerRank": "Owner", @@ -108,7 +108,7 @@ def test_extra_attrs(): "extension": "doc", "description": "A document", "url": "https://testurl.com", - "mime_type": "text/x.my-doc-type", + "mime_type": "application/x-my-doc-type", } } diff --git a/tests/config/test_is_uti_core_type.py b/tests/config/test_is_uti_core_type.py index 62b865dbf..df0e9dcc0 100644 --- a/tests/config/test_is_uti_core_type.py +++ b/tests/config/test_is_uti_core_type.py @@ -6,13 +6,19 @@ @pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS") -def test_is_uti_core_type(): +@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(None) is False - assert utils.is_uti_core_type("public.data") is True - assert utils.is_uti_core_type("public.content") is True - assert utils.is_uti_core_type("com.adobe.pdf") is True - assert utils.is_uti_core_type("com.unknown.data") is False + assert utils.is_uti_core_type(uti) == result @pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS") diff --git a/tests/config/test_mime_type_to_uti.py b/tests/config/test_mime_type_to_uti.py index e2732f620..897ab928b 100644 --- a/tests/config/test_mime_type_to_uti.py +++ b/tests/config/test_mime_type_to_uti.py @@ -6,13 +6,19 @@ @pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS") -def test_mime_type_to_uti(): +@pytest.mark.parametrize( + "mime_type, uti", + [ + (None, None), + ("application/pdf", "com.adobe.pdf"), + ("text/plain", "public.plain-text"), + ("image/png", "public.png"), + ("application/unknown", None), + ], +) +def test_mime_type_to_uti(mime_type, uti): """Check if a MIME type can be converted to a UTI.""" - assert utils.mime_type_to_uti(None) is None - assert utils.mime_type_to_uti("application/pdf") == "com.adobe.pdf" - assert utils.mime_type_to_uti("text/plain") == "public.plain-text" - assert utils.mime_type_to_uti("image/png") == "public.png" - assert utils.mime_type_to_uti("application/unknown") is None + assert utils.mime_type_to_uti(mime_type) == uti @pytest.mark.skipif(sys.platform != "darwin", reason="Test runs only on macOS") From d51b53b06d920a4239a04278cd00230db459f237 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 13 Jun 2025 15:05:04 +0800 Subject: [PATCH 33/35] Tweak release note. --- changes/2284.feature.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/2284.feature.rst b/changes/2284.feature.rst index e5200bed8..7fd38a45b 100644 --- a/changes/2284.feature.rst +++ b/changes/2284.feature.rst @@ -1 +1 @@ -Improved document types and file associations for macOS platforms. +Document type and file associations have been improved on macOS. From 63f594ffd5e6df255b93a78b1363c6ee91c35987 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 13 Jun 2025 15:12:45 +0800 Subject: [PATCH 34/35] Restore some additional reading links on macOS document types. --- docs/reference/platforms/macOS/index.rst | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/reference/platforms/macOS/index.rst b/docs/reference/platforms/macOS/index.rst index aa62e6472..47c89f5c2 100644 --- a/docs/reference/platforms/macOS/index.rst +++ b/docs/reference/platforms/macOS/index.rst @@ -240,6 +240,20 @@ 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 + `_ + * `Uniform Type Identifiers — a reintroduction + `_ + * `Core Foundation Keys (archived) + `_ + Permissions =========== From 21ee20d34cf6f08fa1f074eabd1e40119ee338bd Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 13 Jun 2025 15:19:57 +0800 Subject: [PATCH 35/35] Correct spelling. --- docs/reference/platforms/macOS/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/platforms/macOS/index.rst b/docs/reference/platforms/macOS/index.rst index 47c89f5c2..09482ccdd 100644 --- a/docs/reference/platforms/macOS/index.rst +++ b/docs/reference/platforms/macOS/index.rst @@ -223,7 +223,7 @@ the conforming types). ``macOS.is_core_type`` ---------------------- -A boolean, used to explicitly declare a content type as a core type. This flag is used +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.