Skip to content

Commit cc5c976

Browse files
authored
feat(mobile): add file association support (#14486)
1 parent 926a57b commit cc5c976

20 files changed

Lines changed: 635 additions & 187 deletions

File tree

.changes/file-association.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"tauri": minor:feat
3+
"tauri-build": minor:feat
4+
"tauri-plugin": minor:feat
5+
"tauri-cli": minor:feat
6+
"tauri-bundler": minor:feat
7+
---
8+
9+
Implement file association for Android and iOS.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"tauri": minor:feat
3+
"tauri-runtime": minor:feat
4+
"tauri-runtime-wry": minor:feat
5+
---
6+
7+
Trigger `RunEvent::Opened` on Android.

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/tauri-build/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,11 @@ pub fn try_build(attributes: Attributes) -> Result<()> {
497497

498498
if let Some(project_dir) = env::var_os("TAURI_ANDROID_PROJECT_PATH").map(PathBuf::from) {
499499
mobile::generate_gradle_files(project_dir)?;
500+
501+
// Update Android manifest with file associations
502+
if let Some(associations) = config.bundle.file_associations.as_ref() {
503+
mobile::update_android_manifest_file_associations(associations)?;
504+
}
500505
}
501506

502507
cfg_alias("dev", is_dev());

crates/tauri-build/src/mobile.rs

Lines changed: 139 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,147 @@
22
// SPDX-License-Identifier: Apache-2.0
33
// SPDX-License-Identifier: MIT
44

5-
use std::path::PathBuf;
5+
use std::{collections::HashSet, path::PathBuf};
66

77
use anyhow::{Context, Result};
8-
use tauri_utils::write_if_changed;
8+
use tauri_utils::{config::AndroidIntentAction, write_if_changed};
9+
10+
/// Updates the Android manifest to add file association intent filters
11+
pub fn update_android_manifest_file_associations(
12+
associations: &[tauri_utils::config::FileAssociation],
13+
) -> Result<()> {
14+
if associations.is_empty() {
15+
return Ok(());
16+
}
17+
18+
let intent_filters = generate_file_association_intent_filters(associations);
19+
tauri_utils::build::update_android_manifest("tauri-file-associations", "activity", intent_filters)
20+
}
21+
22+
fn generate_file_association_intent_filters(
23+
associations: &[tauri_utils::config::FileAssociation],
24+
) -> String {
25+
let mut filters = String::new();
26+
27+
for association in associations {
28+
// Get mime types - use explicit mime_type, or infer from extensions
29+
let mut mime_types = HashSet::new();
30+
31+
if let Some(mime_type) = &association.mime_type {
32+
mime_types.insert((
33+
mime_type.clone(),
34+
association.android_intent_action_filters.clone(),
35+
));
36+
} else {
37+
// Infer mime types from extensions
38+
for ext in &association.ext {
39+
if let Some(mime) = extension_to_mime_type(&ext.0) {
40+
mime_types.insert((mime, association.android_intent_action_filters.clone()));
41+
}
42+
}
43+
}
44+
45+
// If we have mime types, create intent filters
46+
if !mime_types.is_empty() {
47+
for (mime_type, actions) in &mime_types {
48+
filters.push_str("<intent-filter>\n");
49+
if let Some(actions) = actions {
50+
for action in actions {
51+
let action = match action {
52+
AndroidIntentAction::Send => "SEND",
53+
AndroidIntentAction::SendMultiple => "SEND_MULTIPLE",
54+
AndroidIntentAction::View => "VIEW",
55+
_ => unimplemented!(),
56+
};
57+
filters.push_str(&format!(
58+
" <action android:name=\"android.intent.action.{action}\" />\n"
59+
));
60+
}
61+
} else {
62+
filters.push_str(" <action android:name=\"android.intent.action.SEND\" />\n");
63+
filters.push_str(" <action android:name=\"android.intent.action.SEND_MULTIPLE\" />\n");
64+
filters.push_str(" <action android:name=\"android.intent.action.VIEW\" />\n");
65+
}
66+
filters.push_str(" <category android:name=\"android.intent.category.DEFAULT\" />\n");
67+
filters.push_str(" <category android:name=\"android.intent.category.BROWSABLE\" />\n");
68+
filters.push_str(&format!(
69+
" <data android:mimeType=\"{}\" />\n",
70+
mime_type
71+
));
72+
73+
// Add file scheme and path patterns for extensions
74+
if !association.ext.is_empty() {
75+
// Create path patterns for each extension
76+
// Android's pathPattern needs \\. (double backslash-dot) in XML to match a literal dot
77+
let path_patterns: Vec<String> = association
78+
.ext
79+
.iter()
80+
.map(|ext| format!(".*\\\\.{}", ext.0))
81+
.collect();
82+
83+
for pattern in &path_patterns {
84+
filters.push_str(&format!(
85+
" <data android:pathPattern=\"{}\" />\n",
86+
pattern
87+
));
88+
}
89+
}
90+
91+
filters.push_str("</intent-filter>\n");
92+
}
93+
} else if !association.ext.is_empty() {
94+
// If no mime type but we have extensions, use a generic approach
95+
filters.push_str("<intent-filter>\n");
96+
filters.push_str(" <action android:name=\"android.intent.action.VIEW\" />\n");
97+
filters.push_str(" <category android:name=\"android.intent.category.DEFAULT\" />\n");
98+
filters.push_str(" <category android:name=\"android.intent.category.BROWSABLE\" />\n");
99+
100+
for ext in &association.ext {
101+
// Android's pathPattern needs \\. (double backslash-dot) in XML to match a literal dot
102+
filters.push_str(&format!(
103+
" <data android:pathPattern=\".*\\\\.{}\" />\n",
104+
ext.0
105+
));
106+
}
107+
108+
filters.push_str("</intent-filter>\n");
109+
}
110+
}
111+
112+
filters
113+
}
114+
115+
fn extension_to_mime_type(ext: &str) -> Option<String> {
116+
Some(
117+
match ext.to_lowercase().as_str() {
118+
"png" => "image/png",
119+
"jpg" | "jpeg" => "image/jpeg",
120+
"gif" => "image/gif",
121+
"bmp" => "image/bmp",
122+
"webp" => "image/webp",
123+
"svg" => "image/svg+xml",
124+
"ico" => "image/x-icon",
125+
"tiff" | "tif" => "image/tiff",
126+
"heic" | "heif" => "image/heic",
127+
"mp4" => "video/mp4",
128+
"mov" => "video/quicktime",
129+
"avi" => "video/x-msvideo",
130+
"mkv" => "video/x-matroska",
131+
"mp3" => "audio/mpeg",
132+
"wav" => "audio/wav",
133+
"aac" => "audio/aac",
134+
"m4a" => "audio/mp4",
135+
"pdf" => "application/pdf",
136+
"txt" => "text/plain",
137+
"html" | "htm" => "text/html",
138+
"json" => "application/json",
139+
"xml" => "application/xml",
140+
"rtf" => "application/rtf",
141+
_ => return None,
142+
}
143+
.to_string(),
144+
)
145+
}
9146

10147
pub fn generate_gradle_files(project_dir: PathBuf) -> Result<()> {
11148
let gradle_settings_path = project_dir.join("tauri.settings.gradle");

crates/tauri-bundler/src/bundle/macos/app.rs

Lines changed: 8 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -262,102 +262,15 @@ fn create_info_plist(
262262
}
263263

264264
if let Some(associations) = settings.file_associations() {
265-
let exported_associations = associations
266-
.iter()
267-
.filter_map(|association| {
268-
association.exported_type.as_ref().map(|exported_type| {
269-
let mut dict = plist::Dictionary::new();
270-
271-
dict.insert(
272-
"UTTypeIdentifier".into(),
273-
exported_type.identifier.clone().into(),
274-
);
275-
if let Some(description) = &association.description {
276-
dict.insert("UTTypeDescription".into(), description.clone().into());
277-
}
278-
if let Some(conforms_to) = &exported_type.conforms_to {
279-
dict.insert(
280-
"UTTypeConformsTo".into(),
281-
plist::Value::Array(conforms_to.iter().map(|s| s.clone().into()).collect()),
282-
);
283-
}
284-
285-
let mut specification = plist::Dictionary::new();
286-
specification.insert(
287-
"public.filename-extension".into(),
288-
plist::Value::Array(
289-
association
290-
.ext
291-
.iter()
292-
.map(|s| s.to_string().into())
293-
.collect(),
294-
),
295-
);
296-
if let Some(mime_type) = &association.mime_type {
297-
specification.insert("public.mime-type".into(), mime_type.clone().into());
298-
}
299-
300-
dict.insert("UTTypeTagSpecification".into(), specification.into());
301-
302-
plist::Value::Dictionary(dict)
303-
})
304-
})
305-
.collect::<Vec<_>>();
306-
307-
if !exported_associations.is_empty() {
308-
plist.insert(
309-
"UTExportedTypeDeclarations".into(),
310-
plist::Value::Array(exported_associations),
311-
);
265+
if let Some(file_associations_plist) =
266+
tauri_utils::config::file_associations_plist(associations)
267+
{
268+
if let Some(plist_dict) = file_associations_plist.as_dictionary() {
269+
for (key, value) in plist_dict {
270+
plist.insert(key.clone(), value.clone());
271+
}
272+
}
312273
}
313-
314-
plist.insert(
315-
"CFBundleDocumentTypes".into(),
316-
plist::Value::Array(
317-
associations
318-
.iter()
319-
.map(|association| {
320-
let mut dict = plist::Dictionary::new();
321-
322-
if !association.ext.is_empty() {
323-
dict.insert(
324-
"CFBundleTypeExtensions".into(),
325-
plist::Value::Array(
326-
association
327-
.ext
328-
.iter()
329-
.map(|ext| ext.to_string().into())
330-
.collect(),
331-
),
332-
);
333-
}
334-
335-
if let Some(content_types) = &association.content_types {
336-
dict.insert(
337-
"LSItemContentTypes".into(),
338-
plist::Value::Array(content_types.iter().map(|s| s.to_string().into()).collect()),
339-
);
340-
}
341-
342-
dict.insert(
343-
"CFBundleTypeName".into(),
344-
association
345-
.name
346-
.as_ref()
347-
.unwrap_or(&association.ext[0].0)
348-
.to_string()
349-
.into(),
350-
);
351-
dict.insert(
352-
"CFBundleTypeRole".into(),
353-
association.role.to_string().into(),
354-
);
355-
dict.insert("LSHandlerRank".into(), association.rank.to_string().into());
356-
plist::Value::Dictionary(dict)
357-
})
358-
.collect(),
359-
),
360-
);
361274
}
362275

363276
if let Some(path) = bundle_icon_file {

crates/tauri-cli/config.schema.json

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2517,6 +2517,16 @@
25172517
"type": "null"
25182518
}
25192519
]
2520+
},
2521+
"androidIntentActionFilters": {
2522+
"description": "Intent action filters for this file association.\n\n By default all filters are used.",
2523+
"type": [
2524+
"array",
2525+
"null"
2526+
],
2527+
"items": {
2528+
"$ref": "#/definitions/AndroidIntentAction"
2529+
}
25202530
}
25212531
},
25222532
"additionalProperties": false
@@ -2622,6 +2632,32 @@
26222632
},
26232633
"additionalProperties": false
26242634
},
2635+
"AndroidIntentAction": {
2636+
"description": "Android intent action.",
2637+
"oneOf": [
2638+
{
2639+
"description": "ACTION_SEND.\n\n <https://developer.android.com/reference/android/content/Intent#ACTION_SEND>",
2640+
"type": "string",
2641+
"enum": [
2642+
"send"
2643+
]
2644+
},
2645+
{
2646+
"description": "ACTION_SEND_MULTIPLE.\n\n <https://developer.android.com/reference/android/content/Intent#ACTION_SEND_MULTIPLE>",
2647+
"type": "string",
2648+
"enum": [
2649+
"sendMultiple"
2650+
]
2651+
},
2652+
{
2653+
"description": "ACTION_VIEW.\n\n <https://developer.android.com/reference/android/content/Intent#ACTION_SEND>",
2654+
"type": "string",
2655+
"enum": [
2656+
"view"
2657+
]
2658+
}
2659+
]
2660+
},
26252661
"WindowsConfig": {
26262662
"description": "Windows bundler configuration.\n\n See more: <https://v2.tauri.app/reference/config/#windowsconfig>",
26272663
"type": "object",

crates/tauri-cli/src/mobile/ios/build.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,8 +243,15 @@ pub fn run(options: Options, noise_level: NoiseLevel, dirs: &Dirs) -> Result<Bui
243243
if dirs.tauri.join("Info.ios.plist").exists() {
244244
src_plists.push(dirs.tauri.join("Info.ios.plist").into());
245245
}
246-
if let Some(info_plist) = &tauri_config.bundle.ios.info_plist {
247-
src_plists.push(info_plist.clone().into());
246+
{
247+
if let Some(info_plist) = &tauri_config.bundle.ios.info_plist {
248+
src_plists.push(info_plist.clone().into());
249+
}
250+
if let Some(associations) = tauri_config.bundle.file_associations.as_ref() {
251+
if let Some(file_associations) = tauri_utils::config::file_associations_plist(associations) {
252+
src_plists.push(file_associations.into());
253+
}
254+
}
248255
}
249256
let merged_info_plist = merge_plist(src_plists)?;
250257
merged_info_plist

crates/tauri-cli/src/mobile/ios/dev.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,8 +230,15 @@ fn run_command(options: Options, noise_level: NoiseLevel, dirs: Dirs) -> Result<
230230
if dirs.tauri.join("Info.ios.plist").exists() {
231231
src_plists.push(dirs.tauri.join("Info.ios.plist").into());
232232
}
233-
if let Some(info_plist) = &tauri_config.bundle.ios.info_plist {
234-
src_plists.push(info_plist.clone().into());
233+
{
234+
if let Some(info_plist) = &tauri_config.bundle.ios.info_plist {
235+
src_plists.push(info_plist.clone().into());
236+
}
237+
if let Some(associations) = tauri_config.bundle.file_associations.as_ref() {
238+
if let Some(file_associations) = tauri_utils::config::file_associations_plist(associations) {
239+
src_plists.push(file_associations.into());
240+
}
241+
}
235242
}
236243
let merged_info_plist = merge_plist(src_plists)?;
237244
merged_info_plist

0 commit comments

Comments
 (0)