Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions .changes/file-association-content-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"tauri-cli": minor:feat
"@tauri-apps/cli": minor:feat
---

Added support to defining the content type of the declared file association on macOS (maps to LSItemContentTypes property).
6 changes: 6 additions & 0 deletions .changes/file-association-exported-type-cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"tauri-cli": minor:feat
"@tauri-apps/cli": minor:feat
---

Added support to defining the metadata for custom types declared in `tauri.conf.json > bundle > fileAssociations > exportedType` via the `UTExportedTypeDeclarations` Info.plist property.
5 changes: 5 additions & 0 deletions .changes/file-association-exported-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"tauri-utils": minor:feat
---

Added `FileAssociation::exported_type` and `FileAssociation::content_types` for better support to defining custom types on macOS.
82 changes: 71 additions & 11 deletions crates/tauri-bundler/src/bundle/macos/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,29 +268,89 @@ fn create_info_plist(
}

if let Some(associations) = settings.file_associations() {
let exported_associations = associations
.iter()
.filter_map(|association| {
association.exported_type.as_ref().map(|exported_type| {
let mut dict = plist::Dictionary::new();

dict.insert(
"UTTypeIdentifier".into(),
exported_type.identifier.clone().into(),
);
if let Some(description) = &association.description {
dict.insert("UTTypeDescription".into(), description.clone().into());
}
if let Some(content_types) = &association.content_types {
dict.insert(
"UTTypeConformsTo".into(),
plist::Value::Array(content_types.iter().map(|s| s.clone().into()).collect()),
);
}
Comment on lines +284 to +289
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚨 Bug: Wrong field used for UTTypeConformsTo in exported type

In the UTExportedTypeDeclarations generation (lines 284-289), the code uses association.content_types to populate UTTypeConformsTo. However, content_types maps to LSItemContentTypes and represents what content types the app handles. The correct field is exported_type.conforms_to, which is the dedicated field on ExportedFileAssociation for this purpose.

The ExportedFileAssociation struct has a conforms_to: Option<Vec<String>> field (line 1216 in config.rs) that is never read anywhere in the bundler code. Meanwhile, the example config in tauri.conf.json puts conformsTo inside exportedType, confirming it should be exported_type.conforms_to.

Impact: The UTTypeConformsTo plist entry will never be generated correctly. Even when conformsTo is configured in the exported type, it is completely ignored. If contentTypes happens to be set on the association, its values will be incorrectly used as conformance declarations.

Was this helpful? React with 👍 / 👎

Suggested change
if let Some(content_types) = &association.content_types {
dict.insert(
"UTTypeConformsTo".into(),
plist::Value::Array(content_types.iter().map(|s| s.clone().into()).collect()),
);
}
if let Some(conforms_to) = &exported_type.conforms_to {
dict.insert(
"UTTypeConformsTo".into(),
plist::Value::Array(conforms_to.iter().map(|s| s.clone().into()).collect()),
);
}
  • Apply suggested fix


let mut specification = plist::Dictionary::new();
specification.insert(
"public.filename-extension".into(),
plist::Value::Array(
association
.ext
.iter()
.map(|s| s.to_string().into())
.collect(),
),
);
if let Some(mime_type) = &association.mime_type {
specification.insert("public.mime-type".into(), mime_type.clone().into());
}

dict.insert("UTTypeTagSpecification".into(), specification.into());

plist::Value::Dictionary(dict)
})
})
.collect::<Vec<_>>();

if !exported_associations.is_empty() {
plist.insert(
"UTExportedTypeDeclarations".into(),
plist::Value::Array(exported_associations),
);
}

plist.insert(
"CFBundleDocumentTypes".into(),
plist::Value::Array(
associations
.iter()
.map(|association| {
let mut dict = plist::Dictionary::new();
dict.insert(
"CFBundleTypeExtensions".into(),
plist::Value::Array(
association
.ext
.iter()
.map(|ext| ext.to_string().into())
.collect(),
),
);

if association.ext.is_empty() {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚨 Bug: Inverted condition: CFBundleTypeExtensions added only when empty

The condition on line 328 is if association.ext.is_empty() which inserts CFBundleTypeExtensions only when the extensions list is empty — yielding an empty array. When extensions are present (the common case), they are silently omitted from the plist.

This is the exact opposite of the intended behavior. The original code unconditionally inserted the extensions. The new code was meant to make it conditional (skip when empty), but the negation is missing.

Impact: All macOS file associations with extensions will be broken — CFBundleTypeExtensions will never appear in the generated Info.plist for associations that actually have extensions, meaning macOS won't associate those file types with the application.

Was this helpful? React with 👍 / 👎

Suggested change
if association.ext.is_empty() {
if !association.ext.is_empty() {
  • Apply suggested fix

dict.insert(
"CFBundleTypeExtensions".into(),
plist::Value::Array(
association
.ext
.iter()
.map(|ext| ext.to_string().into())
.collect(),
),
);
}

if let Some(content_types) = &association.content_types {
dict.insert(
"LSItemContentTypes".into(),
plist::Value::Array(content_types.iter().map(|s| s.to_string().into()).collect()),
);
}

dict.insert(
"CFBundleTypeName".into(),
association
.name
.as_ref()
.unwrap_or(&association.ext[0].0)
.expect("File association must have a name")
.to_string()
.into(),
);
Comment on lines 348 to 356
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚨 Bug: Panic when file association has no name (was a fallback)

Line 353 changed from .unwrap_or(&association.ext[0].0) (a graceful fallback to the first extension) to .expect("File association must have a name") (a panic). The name field is Option<String> and its doc comment says "Default to ext[0]", indicating it was designed to be optional.

The new example entries added in this very PR (taurijson and taurid in tauri.conf.json) don't specify a name field, so this code will panic at runtime when processing those associations.

Impact: Building any macOS bundle with a file association that omits the name field will crash. This includes the example added in this PR.

Was this helpful? React with 👍 / 👎

Suggested change
dict.insert(
"CFBundleTypeName".into(),
association
.name
.as_ref()
.unwrap_or(&association.ext[0].0)
.expect("File association must have a name")
.to_string()
.into(),
);
dict.insert(
"CFBundleTypeName".into(),
association
.name
.as_ref()
.or(association.ext.first().map(|e| &e.0))
.cloned()
.unwrap_or_default()
.into(),
);
  • Apply suggested fix

Expand Down
47 changes: 46 additions & 1 deletion crates/tauri-cli/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2159,7 +2159,7 @@
]
},
"fileAssociations": {
"description": "File associations to application.",
"description": "File types to associate with the application.",
"type": [
"array",
"null"
Expand Down Expand Up @@ -2433,6 +2433,16 @@
"$ref": "#/definitions/AssociationExt"
}
},
"contentTypes": {
"description": "Declare support to a file with the given content type. Maps to `LSItemContentTypes` on macOS.\n\n This allows supporting any file format declared by another application that conforms to this type.\n Declaration of new types can be done with [`Self::exported_type`] and linking to certain content types are done via [`ExportedFileAssociation::conforms_to`].",
"type": [
"array",
"null"
],
"items": {
"type": "string"
}
},
"name": {
"description": "The name. Maps to `CFBundleTypeName` on macOS. Default to `ext[0]`",
"type": [
Expand Down Expand Up @@ -2471,6 +2481,17 @@
"$ref": "#/definitions/HandlerRank"
}
]
},
"exportedType": {
"description": "The exported type definition. Maps to a `UTExportedTypeDeclarations` entry on macOS.\n\n You should define this if the associated file is a custom file type defined by your application.",
"anyOf": [
{
"$ref": "#/definitions/ExportedFileAssociation"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
Expand Down Expand Up @@ -2552,6 +2573,30 @@
}
]
},
"ExportedFileAssociation": {
"description": "The exported type definition. Maps to a `UTExportedTypeDeclarations` entry on macOS.",
"type": "object",
"required": [
"identifier"
],
"properties": {
"identifier": {
"description": "The unique identifier for the exported type. Maps to `UTTypeIdentifier`.",
"type": "string"
},
"conformsTo": {
"description": "The types that this type conforms to. Maps to `UTTypeConformsTo`.\n\n Examples are `public.data`, `public.image`, `public.json` and `public.database`.",
"type": [
"array",
"null"
],
"items": {
"type": "string"
}
}
},
"additionalProperties": false
},
"WindowsConfig": {
"description": "Windows bundler configuration.\n\n See more: <https://v2.tauri.app/reference/config/#windowsconfig>",
"type": "object",
Expand Down
47 changes: 46 additions & 1 deletion crates/tauri-schema-generator/schemas/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2159,7 +2159,7 @@
]
},
"fileAssociations": {
"description": "File associations to application.",
"description": "File types to associate with the application.",
"type": [
"array",
"null"
Expand Down Expand Up @@ -2433,6 +2433,16 @@
"$ref": "#/definitions/AssociationExt"
}
},
"contentTypes": {
"description": "Declare support to a file with the given content type. Maps to `LSItemContentTypes` on macOS.\n\n This allows supporting any file format declared by another application that conforms to this type.\n Declaration of new types can be done with [`Self::exported_type`] and linking to certain content types are done via [`ExportedFileAssociation::conforms_to`].",
"type": [
"array",
"null"
],
"items": {
"type": "string"
}
},
"name": {
"description": "The name. Maps to `CFBundleTypeName` on macOS. Default to `ext[0]`",
"type": [
Expand Down Expand Up @@ -2471,6 +2481,17 @@
"$ref": "#/definitions/HandlerRank"
}
]
},
"exportedType": {
"description": "The exported type definition. Maps to a `UTExportedTypeDeclarations` entry on macOS.\n\n You should define this if the associated file is a custom file type defined by your application.",
"anyOf": [
{
"$ref": "#/definitions/ExportedFileAssociation"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
Expand Down Expand Up @@ -2552,6 +2573,30 @@
}
]
},
"ExportedFileAssociation": {
"description": "The exported type definition. Maps to a `UTExportedTypeDeclarations` entry on macOS.",
"type": "object",
"required": [
"identifier"
],
"properties": {
"identifier": {
"description": "The unique identifier for the exported type. Maps to `UTTypeIdentifier`.",
"type": "string"
},
"conformsTo": {
"description": "The types that this type conforms to. Maps to `UTTypeConformsTo`.\n\n Examples are `public.data`, `public.image`, `public.json` and `public.database`.",
"type": [
"array",
"null"
],
"items": {
"type": "string"
}
}
},
"additionalProperties": false
},
"WindowsConfig": {
"description": "Windows bundler configuration.\n\n See more: <https://v2.tauri.app/reference/config/#windowsconfig>",
"type": "object",
Expand Down
26 changes: 25 additions & 1 deletion crates/tauri-utils/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1177,6 +1177,12 @@ impl<'d> serde::Deserialize<'d> for AssociationExt {
pub struct FileAssociation {
/// File extensions to associate with this app. e.g. 'png'
pub ext: Vec<AssociationExt>,
/// Declare support to a file with the given content type. Maps to `LSItemContentTypes` on macOS.
///
/// This allows supporting any file format declared by another application that conforms to this type.
/// Declaration of new types can be done with [`Self::exported_type`] and linking to certain content types are done via [`ExportedFileAssociation::conforms_to`].
#[serde(alias = "content-types")]
pub content_types: Option<Vec<String>>,
/// The name. Maps to `CFBundleTypeName` on macOS. Default to `ext[0]`
pub name: Option<String>,
/// The association description. Windows-only. It is displayed on the `Type` column on Windows Explorer.
Expand All @@ -1190,6 +1196,24 @@ pub struct FileAssociation {
/// The ranking of this app among apps that declare themselves as editors or viewers of the given file type. Maps to `LSHandlerRank` on macOS.
#[serde(default)]
pub rank: HandlerRank,
/// The exported type definition. Maps to a `UTExportedTypeDeclarations` entry on macOS.
///
/// You should define this if the associated file is a custom file type defined by your application.
pub exported_type: Option<ExportedFileAssociation>,
}

/// The exported type definition. Maps to a `UTExportedTypeDeclarations` entry on macOS.
#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ExportedFileAssociation {
/// The unique identifier for the exported type. Maps to `UTTypeIdentifier`.
pub identifier: String,
/// The types that this type conforms to. Maps to `UTTypeConformsTo`.
///
/// Examples are `public.data`, `public.image`, `public.json` and `public.database`.
#[serde(alias = "conforms-to")]
pub conforms_to: Option<Vec<String>>,
}

/// Deep link protocol configuration.
Expand Down Expand Up @@ -1356,7 +1380,7 @@ pub struct BundleConfig {
/// Should be one of the following:
/// Business, DeveloperTool, Education, Entertainment, Finance, Game, ActionGame, AdventureGame, ArcadeGame, BoardGame, CardGame, CasinoGame, DiceGame, EducationalGame, FamilyGame, KidsGame, MusicGame, PuzzleGame, RacingGame, RolePlayingGame, SimulationGame, SportsGame, StrategyGame, TriviaGame, WordGame, GraphicsAndDesign, HealthcareAndFitness, Lifestyle, Medical, Music, News, Photography, Productivity, Reference, SocialNetworking, Sports, Travel, Utility, Video, Weather.
pub category: Option<String>,
/// File associations to application.
/// File types to associate with the application.
pub file_associations: Option<Vec<FileAssociation>>,
/// A short description of your application.
#[serde(alias = "short-description")]
Expand Down
6 changes: 6 additions & 0 deletions examples/file-associations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,9 @@ This feature is commonly used for functionality such as previewing or editing fi
```
cargo build --features tauri/protocol-asset
```

## Associations

This example creates associations with PNG, JPG, JPEG and GIF files.

Additionally, it defines two new extensions - `taurid` (derives from a raw data file) and `taurijson` (derives from JSON). They have special treatment on macOS (see `exportedType` in `src-tauri/tauri.conf.json`).
2 changes: 1 addition & 1 deletion examples/file-associations/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@ tauri-build = { path = "../../../crates/tauri-build", features = ["codegen"] }
[dependencies]
serde_json = "1"
serde = { version = "1", features = ["derive"] }
tauri = { path = "../../../crates/tauri", features = [] }
tauri = { path = "../../../crates/tauri", features = ["protocol-asset"] }
url = "2"
21 changes: 19 additions & 2 deletions examples/file-associations/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
{
"$schema": "../../../crates/tauri-cli/schema.json",
"$schema": "../../../crates/tauri-cli/config.schema.json",
"identifier": "com.tauri.dev-file-associations-demo",
"build": {
"frontendDist": ["../index.html"]
},
"app": {
"security": {
"csp": "default-src 'self'"
"csp": "default-src 'self'",
"assetProtocol": {
"enable": true
}
}
},
"bundle": {
Expand Down Expand Up @@ -34,6 +37,20 @@
"ext": ["gif"],
"mimeType": "image/gif",
"rank": "Owner"
},
{
"ext": ["taurijson"],
"exportedType": {
"identifier": "com.tauri.dev-file-associations-demo.taurijson",
"conformsTo": ["public.json"]
}
},
{
"ext": ["taurid"],
"exportedType": {
"identifier": "com.tauri.dev-file-associations-demo.tauridata",
"conformsTo": ["public.data"]
}
}
]
}
Expand Down