Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .changes/file-association.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"tauri": minor:feat
"tauri-build": minor:feat
"tauri-plugin": minor:feat
"tauri-cli": minor:feat
"tauri-bundler": minor:feat
---

Implement file association for Android and iOS.
7 changes: 7 additions & 0 deletions .changes/mobile-file-associations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"tauri": minor:feat
"tauri-runtime": minor:feat
"tauri-runtime-wry": minor:feat
---

Trigger `RunEvent::Opened` on Android.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions crates/tauri-build/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,11 @@ pub fn try_build(attributes: Attributes) -> Result<()> {

if let Some(project_dir) = env::var_os("TAURI_ANDROID_PROJECT_PATH").map(PathBuf::from) {
mobile::generate_gradle_files(project_dir)?;

// Update Android manifest with file associations
if let Some(associations) = config.bundle.file_associations.as_ref() {
mobile::update_android_manifest_file_associations(associations)?;
}
}

cfg_alias("dev", is_dev());
Expand Down
141 changes: 139 additions & 2 deletions crates/tauri-build/src/mobile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,147 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT

use std::path::PathBuf;
use std::{collections::HashSet, path::PathBuf};

use anyhow::{Context, Result};
use tauri_utils::write_if_changed;
use tauri_utils::{config::AndroidIntentAction, write_if_changed};

/// Updates the Android manifest to add file association intent filters
pub fn update_android_manifest_file_associations(
associations: &[tauri_utils::config::FileAssociation],
) -> Result<()> {
if associations.is_empty() {
return Ok(());
}

let intent_filters = generate_file_association_intent_filters(associations);
tauri_utils::build::update_android_manifest("tauri-file-associations", "activity", intent_filters)
}

fn generate_file_association_intent_filters(
associations: &[tauri_utils::config::FileAssociation],
) -> String {
let mut filters = String::new();

for association in associations {
// Get mime types - use explicit mime_type, or infer from extensions
let mut mime_types = HashSet::new();

if let Some(mime_type) = &association.mime_type {
mime_types.insert((
mime_type.clone(),
association.android_intent_action_filters.clone(),
));
} else {
// Infer mime types from extensions
for ext in &association.ext {
if let Some(mime) = extension_to_mime_type(&ext.0) {
mime_types.insert((mime, association.android_intent_action_filters.clone()));
}
}
}

// If we have mime types, create intent filters
if !mime_types.is_empty() {
for (mime_type, actions) in &mime_types {
filters.push_str("<intent-filter>\n");
if let Some(actions) = actions {
for action in actions {
let action = match action {
AndroidIntentAction::Send => "SEND",
AndroidIntentAction::SendMultiple => "SEND_MULTIPLE",
AndroidIntentAction::View => "VIEW",
_ => unimplemented!(),
};
filters.push_str(&format!(
" <action android:name=\"android.intent.action.{action}\" />\n"
));
}
} else {
filters.push_str(" <action android:name=\"android.intent.action.SEND\" />\n");
filters.push_str(" <action android:name=\"android.intent.action.SEND_MULTIPLE\" />\n");
filters.push_str(" <action android:name=\"android.intent.action.VIEW\" />\n");
}
filters.push_str(" <category android:name=\"android.intent.category.DEFAULT\" />\n");
filters.push_str(" <category android:name=\"android.intent.category.BROWSABLE\" />\n");
filters.push_str(&format!(
" <data android:mimeType=\"{}\" />\n",
mime_type
));

// Add file scheme and path patterns for extensions
if !association.ext.is_empty() {
// Create path patterns for each extension
// Android's pathPattern needs \\. (double backslash-dot) in XML to match a literal dot
let path_patterns: Vec<String> = association
.ext
.iter()
.map(|ext| format!(".*\\\\.{}", ext.0))
.collect();

for pattern in &path_patterns {
filters.push_str(&format!(
" <data android:pathPattern=\"{}\" />\n",
pattern
));
}
}

filters.push_str("</intent-filter>\n");
}
} else if !association.ext.is_empty() {
// If no mime type but we have extensions, use a generic approach
filters.push_str("<intent-filter>\n");
filters.push_str(" <action android:name=\"android.intent.action.VIEW\" />\n");
filters.push_str(" <category android:name=\"android.intent.category.DEFAULT\" />\n");
filters.push_str(" <category android:name=\"android.intent.category.BROWSABLE\" />\n");

for ext in &association.ext {
// Android's pathPattern needs \\. (double backslash-dot) in XML to match a literal dot
filters.push_str(&format!(
" <data android:pathPattern=\".*\\\\.{}\" />\n",
ext.0
));
}

filters.push_str("</intent-filter>\n");
}
}

filters
}

fn extension_to_mime_type(ext: &str) -> Option<String> {
Some(
match ext.to_lowercase().as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"bmp" => "image/bmp",
"webp" => "image/webp",
"svg" => "image/svg+xml",
"ico" => "image/x-icon",
"tiff" | "tif" => "image/tiff",
"heic" | "heif" => "image/heic",
"mp4" => "video/mp4",
"mov" => "video/quicktime",
"avi" => "video/x-msvideo",
"mkv" => "video/x-matroska",
"mp3" => "audio/mpeg",
"wav" => "audio/wav",
"aac" => "audio/aac",
"m4a" => "audio/mp4",
"pdf" => "application/pdf",
"txt" => "text/plain",
"html" | "htm" => "text/html",
"json" => "application/json",
"xml" => "application/xml",
"rtf" => "application/rtf",
_ => return None,
}
.to_string(),
)
}

pub fn generate_gradle_files(project_dir: PathBuf) -> Result<()> {
let gradle_settings_path = project_dir.join("tauri.settings.gradle");
Expand Down
103 changes: 8 additions & 95 deletions crates/tauri-bundler/src/bundle/macos/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,102 +262,15 @@ 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(conforms_to) = &exported_type.conforms_to {
dict.insert(
"UTTypeConformsTo".into(),
plist::Value::Array(conforms_to.iter().map(|s| s.clone().into()).collect()),
);
}

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),
);
if let Some(file_associations_plist) =
tauri_utils::config::file_associations_plist(associations)
{
if let Some(plist_dict) = file_associations_plist.as_dictionary() {
for (key, value) in plist_dict {
plist.insert(key.clone(), value.clone());
}
}
}

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

if !association.ext.is_empty() {
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)
.to_string()
.into(),
);
dict.insert(
"CFBundleTypeRole".into(),
association.role.to_string().into(),
);
dict.insert("LSHandlerRank".into(), association.rank.to_string().into());
plist::Value::Dictionary(dict)
})
.collect(),
),
);
}

if let Some(path) = bundle_icon_file {
Expand Down
36 changes: 36 additions & 0 deletions crates/tauri-cli/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2517,6 +2517,16 @@
"type": "null"
}
]
},
"androidIntentActionFilters": {
"description": "Intent action filters for this file association.\n\n By default all filters are used.",
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/AndroidIntentAction"
}
}
},
"additionalProperties": false
Expand Down Expand Up @@ -2622,6 +2632,32 @@
},
"additionalProperties": false
},
"AndroidIntentAction": {
"description": "Android intent action.",
"oneOf": [
{
"description": "ACTION_SEND.\n\n <https://developer.android.com/reference/android/content/Intent#ACTION_SEND>",
"type": "string",
"enum": [
"send"
]
},
{
"description": "ACTION_SEND_MULTIPLE.\n\n <https://developer.android.com/reference/android/content/Intent#ACTION_SEND_MULTIPLE>",
"type": "string",
"enum": [
"sendMultiple"
]
},
{
"description": "ACTION_VIEW.\n\n <https://developer.android.com/reference/android/content/Intent#ACTION_SEND>",
"type": "string",
"enum": [
"view"
]
}
]
},
"WindowsConfig": {
"description": "Windows bundler configuration.\n\n See more: <https://v2.tauri.app/reference/config/#windowsconfig>",
"type": "object",
Expand Down
11 changes: 9 additions & 2 deletions crates/tauri-cli/src/mobile/ios/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,8 +243,15 @@ pub fn run(options: Options, noise_level: NoiseLevel, dirs: &Dirs) -> Result<Bui
if dirs.tauri.join("Info.ios.plist").exists() {
src_plists.push(dirs.tauri.join("Info.ios.plist").into());
}
if let Some(info_plist) = &tauri_config.bundle.ios.info_plist {
src_plists.push(info_plist.clone().into());
{
if let Some(info_plist) = &tauri_config.bundle.ios.info_plist {
src_plists.push(info_plist.clone().into());
}
if let Some(associations) = tauri_config.bundle.file_associations.as_ref() {
if let Some(file_associations) = tauri_utils::config::file_associations_plist(associations) {
src_plists.push(file_associations.into());
}
}
}
let merged_info_plist = merge_plist(src_plists)?;
merged_info_plist
Expand Down
11 changes: 9 additions & 2 deletions crates/tauri-cli/src/mobile/ios/dev.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,15 @@ fn run_command(options: Options, noise_level: NoiseLevel, dirs: Dirs) -> Result<
if dirs.tauri.join("Info.ios.plist").exists() {
src_plists.push(dirs.tauri.join("Info.ios.plist").into());
}
if let Some(info_plist) = &tauri_config.bundle.ios.info_plist {
src_plists.push(info_plist.clone().into());
{
if let Some(info_plist) = &tauri_config.bundle.ios.info_plist {
src_plists.push(info_plist.clone().into());
}
if let Some(associations) = tauri_config.bundle.file_associations.as_ref() {
if let Some(file_associations) = tauri_utils::config::file_associations_plist(associations) {
src_plists.push(file_associations.into());
}
}
}
let merged_info_plist = merge_plist(src_plists)?;
merged_info_plist
Expand Down
Loading
Loading