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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/2284.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added improved document types (file associations) for Windows and macOS platforms
Comment thread
davidfokkema marked this conversation as resolved.
Outdated
38 changes: 38 additions & 0 deletions src/briefcase/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,44 @@ def validate_document_type_config(document_type_id, document_type):
f"The URL associated with document type {document_type_id!r} is invalid: {e}"
)

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

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("LSItemContentTypes", uti)
macOS.setdefault("LSHandlerRank", "Alternate")
Comment thread
freakboy3742 marked this conversation as resolved.
Outdated
else:
# LSItemContentTypes will default to bundle.app_name.document_type_id
# in the Info.plist template if it is not provided.
macOS.setdefault("is_core_type", False)
macOS.setdefault("LSHandlerRank", "Owner")
macOS.setdefault("UTTypeConformsTo", ["public.data", "public.content"])

macOS.setdefault("CFBundleTypeRole", "Viewer")

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]
Comment thread
davidfokkema marked this conversation as resolved.
Outdated
else:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These no-op branches can be cleaned up if the content type handling is performed before the UTI handling - I'll push an update to do this.

# This is basically a no-op to satisfy coverage checkers
content_types = "string or None"
else: # pragma: no-cover-if-is-macos
pass


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

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

from briefcase.exceptions import BriefcaseCommandError

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


def sha256_file_digest(path: Path) -> str:
"""Compute a sha256 checksum digest of a file.
Expand Down Expand Up @@ -272,3 +276,52 @@ 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: # pragma: no-cover-if-not-macos
"""Convert a MIME type to a Uniform Type Identifier (UTI).

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

Args:
mime_type: The MIME type to convert.

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

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

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

import pytest

from briefcase.config import AppConfig
Expand Down Expand Up @@ -78,14 +80,34 @@ def test_extra_attrs():
# Properties that are derived by default have been set explicitly
assert config.formal_name == "My App!"
assert config.class_name == "MyApp"
assert config.document_types == {
"document": {
"icon": "icon",
"extension": "doc",
"description": "A document",
"url": "https://testurl.com",

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

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

import pytest

from briefcase.config import validate_document_type_config
from briefcase.exceptions import BriefcaseConfigError
from briefcase.platforms.macOS import utils


@pytest.fixture
Expand Down Expand Up @@ -160,3 +163,80 @@ 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)


@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.

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 "LSItemContentTypes" in valid_document["macOS"].keys()
assert valid_document["macOS"]["LSItemContentTypes"] == "com.adobe.pdf"
Comment thread
davidfokkema marked this conversation as resolved.
Outdated


@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.

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 "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")
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 LSItemContentTypes should _not_ be set.
"""
valid_document["mime_type"] = "custom/mytype"
validate_document_type_config("ext", valid_document)
assert "LSItemContentTypes" 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")
assert utils.mime_type_to_UTI("application/pdf") is None

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For consistency, this should either (a) be test at the validate_document_type_config() level (i.e., a reproduction of the actual application/pdf case, but with the "couldn't find UTI data" response, or a separate test in the macOS module that does a couple of superficial direct checks of mime_type_to_UTI().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I follow. Could you elaborate, please?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All the other tests in this file are based on invoking validate_document_type_config(); but this test is invoking utils.mime_type_to_UTI. The test suite is broadly organised by test function; a test of mime_type_to_UTI should either be in a test file for that method; or the entry point for for this specific test should be modified to use validate_document_type_config() (i.e., testing the same thing, but doing so by setting up test conditions that will result in performing a mime type lookup).

To that end - this test file should also be called test_validate_document_type_config.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've reshuffled the tests. Renamed test_document_type_config.py to test_validate_document_type_config.py and split out test_is_uti_core_type.py and test_mime_type_to_uti.py, and the first test file no longer depends on the briefcase.platforms.macOS.utils module.



@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"
Comment thread
davidfokkema marked this conversation as resolved.
Outdated

valid_document["macOS"]["LSItemContentTypes"] = ["com.adobe.pdf"]
validate_document_type_config("ext", valid_document)
assert valid_document["macOS"]["LSItemContentTypes"] == "com.adobe.pdf"