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,
})
}
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;