File Associations in Tauri
How to configure file type associations so your Tauri v2 app becomes the default handler for specific file extensions and how to handle opened files from the frontend
When a user double-clicks a .png file in their file manager, their operating system launches whatever app is registered to handle image files. File associations are how your Tauri application tells the OS "I can open these file types." Once registered, double-clicking an associated file either starts your app or, if already running, brings it to the foreground and delivers the file path so you can process the content.
This section covers the configuration inside tauri.conf.json that defines file associations, how those declarations get embedded into platform-specific installer metadata, and the Rust + JavaScript code needed to receive and react to opened files on every supported platform. Handling a file that launched the app often overlaps with the deep-link plugin.
How File Associations Work in Tauri
The bundle.fileAssociations array in tauri.conf.json is a cross-platform declaration of which file types your app can handle. During tauri build, the Tauri CLI reads this array and generates the appropriate native metadata:
- Windows – writes entries into the WiX or NSIS installer configuration so the file extensions get registered in the Windows Registry.
- macOS – adds
CFBundleDocumentTypesentries to the app bundle'sInfo.plist, and optionallyUTExportedTypeDeclarationsfor custom file types. - Linux – inserts MIME type associations into the
.desktopfile (for Debian packages and AppImages). - Android – generates
<intent-filter>blocks insideAndroidManifest.xmlso the OS knows your activity can handleACTION_VIEWfor those MIME types. - iOS – adds
CFBundleDocumentTypes(andUTExportedTypeDeclarationsif needed) to the app'sInfo.plist.
You don't write any platform-specific registrations yourself; the configuration is the single source of truth. The CLI does the translation.
When the OS launches your app because a user opened a file, Tauri fires a RunEvent::Opened event on macOS, iOS, and Android. On Windows and Linux the behaviour differs: a second instance of your app is launched and the opened file path is available as a command-line argument. We'll cover both patterns shortly.
The configuration is declarative, not runtime:
These settings only affect the bundled application (i.e., after tauri build). During development with tauri dev, there's no operating system-level file association registration, so double-clicking a file won't launch your dev server. Test file opening on production builds or use simulated events.
Declaring File Associations
Open src-tauri/tauri.conf.json and add a fileAssociations array inside the bundle object. Each entry describes one file type.
Basic Association for Common Types
Here is a minimal example that registers .png and .jpg handling:
{
"bundle": {
"fileAssociations": [
{
"ext": ["png"],
"mimeType": "image/png"
},
{
"ext": ["jpg", "jpeg"],
"mimeType": "image/jpeg"
}
]
}
}
ext– an array of file extensions without the leading dot. Case-insensitive on most platforms.mimeType– the MIME type (e.g.image/png). Required on Android for intent filter matching and used on Linux/Windows for MIME type registration. If you omit it, Tauri infers common MIME types from the extensions, but specifying it explicitly avoids surprises.
When you build the app, the installer will set your application as the default handler for these file types (subject to user consent on modern OS versions).
No further code required for basic registration:
The configuration alone registers the types. If you only need your app to appear in the "Open with" menu, this is enough.
Custom File Types (Non-Standard Extensions)
If your app uses a proprietary file format—say .mydata—the operating system has no built-in definition for it. On Apple platforms you must provide an exportedType so the system can categorise the file. The identifier should be a reverse-DNS string unique to your app, and conformsTo declares which well-known uniform type identifier (UTI) your type inherits from.
{
"bundle": {
"fileAssociations": [
{
"ext": ["mydata"],
"mimeType": "application/octet-stream",
"exportedType": {
"identifier": "com.example.myapp.mydata",
"conformsTo": ["public.data"]
}
}
]
}
}
Common parent UTIs to use for conformsTo:
public.data– generic binary datapublic.image– any image formatpublic.json– JSON filespublic.plain-text– plain text files
On Windows and Linux, the exportedType block is ignored, but it's safe to leave it in the configuration—it simply has no effect.
Controlling App Role and Ranking (macOS / iOS)
Two optional fields give you finer control on Apple platforms:
role– how your app should be presented for the file type. Maps toCFBundleTypeRoleinInfo.plist. Values:Editor(default),Viewer,Shell,QLGenerator,None. UseViewerif you only display the file without editing.rank– the preference level relative to other apps that claim the same type. Maps toLSHandlerRank. Values:Default(default),Owner(your app "owns" the type),Alternate(secondary handler),None.
Example:
{
"bundle": {
"fileAssociations": [
{
"ext": ["mydata"],
"mimeType": "application/octet-stream",
"role": "Editor",
"rank": "Owner",
"exportedType": {
"identifier": "com.example.myapp.mydata",
"conformsTo": ["public.data"]
}
}
]
}
}
Android Intent Action Filters
On Android, the system delivers files via intents. By default, Tauri registers for Send, SendMultiple, and View actions. You can narrow this down with androidIntentActionFilters:
{
"bundle": {
"fileAssociations": [
{
"ext": ["mydata"],
"mimeType": "application/octet-stream",
"androidIntentActionFilters": ["View"]
}
]
}
}
This prevents your activity from appearing in share-sheets—it only responds when the user explicitly views the file.
Handling Opened Files
Once the OS routes a file to your app, you need to actually do something with it. The mechanism splits into two scenarios:
- Cold start – the app was not running; the OS launches it and passes the file.
- Warm delivery – the app is already open; the OS tells the running instance about the new file.
The implementation must handle both. Tauri provides a unified event RunEvent::Opened that fires on macOS, iOS, and Android for both cases. On Windows and Linux, a second process is spawned and you must manually forward the file path to the primary instance (we'll handle that with the single-instance plugin).
Rust Backend: Storing and Broadcasting URLs
In your src-tauri/src/lib.rs (or main.rs), we set up:
- A managed state (
Mutex<Vec<Url>>) to hold URLs that arrived before the frontend finished loading. - A Tauri command
opened_urlsto let the frontend retrieve those stored URLs on startup. - A
RunEvent::Openedlistener that stores incoming URLs and emits a customopenedevent so the frontend can react in real time.
use std::sync::Mutex;
use tauri::Manager;
struct OpenedUrls(Mutex<Vec<tauri::Url>>);
#[tauri::command]
fn opened_urls(app: tauri::AppHandle) -> Vec<tauri::Url> {
app.state::<OpenedUrls>().0.lock().unwrap().clone()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(OpenedUrls(Mutex::new(vec![])))
.invoke_handler(tauri::generate_handler![opened_urls])
.build(tauri::generate_context!())
.expect("error while running tauri application")
.run(|app, event| {
#[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
if let tauri::RunEvent::Opened { urls } = event {
use tauri::Emitter;
app.state::<OpenedUrls>()
.0
.lock()
.unwrap()
.extend(urls.clone());
app.emit("opened", urls).unwrap();
}
});
}
RunEvent::Opened only fires on macOS, iOS, and Android:
On Windows and Linux, Tauri does not emit RunEvent::Opened. Instead, the OS launches a second instance of your app with the file path as a command-line argument. The single-instance approach covered next is necessary for those platforms.
React Frontend: Fetching Initial URLs and Listening for Events
On the frontend side, we need two things:
- Call
invoke('opened_urls')as soon as the React app mounts, to grab any URLs that arrived before the UI was ready. - Subscribe to the
openedTauri event so that new file opens while the app is already running are handled immediately.
Install the required API package if you haven't already:
npm install @tauri-apps/api
Then create a hook or component that wires up both paths.
import { useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
function App() {
useEffect(() => {
// 1. Cold start: retrieve URLs that were stored before the UI loaded
invoke<string[]>("opened_urls").then((urls) => {
if (urls.length > 0) {
handleFiles(urls);
}
});
// 2. Warm start: listen for files opened while the app is already running
const unlisten = listen<string[]>("opened", (event) => {
handleFiles(event.payload);
});
return () => {
unlisten.then((fn) => fn());
};
}, []);
const handleFiles = (urls: string[]) => {
// urls are in the format "file:///path/to/file.ext"
// convert to normal paths or read content via the FS plugin
console.log("Received files:", urls);
};
return (
<div>
<h1>File Association Demo</h1>
<p>Open an associated file to see its path logged.</p>
</div>
);
}
export default App;
The handleFiles function receives an array of URL strings like "file:///Users/me/document.txt". From there you can use the Tauri file system plugin or the standard fetch API (with tauri://localhost protocol allowances) to read the actual file content.
Don't assume simple filesystem access works in the frontend:
The webview cannot directly read arbitrary file system paths. The file:// URL is a pointer—you must convert it to a Tauri asset protocol path or use the @tauri-apps/plugin-fs plugin to read the file through Rust. For example, you can strip the file:// prefix and invoke a custom command that reads the file on the Rust side and returns its contents.
Windows and Linux: Integrating the Single Instance Plugin
Because Windows and Linux launch a second instance instead of sending an event to the first, you need the tauri-plugin-single-instance plugin. It ensures only one instance of your app exists. When a second launch is triggered (by a file double-click), the plugin intercepts it, sends the command-line arguments to the already-running instance, and then exits the new process.
Step 1: Add the Plugin
Add the plugin to your Rust dependencies and JavaScript dependencies:
cargo add tauri-plugin-single-instance
npm install @tauri-apps/plugin-single-instance
Step 2: Register the Plugin in Rust
In src-tauri/src/lib.rs, initialize the plugin and handle the callback. We'll emit a custom event when a file path is received from a secondary instance.
use std::sync::Mutex;
use tauri::Manager;
struct OpenedUrls(Mutex<Vec<tauri::Url>>);
#[tauri::command]
fn opened_urls(app: tauri::AppHandle) -> Vec<tauri::Url> {
app.state::<OpenedUrls>().0.lock().unwrap().clone()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
use tauri::Emitter;
// On a second instance, argv contains the executable path and the file path.
// We extract file paths (anything after the first argument).
if argv.len() > 1 {
let urls: Vec<String> = argv[1..]
.iter()
.map(|p| {
// Convert to file:// URL if it isn't already
if p.starts_with("file://") {
p.clone()
} else {
format!("file://{}", p)
}
})
.collect();
// Store so the frontend can poll them if needed
app.state::<OpenedUrls>()
.0
.lock()
.unwrap()
.extend(urls.iter().map(|u| u.parse().unwrap()));
// Notify the frontend immediately
app.emit("opened", urls).unwrap();
}
// Returning true prevents the second instance from continuing to run
true
}))
.manage(OpenedUrls(Mutex::new(vec![])))
.invoke_handler(tauri::generate_handler![opened_urls])
.build(tauri::generate_context!())
.expect("error while running tauri application")
.run(|_app, _event| {
// On Windows/Linux, we handle opened files via the single-instance callback above,
// so the RunEvent::Opened block is only for macOS/iOS/Android.
// We'll leave the previous pattern for other platforms.
#[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
if let tauri::RunEvent::Opened { urls } = event {
use tauri::Emitter;
_app.state::<OpenedUrls>()
.0
.lock()
.unwrap()
.extend(urls.clone());
_app.emit("opened", urls).unwrap();
}
});
}
The callback we pass to init receives the command-line arguments of the secondary instance. We parse them into URL strings (ensuring file:// prefix), store them in state, and emit the opened event just like the macOS path. Returning true tells the plugin to terminate the second instance.
Step 3: Frontend Handler Remains Unchanged
The React useEffect code we wrote earlier works identically on Windows and Linux because we still emit an opened event and provide the opened_urls command. The frontend does not need to know the platform-specific plumbing—the same event system delivers file paths on every OS.
One frontend, all platforms:
With the single-instance plugin in place, you can write a single frontend handler that works across Windows, macOS, Linux, iOS, and Android. The same listen("opened", ...) call receives file paths everywhere.
Common Mistakes and Troubleshooting
Forgetting to handle the file:// URL format:
The event payload contains full file:// URLs. Using these directly with the HTML5 File API won't work because the webview is sandboxed. Always convert the URL to a file path (strip the file:// prefix) and send it to a Rust command that reads the file using std::fs or the Tauri filesystem APIs.
Assuming RunEvent::Opened works on Windows/Linux:
If you only implement the RunEvent::Opened listener and skip the single-instance plugin, your app will not receive file open events on Windows or Linux. A second window may open with no way to access the file path from the original instance. Always combine both patterns for full desktop coverage.
MIME type mismatches can prevent registration:
On Android, if the mimeType in your config doesn't match the actual file content (or isn't a standard type), the intent filter may not trigger. Stick to well-known MIME types or declare an exportedType for custom formats on Apple platforms. On Linux, incorrect MIME types might prevent the file manager from listing your app.
Putting It All Together: A Complete Configuration and Handler
Below is a full example that ties the pieces together. It registers a custom .myapp file type, sets up the Rust backend with both the macOS/iOS/Android RunEvent::Opened handler and the single-instance fallback for Windows/Linux, and a React frontend that reads the file path and displays it.
{
"bundle": {
"fileAssociations": [
{
"ext": ["myapp"],
"mimeType": "application/octet-stream",
"role": "Editor",
"rank": "Owner",
"exportedType": {
"identifier": "com.example.myapp.project",
"conformsTo": ["public.data"]
}
}
]
}
}
use std::sync::Mutex;
use tauri::Manager;
struct OpenedUrls(Mutex<Vec<tauri::Url>>);
#[tauri::command]
fn opened_urls(app: tauri::AppHandle) -> Vec<tauri::Url> {
app.state::<OpenedUrls>().0.lock().unwrap().clone()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
use tauri::Emitter;
if argv.len() > 1 {
let urls: Vec<String> = argv[1..]
.iter()
.map(|p| {
if p.starts_with("file://") {
p.clone()
} else {
format!("file://{}", p)
}
})
.collect();
app.state::<OpenedUrls>()
.0
.lock()
.unwrap()
.extend(urls.iter().map(|u| u.parse().unwrap()));
app.emit("opened", urls).unwrap();
}
true
}))
.manage(OpenedUrls(Mutex::new(vec![])))
.invoke_handler(tauri::generate_handler![opened_urls])
.build(tauri::generate_context!())
.expect("error while running tauri application")
.run(|app, event| {
#[cfg(any(target_os = "macos", target_os = "ios", target_os = "android"))]
if let tauri::RunEvent::Opened { urls } = event {
use tauri::Emitter;
app.state::<OpenedUrls>()
.0
.lock()
.unwrap()
.extend(urls.clone());
app.emit("opened", urls).unwrap();
}
});
}
import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
function App() {
const [openedFiles, setOpenedFiles] = useState<string[]>([]);
useEffect(() => {
invoke<string[]>("opened_urls").then((urls) => {
if (urls.length > 0) {
setOpenedFiles(urls);
}
});
const unlisten = listen<string[]>("opened", (event) => {
setOpenedFiles(event.payload);
});
return () => {
unlisten.then((fn) => fn());
};
}, []);
return (
<div>
<h1>File Association Demo</h1>
{openedFiles.length > 0 ? (
<ul>
{openedFiles.map((url, i) => (
<li key={i}>{url}</li>
))}
</ul>
) : (
<p>No file opened yet. Double-click a .myapp file to test.</p>
)}
</div>
);
}
export default App;
This example compiles and runs on all platforms. On macOS, you must also add the deep-link domain to your Info.plist if you are using custom URL schemes, but for file associations the configuration above is sufficient.
When to Use the Deep-Link Plugin Instead
Tauri has a separate tauri-plugin-deep-link plugin for custom protocol schemes (e.g., myapp://...). File associations are about files on disk, not URL schemes. However, on Windows and Linux, deep-link handling and file association handling both involve command-line arguments and can benefit from the single-instance plugin. The deep-link plugin is not required for file associations; the approach shown here works with pure configuration plus the single-instance plugin.
Summary
File associations turn your Tauri app into a first-class citizen on the user's operating system. The configuration is declarative and cross-platform. The runtime handling requires two separate code paths—one for platforms where RunEvent::Opened fires (macOS, iOS, Android) and one for platforms that spawn new processes (Windows, Linux). By combining managed state, a custom Tauri event, and the single-instance plugin, you create a single, clean frontend API that works everywhere. The key is to always store incoming URLs in a queue before the UI loads and always emit a real-time event for already-running instances.