Window Creation and Lifecycle
Patterns for splash screens, dev server polling, macOS-specific tweaks, and window destroy cleanup.
Why create windows in Rust?
Tauri v2 lets you define windows in tauri.conf.json, but creating them programmatically in Rust gives you control over:
- Splash screen timing
- Dev server readiness polling
- Conditional window creation
- Multiple window instances
- Platform-specific configuration
Splash screen pattern
Show a lightweight splash window while the app initializes, then replace it with the main window:
.setup(|app| {
// 1. Create splash window (non-fatal if it fails)
if let Err(e) = native::window::create_splash_window(app.handle()) {
eprintln!("Failed to create splash window: {e}");
}
// 2. Do initialization work
let project_root = resolve_project_root();
let app_state = Arc::new(AppState::new(project_root));
app.manage(app_state);
// Start file watchers, etc.
// 3. Close splash, create main window
native::window::close_splash_window(app.handle());
native::window::create_main_window(app.handle())?;
Ok(())
})
Splash window creation
The splash window is frameless, transparent, and always on top:
pub fn create_splash_window(
handle: &tauri::AppHandle,
) -> tauri::Result<()> {
let url = WebviewUrl::App("../frontend/splash.html".into());
let _window = WebviewWindowBuilder::new(handle, "splash", url)
.title("myapp")
.inner_size(400.0, 200.0)
.decorations(false)
.transparent(true)
.always_on_top(true)
.resizable(false)
.build()?;
Ok(())
}
Closing the splash
pub fn close_splash_window(handle: &tauri::AppHandle) {
if let Some(window) = handle.get_webview_window("splash") {
let _ = window.close();
}
}
📝 Note
When creating windows programmatically, keep the "windows" array in tauri.conf.json empty: "windows": []. If you define windows in both places, you will get duplicates.
Main window with dev server polling
In development, the Vite dev server may not be ready when the window is created. The pattern is: show a loading page immediately, then navigate to the dev server once it responds.
const DEV_SERVER_URL: &str = "http://localhost:37461";
pub fn create_main_window(
handle: &tauri::AppHandle,
) -> tauri::Result<()> {
// Unique label for multiple windows
let label = if handle.get_webview_window("main").is_some() {
format!("main-{}", std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis())
} else {
"main".to_string()
};
let url = if cfg!(debug_assertions) {
// Inline loading page as data: URL
WebviewUrl::External(
loading_page_data_url().parse().unwrap(),
)
} else {
WebviewUrl::default() // Bundled frontend
};
let window = WebviewWindowBuilder::new(handle, &label, url)
.title("myapp")
.inner_size(1400.0, 800.0)
.visible(false) // Start hidden
.on_navigation(move |url: &tauri::Url| {
let url_str = url.as_str();
// Allow local and tauri URLs
if url_str.starts_with("http://localhost")
|| url_str.starts_with("tauri://")
|| url_str.starts_with("data:")
{
return true;
}
// Open external URLs in default browser
if url_str.starts_with("http://")
|| url_str.starts_with("https://")
{
let owned = url_str.to_string();
tauri::async_runtime::spawn_blocking(move || {
let _ = open::that(owned);
});
return false;
}
false // Block unknown schemes
})
.build()?;
// In dev: poll Vite, navigate when ready
if cfg!(debug_assertions) {
let win = window.clone();
std::thread::spawn(move || {
if wait_for_dev_server(DEV_SERVER_URL, 120) {
let url: tauri::Url =
DEV_SERVER_URL.parse().unwrap();
let _ = win.navigate(url);
}
});
}
// Show after a short delay
let win = window.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(200));
let _ = win.show();
});
Ok(())
}
Dev server readiness check
Use TCP connection attempts instead of HTTP requests for faster polling:
fn wait_for_dev_server(url: &str, timeout_secs: u64) -> bool {
let addr = url
.strip_prefix("http://")
.or_else(|| url.strip_prefix("https://"))
.and_then(|h| h.split('/').next())
.expect("invalid URL");
let start = std::time::Instant::now();
let timeout = std::time::Duration::from_secs(timeout_secs);
while start.elapsed() < timeout {
if std::net::TcpStream::connect(addr).is_ok() {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
false
}
💡 Tip
TcpStream::connect is faster than an HTTP request for readiness checks. It returns as soon as the port is open, without waiting for the full HTTP response.
macOS: suppress press-and-hold accent menu
macOS shows an accent character popup when you press and hold a key. This interferes with apps that use key-repeat (text editors, terminal emulators). Suppress it in setup():
#[cfg(target_os = "macos")]
{
use objc2_foundation::{NSUserDefaults, NSString};
let defaults = NSUserDefaults::standardUserDefaults();
unsafe {
let key = NSString::from_str("ApplePressAndHoldEnabled");
defaults.setBool_forKey(false, &key);
}
}
📝 Note
This only affects the current process. It does not change the system-wide setting. Other applications continue to show the accent menu normally.
External link handling
The on_navigation callback controls which URLs the webview is allowed to load. Use it to open external links in the default browser:
.on_navigation(move |url: &tauri::Url| {
let url_str = url.as_str();
// Allow local dev server and tauri asset URLs
if url_str.starts_with("http://localhost")
|| url_str.starts_with("https://localhost")
|| url_str.starts_with("tauri://")
|| url_str.starts_with("asset://")
|| url_str.starts_with("data:")
{
return true;
}
// External HTTP(S) links -> default browser
if url_str.starts_with("http://")
|| url_str.starts_with("https://")
{
let owned = url_str.to_string();
tauri::async_runtime::spawn_blocking(move || {
let _ = open::that(owned);
});
return false;
}
// Block unknown schemes (javascript:, file:, etc.)
false
})
⚠️ Warning
Always block javascript: and file: schemes. Allowing them opens security vulnerabilities in the webview.
Window destroy event handling
Use on_window_event to clean up resources when a window is destroyed:
.on_window_event(|window, event| {
if let tauri::WindowEvent::Destroyed = event {
if window.label() == "main" {
if let Some(state) = window.try_state::<Arc<AppState>>() {
// Kill all PTY sessions
commands::terminal::kill_all_ptys(&state);
}
}
}
})
Sidecar process cleanup
For apps that spawn sidecar processes, clean them up on window destroy using the run callback:
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(move |app_handle, event| match &event {
tauri::RunEvent::WindowEvent {
event: tauri::WindowEvent::Destroyed,
..
} => {
if let Ok(mut guard) = sidecar_arc.lock() {
if let Some(mut sidecar) = guard.take() {
kill_sidecar(&mut sidecar);
}
}
app_handle.exit(0);
}
_ => {}
});
⚠️ Warning
If your app spawns child processes (PTY sessions, sidecar servers), you must explicitly kill them on window destroy. Otherwise they become orphan processes that continue running after the app closes.
Window lifecycle summary
Key takeaways
- Keep
"windows": []empty in tauri.conf.json when creating windows programmatically - Start windows hidden and show after content is ready to avoid white flash
- Use
data:URLs for loading pages — no need for bundled HTML files - Poll dev server with TCP connect — faster than HTTP requests
- Always clean up child processes on window destroy
- Use
on_navigationto control link behavior — open external links in the default browser - Platform-specific code goes behind
#[cfg(target_os = "macos")]guards