Skip to content
Open
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.

⚠️ Potential issue | 🔴 Critical

Wrong source for UTTypeConformsTo — uses association.content_types instead of exported_type.conforms_to.

According to the ExportedFileAssociation struct definition, conforms_to is the field meant to populate UTTypeConformsTo. The current code incorrectly uses association.content_types, which is intended for LSItemContentTypes in CFBundleDocumentTypes.

🐛 Proposed fix
-          if let Some(content_types) = &association.content_types {
+          if let Some(conforms_to) = &exported_type.conforms_to {
             dict.insert(
               "UTTypeConformsTo".into(),
-              plist::Value::Array(content_types.iter().map(|s| s.clone().into()).collect()),
+              plist::Value::Array(conforms_to.iter().map(|s| s.clone().into()).collect()),
             );
           }
🤖 Prompt for AI Agents
In `@crates/tauri-bundler/src/bundle/macos/app.rs` around lines 284 - 289, The
code is populating UTTypeConformsTo with association.content_types but should
use the exported_type.conforms_to field; update the block that inserts
"UTTypeConformsTo" (in the function building the plist for CFBundleDocumentTypes
/ ExportedFileAssociation handling) to read from exported_type.conforms_to
instead of association.content_types, and leave LSItemContentTypes populated
from association.content_types as currently intended so each key uses the
correct source fields (UTTypeConformsTo <- exported_type.conforms_to,
LSItemContentTypes <- association.content_types).


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() {
dict.insert(
"CFBundleTypeExtensions".into(),
plist::Value::Array(
association
.ext
.iter()
.map(|ext| ext.to_string().into())
.collect(),
),
);
}
Comment on lines +328 to +339

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Inverted condition inserts an empty array when ext is empty.

The condition if association.ext.is_empty() causes CFBundleTypeExtensions to be inserted only when there are no extensions, resulting in an empty array. The logic should be inverted to insert extensions when they exist and when content_types is not provided (since LSItemContentTypes takes precedence).

🐛 Proposed fix
-            if association.ext.is_empty() {
+            if association.content_types.is_none() && !association.ext.is_empty() {
               dict.insert(
                 "CFBundleTypeExtensions".into(),
                 plist::Value::Array(
                   association
                     .ext
                     .iter()
                     .map(|ext| ext.to_string().into())
                     .collect(),
                 ),
               );
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if association.ext.is_empty() {
dict.insert(
"CFBundleTypeExtensions".into(),
plist::Value::Array(
association
.ext
.iter()
.map(|ext| ext.to_string().into())
.collect(),
),
);
}
if association.content_types.is_none() && !association.ext.is_empty() {
dict.insert(
"CFBundleTypeExtensions".into(),
plist::Value::Array(
association
.ext
.iter()
.map(|ext| ext.to_string().into())
.collect(),
),
);
}
🤖 Prompt for AI Agents
In `@crates/tauri-bundler/src/bundle/macos/app.rs` around lines 328 - 339, The
current check uses if association.ext.is_empty(), which inserts
CFBundleTypeExtensions only when there are no extensions; change the logic to
insert CFBundleTypeExtensions into dict only when association.ext is non-empty
and when association.content_types is not provided (LSItemContentTypes should
take precedence). Concretely, replace the condition around dict.insert(...) to
something like: check that association.ext is not empty (e.g.,
!association.ext.is_empty()) and that association.content_types is absent/empty
(e.g., association.content_types.is_empty() or
association.content_types.is_none()), then map association.ext into the
plist::Value::Array and insert "CFBundleTypeExtensions".


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.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, check the exact code at the mentioned lines
echo "=== Code at lines 348-356 ==="
sed -n '345,360p' crates/tauri-bundler/src/bundle/macos/app.rs

echo -e "\n=== File size check ==="
wc -l crates/tauri-bundler/src/bundle/macos/app.rs

Repository: agentic-review-benchmarks/tauri-combined-coderabbit

Length of output: 632


🏁 Script executed:

#!/bin/bash
# Find FileAssociation struct definition
echo "=== Searching for FileAssociation struct ==="
rg -n "struct FileAssociation" --type rust -A 10

Repository: agentic-review-benchmarks/tauri-combined-coderabbit

Length of output: 1417


🏁 Script executed:

#!/bin/bash
# Search for validation of file associations
echo "=== Searching for file_associations validation ==="
rg -n "file_associations" --type rust -B 3 -A 3 | head -100

Repository: agentic-review-benchmarks/tauri-combined-coderabbit

Length of output: 7702


🏁 Script executed:

#!/bin/bash
# Check for any validation or deserialization logic
echo "=== Searching for validation or required patterns ==="
rg -n "FileAssociation.*name|name.*required|validate.*file.*assoc" --type rust -i

Repository: agentic-review-benchmarks/tauri-combined-coderabbit

Length of output: 717


🏁 Script executed:

#!/bin/bash
# Search for where FileAssociation.name gets defaulted
echo "=== Searching for default name logic ==="
rg -n "ext\[0\]|FileAssociation.*default" --type rust -B 3 -A 3

Repository: agentic-review-benchmarks/tauri-combined-coderabbit

Length of output: 1070


🏁 Script executed:

#!/bin/bash
# Check the full FileAssociation struct definition
echo "=== Full FileAssociation struct ==="
sed -n '1177,1210p' crates/tauri-utils/src/config.rs

Repository: agentic-review-benchmarks/tauri-combined-coderabbit

Length of output: 2035


🏁 Script executed:

#!/bin/bash
# Look for post-processing or validation of FileAssociation
echo "=== Search for processing of file associations ==="
rg -n "association\.name|for.*association" --type rust crates/tauri-bundler/src/bundle/macos/app.rs -B 2 -A 2

Repository: agentic-review-benchmarks/tauri-combined-coderabbit

Length of output: 145


🏁 Script executed:

#!/bin/bash
# Check git history for this specific code section to see if it changed
echo "=== Git log for the app.rs file ==="
git log --oneline -n 20 crates/tauri-bundler/src/bundle/macos/app.rs

Repository: agentic-review-benchmarks/tauri-combined-coderabbit

Length of output: 149


🏁 Script executed:

#!/bin/bash
# Let's search more broadly in the macos app.rs file for how associations are handled
echo "=== Full context around line 348 in macos/app.rs ==="
sed -n '265,285p' crates/tauri-bundler/src/bundle/macos/app.rs

Repository: agentic-review-benchmarks/tauri-combined-coderabbit

Length of output: 947


🏁 Script executed:

#!/bin/bash
# Search for where the default to ext[0] might be applied
echo "=== Searching for ext field access with name ==="
rg -n "\.ext\[|ext\.get|\.name.*=|name.*=.*ext" --type rust -B 2 -A 2 | head -80

Repository: agentic-review-benchmarks/tauri-combined-coderabbit

Length of output: 6179


🏁 Script executed:

#!/bin/bash
# Check the entire block where associations are processed
echo "=== Full association processing in macos/app.rs ==="
sed -n '270,360p' crates/tauri-bundler/src/bundle/macos/app.rs

Repository: agentic-review-benchmarks/tauri-combined-coderabbit

Length of output: 3069


The .expect() on association.name will panic if not provided, despite struct documentation promising a default.

The FileAssociation.name field is Option<String> with documentation stating "Default to ext[0]", but no code applies this default. Either implement the documented default behavior (populate name from ext[0] during deserialization/validation) or enforce name as required at the config validation layer instead of relying on .expect() at runtime.

🤖 Prompt for AI Agents
In `@crates/tauri-bundler/src/bundle/macos/app.rs` around lines 348 - 356, The
code currently calls association.name.expect(...) when inserting
"CFBundleTypeName", which can panic; instead implement the documented default by
using FileAssociation.name when present or falling back to the first extension
in FileAssociation.ext when name is None, and if ext is empty return/propagate a
proper error rather than panicking. Update the insertion site that creates the
CFBundleTypeName (the dict.insert call in app.rs) to compute the name via
association.name or association.ext.get(0) and convert to String, and ensure the
surrounding function returns a Result (or otherwise surfaces an error) if
neither name nor a first ext exists so no .expect() is used.

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