tauri-plugin-single-window 1.1.1

Desktop-only Tauri plugin that prevents duplicate app launches and redirects activation to the existing instance.
Documentation
use std::{
    fs,
    io::{self, BufRead, BufReader, IsTerminal, Write},
    path::{Path, PathBuf},
    thread,
    time::Duration,
};

use interprocess::local_socket::{
    prelude::*, GenericFilePath, GenericNamespaced, ListenerOptions, Name, Stream,
};
use serde::de::DeserializeOwned;
use sysinfo::{get_current_pid, Pid, System};
use tauri::{plugin::PluginApi, AppHandle, Manager, Runtime};

const ACTIVATION_RETRY_COUNT: usize = 10;
const ACTIVATION_RETRY_DELAY: Duration = Duration::from_millis(50);

pub fn init<R: Runtime, C: DeserializeOwned>(
    app: &AppHandle<R>,
    _api: PluginApi<R, C>,
    config: &crate::Config,
    callback: Option<crate::ActivationCallback<R>>,
) -> crate::Result<SingleInstance<R>> {
    let process_name = app.package_info().name.clone();
    let pid_file_path = pid_file_path(&process_name);
    let current_exe = std::env::current_exe().ok();
    let activation_payload = current_activation_payload(&process_name);
    cleanup_stale_pid_file(&pid_file_path, current_exe.as_deref());
    let target_window_label = config.target_window_label.clone();

    let listener = match bind_listener(&process_name) {
        Ok(listener) => listener,
        Err(error) if error.kind() == io::ErrorKind::AddrInUse => {
            let duplicate_pid = read_primary_pid(&pid_file_path)
                .filter(|pid| is_primary_process(*pid, current_exe.as_deref()));
            let notified_primary =
                notify_primary_instance(&process_name, &activation_payload).is_ok();

            if duplicate_pid.is_some() || notified_primary {
                print_duplicate_notice(
                    &process_name,
                    duplicate_pid,
                    config.duplicate_notice.as_deref(),
                );
                terminate_duplicate_process(app);
            }

            return Err(error.into());
        }
        Err(error) => return Err(error.into()),
    };

    write_primary_pid(&pid_file_path)?;
    spawn_activation_listener(
        app.clone(),
        listener,
        target_window_label.clone(),
        callback.clone(),
    );

    Ok(SingleInstance {
        app: app.clone(),
        pid_file_path,
        process_name,
        target_window_label,
        callback,
    })
}

/// Access to the single-instance APIs.
pub struct SingleInstance<R: Runtime> {
    app: AppHandle<R>,
    pid_file_path: PathBuf,
    process_name: String,
    target_window_label: Option<String>,
    callback: Option<crate::ActivationCallback<R>>,
}

impl<R: Runtime> Drop for SingleInstance<R> {
    fn drop(&mut self) {
        let current_pid = std::process::id();
        let recorded_pid = read_primary_pid(&self.pid_file_path).map(Pid::as_u32);

        if recorded_pid == Some(current_pid) {
            let _ = fs::remove_file(&self.pid_file_path);
        }
    }
}

impl<R: Runtime> SingleInstance<R> {
    pub(crate) fn handle_remote_activation(&self) {
        focus_existing_window(
            &self.app,
            self.target_window_label.as_deref(),
            Some(remote_activation_payload(&self.process_name)),
            self.callback.clone(),
        );
    }
}

fn bind_listener(process_name: &str) -> io::Result<interprocess::local_socket::Listener> {
    ListenerOptions::new()
        .name(socket_name(process_name)?)
        .try_overwrite(true)
        .create_sync()
}

fn notify_primary_instance(
    process_name: &str,
    payload: &crate::ActivationPayload,
) -> io::Result<()> {
    let name = socket_name(process_name)?;
    let mut last_error = None;
    let body = serde_json::to_string(payload)
        .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;

    for _ in 0..ACTIVATION_RETRY_COUNT {
        match Stream::connect(name.borrow()) {
            Ok(mut stream) => {
                stream.write_all(body.as_bytes())?;
                stream.write_all(b"\n")?;
                stream.flush()?;
                return Ok(());
            }
            Err(error) => {
                last_error = Some(error);
                thread::sleep(ACTIVATION_RETRY_DELAY);
            }
        }
    }

    Err(last_error.unwrap_or_else(|| io::Error::other("failed to connect to primary instance")))
}

fn spawn_activation_listener<R: Runtime>(
    app: AppHandle<R>,
    listener: interprocess::local_socket::Listener,
    target_window_label: Option<String>,
    callback: Option<crate::ActivationCallback<R>>,
) {
    thread::spawn(move || {
        for connection in listener.incoming() {
            match connection {
                Ok(stream) => {
                    let payload = read_activation_payload(stream);
                    focus_existing_window(
                        &app,
                        target_window_label.as_deref(),
                        payload,
                        callback.clone(),
                    );
                }
                Err(_) => continue,
            }
        }
    });
}

fn focus_existing_window<R: Runtime>(
    app: &AppHandle<R>,
    target_window_label: Option<&str>,
    payload: Option<crate::ActivationPayload>,
    callback: Option<crate::ActivationCallback<R>>,
) {
    let app = app.clone();
    let app_handle = app.clone();
    let target_window_label = target_window_label.map(str::to_owned);
    let _ = app.run_on_main_thread(move || {
        #[cfg(target_os = "macos")]
        let _ = app_handle.show();

        let Some(window) = find_target_window(&app_handle, target_window_label.as_deref()) else {
            return;
        };

        let _ = window.unminimize();
        let _ = window.show();
        let _ = window.set_focus();

        if let (Some(payload), Some(callback)) = (payload, callback) {
            callback(app_handle.clone(), payload);
        }
    });
}

fn read_activation_payload(
    stream: interprocess::local_socket::Stream,
) -> Option<crate::ActivationPayload> {
    let mut reader = BufReader::new(stream);
    let mut payload = String::new();

    if reader.read_line(&mut payload).ok()? == 0 {
        return None;
    }

    parse_activation_payload(payload.trim())
}

fn is_primary_process(pid: Pid, current_exe: Option<&Path>) -> bool {
    let mut system = System::new_all();
    system.refresh_all();

    let current_pid = match get_current_pid() {
        Ok(pid) => pid,
        Err(_) => return false,
    };

    if pid == current_pid {
        return false;
    }

    let Some(process) = system.process(pid) else {
        return false;
    };

    match (current_exe, process.exe()) {
        (Some(current_exe), Some(process_exe)) => process_exe == current_exe,
        _ => true,
    }
}

fn normalized_process_name(name: &str) -> String {
    name.trim_end_matches(".exe")
        .replace('_', "-")
        .to_ascii_lowercase()
}

fn socket_name(process_name: &str) -> io::Result<Name<'static>> {
    let endpoint = format!(
        "tauri-plugin-single-window-{}",
        normalized_process_name(process_name)
    );

    if GenericNamespaced::is_supported() {
        endpoint
            .to_ns_name::<GenericNamespaced>()
            .map(Name::into_owned)
    } else {
        socket_path(&endpoint)?
            .to_fs_name::<GenericFilePath>()
            .map(Name::into_owned)
    }
}

fn socket_path(endpoint: &str) -> io::Result<PathBuf> {
    let mut path = std::env::temp_dir();
    path.push(format!("{endpoint}.sock"));
    Ok(path)
}

fn pid_file_path(process_name: &str) -> PathBuf {
    let mut path = std::env::temp_dir();
    path.push(format!(
        "tauri-plugin-single-window-{}.pid",
        normalized_process_name(process_name)
    ));
    path
}

fn read_primary_pid(path: &Path) -> Option<Pid> {
    fs::read_to_string(path).ok()?.trim().parse().ok()
}

fn write_primary_pid(path: &Path) -> io::Result<()> {
    fs::write(path, std::process::id().to_string())
}

fn cleanup_stale_pid_file(path: &Path, current_exe: Option<&Path>) {
    let contents = match fs::read_to_string(path) {
        Ok(contents) => contents,
        Err(error) if error.kind() == io::ErrorKind::NotFound => return,
        Err(error) => {
            print_cleanup_warning(path, &format!("failed to read PID file: {error}"));
            return;
        }
    };

    if let Some(reason) = stale_pid_reason(contents.trim(), current_exe) {
        remove_stale_pid_file(path, reason);
    }
}

fn remove_stale_pid_file(path: &Path, reason: &str) {
    if let Err(error) = fs::remove_file(path) {
        if error.kind() != io::ErrorKind::NotFound {
            print_cleanup_warning(
                path,
                &format!("failed to delete stale PID file after cleanup ({reason}): {error}"),
            );
        }
    }
}

fn print_cleanup_warning(path: &Path, message: &str) {
    if std::io::stderr().is_terminal() {
        eprintln!("Warning: {message}: {}", path.display());
    }
}

fn stale_pid_reason(contents: &str, current_exe: Option<&Path>) -> Option<&'static str> {
    match contents.parse::<Pid>() {
        Ok(pid) if is_primary_process(pid, current_exe) => None,
        Ok(_) => Some("PID file pointed to a non-primary process"),
        Err(_) => Some("PID file contained invalid data"),
    }
}

fn current_activation_payload(process_name: &str) -> crate::ActivationPayload {
    crate::ActivationPayload {
        process_name: process_name.to_string(),
        pid: std::process::id(),
        argv: std::env::args_os()
            .map(|arg| arg.to_string_lossy().into_owned())
            .collect(),
        cwd: std::env::current_dir()
            .ok()
            .map(|path| path.to_string_lossy().into_owned()),
    }
}

fn remote_activation_payload(process_name: &str) -> crate::ActivationPayload {
    crate::ActivationPayload {
        process_name: process_name.to_string(),
        pid: std::process::id(),
        argv: Vec::new(),
        cwd: None,
    }
}

fn parse_activation_payload(payload: &str) -> Option<crate::ActivationPayload> {
    serde_json::from_str(payload).ok()
}

fn find_target_window<R: Runtime>(
    app: &AppHandle<R>,
    target_window_label: Option<&str>,
) -> Option<tauri::WebviewWindow<R>> {
    let preferred_label = target_window_label.unwrap_or("main");

    app.get_webview_window(preferred_label)
        .or_else(|| {
            if target_window_label.is_some() && preferred_label != "main" {
                app.get_webview_window("main")
            } else {
                None
            }
        })
        .or_else(|| app.webview_windows().into_values().next())
}

fn terminate_duplicate_process<R: Runtime>(app: &AppHandle<R>) -> ! {
    app.cleanup_before_exit();
    std::process::exit(0);
}

fn print_duplicate_notice(process_name: &str, pid: Option<Pid>, template: Option<&str>) {
    let message = render_duplicate_notice(process_name, pid, template);

    if io::stderr().is_terminal() {
        eprintln!("{message}");
    } else if io::stdout().is_terminal() {
        println!("{message}");
    }
}

fn render_duplicate_notice(process_name: &str, pid: Option<Pid>, template: Option<&str>) -> String {
    let pid_text = pid.map(|pid| pid.to_string()).unwrap_or_default();

    if let Some(template) = template {
        return template
            .replace("{{proc}}", process_name)
            .replace("{{pid}}", &pid_text);
    }

    if let Some(pid) = pid {
        format!(
            "Detected existing process '{process_name}' (pid {pid}): focusing the existing window and exiting."
        )
    } else {
        format!(
            "Detected existing process `{process_name}`: focusing the existing window and exiting."
        )
    }
}

#[cfg(test)]
mod tests;