Skip to content

Commit 83a0db7

Browse files
committed
feat: cross-platform mailto handler with cold-start deep-link fix
Add native mailto: default handler check and registration for the Tauri desktop app across all platforms: - macOS: CoreServices LSCopyDefaultHandlerForURLScheme (read) and LSSetDefaultHandlerForURLScheme (write). Falls back to opening Apple Mail settings with user instructions when the App Sandbox blocks the write call (error -54). - Windows: tauri-plugin-deep-link register()/is_registered() via Windows registry. - Linux: tauri-plugin-deep-link register()/is_registered() via xdg-mime. - Web: navigator.registerProtocolHandler() (explicit opt-in only). Fix cold-start race condition where clicking a mailto: link to launch the app would open the window but silently drop the URL because the webview JS listeners were not yet registered: - Capture initial deep-link URL via deep_link().get_current() during Rust setup() into a PendingDeepLinks mutex queue. - Add get_pending_deep_links IPC command to drain the queue. - initTauriBridge() drains pending URLs after registering listeners. - Move app:deep-link and app:single-instance event listeners before initTauriBridge() call so they are ready when URLs are replayed. Remove silent auto-registration of the web app as Chr
1 parent 036a4ec commit 83a0db7

9 files changed

Lines changed: 1026 additions & 111 deletions

File tree

src-tauri/Cargo.lock

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ tauri-plugin-global-shortcut = "2.3.1"
4343
# macOS-only: Objective-C bindings for dock badge
4444
[target.'cfg(target_os = "macos")'.dependencies]
4545
objc = "0.2"
46+
core-foundation = "0.10"
4647

4748
[profile.release]
4849
panic = "abort"

src-tauri/src/lib.rs

Lines changed: 244 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use serde::Serialize;
2+
use std::sync::Mutex;
23
use tauri::{Emitter, Listener, Manager};
34

45
#[cfg(desktop)]
@@ -29,11 +30,24 @@ struct SingleInstancePayload {
2930
cwd: String,
3031
}
3132

33+
/// Holds deep-link URLs that arrived before the frontend was ready.
34+
/// The frontend calls `get_pending_deep_links` once during bootstrap
35+
/// to drain any URLs that arrived during cold start.
36+
struct PendingDeepLinks(Mutex<Vec<String>>);
37+
3238
// ── IPC Commands ─────────────────────────────────────────────────────────────
3339
//
3440
// Every command validates its inputs on the Rust side. The frontend is never
3541
// trusted — all values are bounds-checked and sanitised before use.
3642

43+
/// Drain and return any deep-link URLs that arrived before the frontend
44+
/// was ready (cold-start race condition fix).
45+
#[tauri::command]
46+
fn get_pending_deep_links(state: tauri::State<'_, PendingDeepLinks>) -> Vec<String> {
47+
let mut queue = state.0.lock().unwrap_or_else(|e| e.into_inner());
48+
queue.drain(..).collect()
49+
}
50+
3751
/// Returns the current app version (compile-time constant, no user input).
3852
#[tauri::command]
3953
fn get_app_version() -> String {
@@ -114,6 +128,200 @@ fn toggle_window_visibility(app: tauri::AppHandle) -> Result<(), String> {
114128
Ok(())
115129
}
116130

131+
// ── Mailto Handler Commands ─────────────────────────────────────────────────
132+
//
133+
// Cross-platform default mailto: handler check and registration.
134+
//
135+
// macOS: Uses CoreServices LSCopyDefaultHandlerForURLScheme (read-only,
136+
// works inside the App Sandbox) and LSSetDefaultHandlerForURLScheme
137+
// (write — returns -54 inside the sandbox). When the write fails
138+
// we open Apple Mail so the user can change the setting manually.
139+
//
140+
// Windows / Linux: Delegates to the tauri-plugin-deep-link register() and
141+
// is_registered() APIs, which use the Windows registry and xdg-mime
142+
// respectively.
143+
144+
/// Result of checking whether we are the default mailto handler.
145+
#[derive(Clone, Serialize)]
146+
struct MailtoStatus {
147+
/// "default" | "not_default" | "unknown"
148+
status: String,
149+
/// The bundle ID of the current default handler (macOS only, empty otherwise)
150+
current_handler: String,
151+
}
152+
153+
/// Check if this app is the default mailto: handler.
154+
#[cfg(desktop)]
155+
#[tauri::command]
156+
async fn is_default_mailto_handler(
157+
app: tauri::AppHandle,
158+
) -> Result<MailtoStatus, String> {
159+
is_default_mailto_handler_impl(&app).await
160+
}
161+
162+
#[cfg(all(desktop, target_os = "macos"))]
163+
async fn is_default_mailto_handler_impl(
164+
_app: &tauri::AppHandle,
165+
) -> Result<MailtoStatus, String> {
166+
use core_foundation::base::TCFType;
167+
use core_foundation::string::{CFString, CFStringRef};
168+
169+
unsafe {
170+
let scheme = CFString::new("mailto");
171+
let handler: CFStringRef = LSCopyDefaultHandlerForURLScheme(scheme.as_concrete_TypeRef());
172+
173+
if handler.is_null() {
174+
return Ok(MailtoStatus {
175+
status: "unknown".to_string(),
176+
current_handler: String::new(),
177+
});
178+
}
179+
180+
let handler_cf = CFString::wrap_under_create_rule(handler);
181+
let handler_str = handler_cf.to_string();
182+
183+
let is_us = handler_str == "net.forwardemail.mail";
184+
185+
Ok(MailtoStatus {
186+
status: if is_us {
187+
"default".to_string()
188+
} else {
189+
"not_default".to_string()
190+
},
191+
current_handler: handler_str,
192+
})
193+
}
194+
}
195+
196+
#[cfg(all(desktop, any(target_os = "windows", target_os = "linux")))]
197+
async fn is_default_mailto_handler_impl(
198+
app: &tauri::AppHandle,
199+
) -> Result<MailtoStatus, String> {
200+
use tauri_plugin_deep_link::DeepLinkExt;
201+
202+
match app.deep_link().is_registered("mailto") {
203+
Ok(true) => Ok(MailtoStatus {
204+
status: "default".to_string(),
205+
current_handler: String::new(),
206+
}),
207+
Ok(false) => Ok(MailtoStatus {
208+
status: "not_default".to_string(),
209+
current_handler: String::new(),
210+
}),
211+
Err(e) => {
212+
log::warn!("deep-link is_registered check failed: {}", e);
213+
Ok(MailtoStatus {
214+
status: "unknown".to_string(),
215+
current_handler: String::new(),
216+
})
217+
}
218+
}
219+
}
220+
221+
/// Result of attempting to set the default mailto handler.
222+
#[derive(Clone, Serialize)]
223+
struct SetMailtoResult {
224+
/// "registered" | "open_mail_settings" | "error"
225+
method: String,
226+
/// Human-readable message for the user
227+
message: String,
228+
}
229+
230+
/// Attempt to set this app as the default mailto: handler.
231+
#[cfg(desktop)]
232+
#[tauri::command]
233+
async fn set_default_mailto_handler(
234+
app: tauri::AppHandle,
235+
) -> Result<SetMailtoResult, String> {
236+
set_default_mailto_handler_impl(&app).await
237+
}
238+
239+
#[cfg(all(desktop, target_os = "macos"))]
240+
async fn set_default_mailto_handler_impl(
241+
_app: &tauri::AppHandle,
242+
) -> Result<SetMailtoResult, String> {
243+
use core_foundation::base::TCFType;
244+
use core_foundation::string::CFString;
245+
246+
unsafe {
247+
let scheme = CFString::new("mailto");
248+
let bundle_id = CFString::new("net.forwardemail.mail");
249+
250+
let result = LSSetDefaultHandlerForURLScheme(
251+
scheme.as_concrete_TypeRef(),
252+
bundle_id.as_concrete_TypeRef(),
253+
);
254+
255+
if result == 0 {
256+
// noErr \u{2014} success (works for non-sandboxed builds)
257+
return Ok(SetMailtoResult {
258+
method: "registered".to_string(),
259+
message: "Forward Email is now your default email app.".to_string(),
260+
});
261+
}
262+
263+
// Error -54 (or any error): App Sandbox blocks this call.
264+
// Open Apple Mail so the user can change the setting manually.
265+
log::info!(
266+
"LSSetDefaultHandlerForURLScheme returned {}, falling back to Mail.app settings",
267+
result
268+
);
269+
270+
let open_result = std::process::Command::new("open")
271+
.arg("-b")
272+
.arg("com.apple.mail")
273+
.output();
274+
275+
match open_result {
276+
Ok(_) => Ok(SetMailtoResult {
277+
method: "open_mail_settings".to_string(),
278+
message: "Apple Mail has been opened. Please go to Mail \u{2192} Settings \u{2192} General \u{2192} \"Default email reader\" and select Forward Email.".to_string(),
279+
}),
280+
Err(e) => Ok(SetMailtoResult {
281+
method: "open_mail_settings".to_string(),
282+
message: format!(
283+
"Please open Apple Mail, then go to Mail \u{2192} Settings \u{2192} General \u{2192} \"Default email reader\" and select Forward Email. (Could not open Mail automatically: {})",
284+
e
285+
),
286+
}),
287+
}
288+
}
289+
}
290+
291+
#[cfg(all(desktop, any(target_os = "windows", target_os = "linux")))]
292+
async fn set_default_mailto_handler_impl(
293+
app: &tauri::AppHandle,
294+
) -> Result<SetMailtoResult, String> {
295+
use tauri_plugin_deep_link::DeepLinkExt;
296+
297+
match app.deep_link().register("mailto") {
298+
Ok(_) => Ok(SetMailtoResult {
299+
method: "registered".to_string(),
300+
message: "Forward Email is now your default email app.".to_string(),
301+
}),
302+
Err(e) => {
303+
log::error!("deep-link register failed: {}", e);
304+
Ok(SetMailtoResult {
305+
method: "error".to_string(),
306+
message: format!("Failed to register as default email handler: {}", e),
307+
})
308+
}
309+
}
310+
}
311+
312+
// CoreServices FFI declarations for macOS
313+
#[cfg(target_os = "macos")]
314+
extern "C" {
315+
fn LSCopyDefaultHandlerForURLScheme(
316+
inURLScheme: core_foundation::string::CFStringRef,
317+
) -> core_foundation::string::CFStringRef;
318+
319+
fn LSSetDefaultHandlerForURLScheme(
320+
inURLScheme: core_foundation::string::CFStringRef,
321+
inHandlerBundleID: core_foundation::string::CFStringRef,
322+
) -> i32;
323+
}
324+
117325
// ── Tray Icon ────────────────────────────────────────────────────────────────
118326

119327
#[cfg(desktop)]
@@ -322,9 +530,15 @@ pub fn run() {
322530
get_platform,
323531
get_build_info,
324532
set_badge_count,
533+
get_pending_deep_links,
325534
#[cfg(desktop)]
326535
toggle_window_visibility,
536+
#[cfg(desktop)]
537+
is_default_mailto_handler,
538+
#[cfg(desktop)]
539+
set_default_mailto_handler,
327540
])
541+
.manage(PendingDeepLinks(Mutex::new(Vec::new())))
328542
.setup(|app| {
329543
// Set up native menu bar and tray icon on desktop
330544
#[cfg(desktop)]
@@ -390,7 +604,31 @@ pub fn run() {
390604
});
391605
}
392606

393-
// Register deep-link handler with URL validation
607+
// ── Cold-start deep-link capture ────────────────────────────
608+
// On cold start the OS delivers the URL before the webview JS
609+
// is ready. We capture it here and the frontend drains it via
610+
// the `get_pending_deep_links` IPC command.
611+
{
612+
use tauri_plugin_deep_link::DeepLinkExt;
613+
if let Ok(Some(urls)) = app.deep_link().get_current() {
614+
let safe_urls: Vec<String> = urls
615+
.into_iter()
616+
.map(|u| u.to_string())
617+
.filter(|u| is_valid_deep_link(u))
618+
.collect();
619+
if !safe_urls.is_empty() {
620+
if let Some(state) = app.try_state::<PendingDeepLinks>() {
621+
let mut queue = state.0.lock().unwrap_or_else(|e| e.into_inner());
622+
queue.extend(safe_urls);
623+
}
624+
}
625+
}
626+
}
627+
628+
// Register deep-link handler with URL validation.
629+
// When the app is already running, URLs arrive here.
630+
// We also push to the pending queue in case the frontend
631+
// listener isn't ready yet (e.g. page reload).
394632
let handle = app.handle().clone();
395633
app.listen("deep-link://new-url", move |event| {
396634
if let Ok(urls) = serde_json::from_str::<Vec<String>>(event.payload()) {
@@ -400,6 +638,11 @@ pub fn run() {
400638
.filter(|u| is_valid_deep_link(u))
401639
.collect();
402640
if !safe_urls.is_empty() {
641+
// Also push to pending queue as a safety net
642+
if let Some(state) = handle.try_state::<PendingDeepLinks>() {
643+
let mut queue = state.0.lock().unwrap_or_else(|e| e.into_inner());
644+
queue.extend(safe_urls.clone());
645+
}
403646
let _ = handle.emit(
404647
"deep-link-received",
405648
DeepLinkPayload { urls: safe_urls },

0 commit comments

Comments
 (0)