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
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.
10 changes: 6 additions & 4 deletions Cargo.lock

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

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,5 @@ schemars_derive = { git = 'https://github.com/tauri-apps/schemars.git', branch =
tauri = { path = "./crates/tauri" }
tauri-plugin = { path = "./crates/tauri-plugin" }
tauri-utils = { path = "./crates/tauri-utils" }
wry = { git = "https://github.com/tauri-apps/wry", branch = "feat/mobile-multi-webview" }
tao = { git = "https://github.com/tauri-apps/tao", branch = "feat/mobile-multi-window" }
wry = { git = "https://github.com/tauri-apps/wry", branch = "feat/android-on-new-intent" }
tao = { git = "https://github.com/tauri-apps/tao", branch = "feat/opened-event-android" }
5 changes: 5 additions & 0 deletions crates/tauri-build/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,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
121 changes: 120 additions & 1 deletion crates/tauri-build/src/mobile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,130 @@
// 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;

/// 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());
} 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);
}
}
}

// If we have mime types, create intent filters
if !mime_types.is_empty() {
for mime_type in &mime_types {
filters.push_str("<intent-filter>\n");
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(" <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");
let app_build_gradle_path = project_dir.join("app").join("tauri.build.gradle.kts");
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 @@ -268,102 +268,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(protocols) = settings.deep_link_protocols() {
Expand Down
20 changes: 11 additions & 9 deletions crates/tauri-cli/src/mobile/ios/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,16 +237,18 @@ pub fn command(options: Options, noise_level: NoiseLevel) -> Result<BuiltApplica
if tauri_path.join("Info.ios.plist").exists() {
src_plists.push(tauri_path.join("Info.ios.plist").into());
}
if let Some(info_plist) = &tauri_config
.lock()
.unwrap()
.as_ref()
.unwrap()
.bundle
.ios
.info_plist
{
src_plists.push(info_plist.clone().into());
let tauri_config_guard = tauri_config.lock().unwrap();
let tauri_config = tauri_config_guard.as_ref().unwrap();

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
20 changes: 11 additions & 9 deletions crates/tauri-cli/src/mobile/ios/dev.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,16 +227,18 @@ fn run_command(options: Options, noise_level: NoiseLevel) -> Result<()> {
if tauri_path.join("Info.ios.plist").exists() {
src_plists.push(tauri_path.join("Info.ios.plist").into());
}
if let Some(info_plist) = &tauri_config
.lock()
.unwrap()
.as_ref()
.unwrap()
.bundle
.ios
.info_plist
{
src_plists.push(info_plist.clone().into());
let tauri_config_guard = tauri_config.lock().unwrap();
let tauri_config = tauri_config_guard.as_ref().unwrap();

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