rightkit_tauri_shell/single_instance.rs
1//! Standalone single instance with a typed message, usable without the shell plugin.
2//!
3//! [`SingleInstance::claim`] makes this process the app's primary, or forwards this launch's
4//! [`InstanceMessage`] (argv, cwd, an optional action and a JSON argument map) to the live primary
5//! and reports that it did. The outcome is typed and fail-closed:
6//!
7//! * [`ClaimOutcome::Primary`]: this process is the primary. Keep the guard for the process lifetime.
8//! * [`ClaimOutcome::Forwarded`]: a live primary accepted the message. The caller must exit.
9//! * [`ClaimOutcome::Failed`]: arbitration could not be decided. The caller must not continue as a
10//! second primary; it should report the reason and exit.
11//!
12//! Arbitration runs over `rightkit-daemon` (current-user socket or named pipe, a probed endpoint and
13//! a live pid), so a crashed primary never blocks the next launch. The shell plugin's own
14//! arbitration (`instance`, method `second-instance`) is separate; an app uses one or the other.
15//!
16//! Reference: Pulse hub's `win_bridge::claim_instance` (a named mutex that forwards `--section`).
17//! That path fails open when the singleton cannot be secured; this one fails closed.
18
19use std::fmt;
20use std::sync::atomic::{AtomicU64, Ordering};
21use std::sync::{Arc, Mutex};
22use std::time::Duration;
23
24use rightkit_daemon::{
25 host, Allowlist, CallContext, Client, Emit, Handler, HostError, Hosted, ServiceConfig,
26 ServiceError, Suite,
27};
28use serde::{Deserialize, Serialize};
29use serde_json::{json, Map, Value};
30
31/// Service method a forwarded launch calls on the primary.
32pub const MESSAGE_METHOD: &str = "instance-message";
33
34/// Attempts to connect to a live primary that may still be starting.
35const CONNECT_ATTEMPTS: u32 = 20;
36const CONNECT_RETRY: Duration = Duration::from_millis(100);
37/// How long hosting the claim may wait for an election.
38const HOST_TIMEOUT: Duration = Duration::from_secs(5);
39
40/// The payload one launch hands to the primary.
41#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
42pub struct InstanceMessage {
43 /// The launch's argv (including the program path).
44 pub argv: Vec<String>,
45 /// The launch's working directory.
46 pub cwd: String,
47 /// What the primary should do, e.g. `"show-section"`.
48 pub action: Option<String>,
49 /// Named arguments for the action, e.g. `{"section": "x"}`.
50 #[serde(default)]
51 pub args: Map<String, Value>,
52}
53
54impl InstanceMessage {
55 /// This process's argv and working directory, with no action.
56 pub fn from_process() -> Self {
57 Self {
58 argv: std::env::args().collect(),
59 cwd: std::env::current_dir()
60 .map(|p| p.to_string_lossy().into_owned())
61 .unwrap_or_default(),
62 action: None,
63 args: Map::new(),
64 }
65 }
66
67 /// Set the action.
68 pub fn with_action(mut self, action: &str) -> Self {
69 self.action = Some(action.to_owned());
70 self
71 }
72
73 /// Add one named argument.
74 pub fn with_arg(mut self, key: &str, value: impl Into<Value>) -> Self {
75 self.args.insert(key.to_owned(), value.into());
76 self
77 }
78}
79
80/// Why arbitration could not be decided. Each variant carries the underlying message.
81///
82/// Every variant is fail-closed: [`SingleInstance::claim`] never turns one into a second primary on
83/// its own. A user must still never be locked out of launching the app, so the app decides what
84/// happens next, and the crate bounds how long each step can take so that decision is reached quickly:
85///
86/// * a live-primary forward retries the connect for at most 2 s (20 attempts, 100 ms apart) and sends
87/// once;
88/// * a legacy forward waits at most 5 s for the old holder to answer (Windows `SendMessageTimeoutW`
89/// with abort-if-hung; macOS a 5 s write timeout, then 2 s for the holder to close the connection
90/// it was written to, see [`ClaimOutcome::Forwarded`]);
91/// * hosting the claim waits at most 5 s for an election;
92/// * so one `claim` call returns within about 14 s in the worst case.
93///
94/// The crate enforces those bounds; the app enforces the rest (below). An app should show or log
95/// the reason, and on a variant it cannot recover from, start anyway after at most that bound rather
96/// than exit silently, unless a second copy would corrupt data, in which case it should tell the user
97/// why it will not start and how to end the other copy.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum ClaimFailure {
100 /// The suite root (the per-user service directory) could not be resolved. Nothing was hosted, so
101 /// no other copy can have been told about this launch. Safe to log and continue unarbitrated.
102 Discovery(String),
103 /// The primary endpoint could not be hosted, for a reason other than a live primary. Usually a
104 /// permissions or disk problem. Continue unarbitrated only if duplicates are harmless; otherwise
105 /// report it.
106 Host(String),
107 /// A live primary exists, but it could not be reached or did not accept the message within the
108 /// bound above (hung, or mid-shutdown). Do not start a second primary silently: tell the user
109 /// the running copy is not responding, and offer to retry. A retry after the bound is safe.
110 Forward(String),
111 /// A live instance started by `tauri-plugin-single-instance` holds the claim and could not take
112 /// this launch within the bound (a hung old build). The caller must not start a second primary
113 /// silently; report that the older copy is not responding and how to close it.
114 Legacy(String),
115 /// The id cannot be turned into a service name (empty, or longer than 39 bytes). The id is
116 /// normalised first (ASCII lowercase, every character outside `[a-z0-9-]` becomes `-`), so a
117 /// dotted bundle identifier is fine. This is a caller bug: nothing was hosted and protection is
118 /// off, so fix the id rather than continuing unarbitrated.
119 InvalidId(String),
120}
121
122impl fmt::Display for ClaimFailure {
123 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124 match self {
125 Self::Discovery(m) => write!(f, "single-instance discovery failed: {m}"),
126 Self::Host(m) => write!(f, "single-instance host failed: {m}"),
127 Self::Forward(m) => write!(f, "single-instance forward failed: {m}"),
128 Self::Legacy(m) => write!(f, "single-instance legacy holder refused the launch: {m}"),
129 Self::InvalidId(m) => write!(f, "single-instance id is not usable: {m}"),
130 }
131 }
132}
133
134impl std::error::Error for ClaimFailure {}
135
136/// Every primary claim this process holds. Guards refer to an entry by token, so a guard does not
137/// own the endpoint directly: [`destroy`] (or dropping the guard) releases it.
138static CLAIMS: Mutex<Vec<(u64, Hosted)>> = Mutex::new(Vec::new());
139static NEXT_CLAIM: AtomicU64 = AtomicU64::new(1);
140/// The legacy claims held and serviced alongside a primary claim, by the primary's token.
141static LEGACY_HOLDS: Mutex<Vec<(u64, crate::legacy_hold::LegacyHold)>> = Mutex::new(Vec::new());
142
143fn legacy_holds() -> std::sync::MutexGuard<'static, Vec<(u64, crate::legacy_hold::LegacyHold)>> {
144 LEGACY_HOLDS.lock().unwrap_or_else(|e| e.into_inner())
145}
146
147fn claims() -> std::sync::MutexGuard<'static, Vec<(u64, Hosted)>> {
148 CLAIMS.lock().unwrap_or_else(|e| e.into_inner())
149}
150
151/// Keep a hosted endpoint alive for the process (or until [`destroy`]). Returns its token.
152pub(crate) fn hold(hosted: Hosted) -> u64 {
153 let token = NEXT_CLAIM.fetch_add(1, Ordering::Relaxed);
154 claims().push((token, hosted));
155 token
156}
157
158/// Release one claim by token. Unknown tokens are ignored.
159pub(crate) fn release(token: u64) {
160 let removed = {
161 let mut held = claims();
162 held.iter()
163 .position(|(t, _)| *t == token)
164 .map(|i| held.remove(i))
165 };
166 drop(removed);
167 let legacy = {
168 let mut held = legacy_holds();
169 held.iter()
170 .position(|(t, _)| *t == token)
171 .map(|i| held.remove(i))
172 };
173 drop(legacy);
174}
175
176/// Release every claim this process holds.
177pub(crate) fn release_all() {
178 let released: Vec<(u64, Hosted)> = std::mem::take(&mut *claims());
179 drop(released);
180 let legacy = std::mem::take(&mut *legacy_holds());
181 drop(legacy);
182}
183
184type DestroyHook = Box<dyn FnOnce() + Send>;
185
186/// Releases registered with [`SingleInstance::on_destroy`], run once by [`SingleInstance::destroy`].
187static DESTROY_HOOKS: Mutex<Vec<DestroyHook>> = Mutex::new(Vec::new());
188
189fn run_destroy_hooks() {
190 let hooks: Vec<DestroyHook> =
191 std::mem::take(&mut *DESTROY_HOOKS.lock().unwrap_or_else(|e| e.into_inner()));
192 for hook in hooks {
193 // A panicking hook must not stop the remaining releases.
194 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(hook));
195 }
196}
197
198/// Every name `tauri-plugin-single-instance` 2.x derives from the Tauri identifier
199/// (`tauri.conf.json` `identifier`). Read from plugin versions 2.4.2 and 2.4.3
200/// (`src/platform_impl/{windows,macos,linux}.rs`), with the plugin's `semver` feature off. HeardRight
201/// confirmed the names at its locked 2.4.5 (verified by the consumer; 2.4.5 source was not read here). The
202/// derivation is pure, so it is the same on every host.
203///
204/// For HeardRight (`app.heardright.next`):
205///
206/// | Platform | Resource | Name |
207/// |---|---|---|
208/// | Windows | named mutex | `app.heardright.next-sim` |
209/// | Windows | window class | `app.heardright.next-sic` |
210/// | Windows | window title | `app.heardright.next-siw` |
211/// | macOS | unix socket | `/tmp/app_heardright_next_si.sock` |
212/// | Linux | D-Bus well-known name | `app.heardright.next.SingleInstance` |
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub struct LegacySingleInstanceNames {
215 /// Windows: `<identifier>-sim`, verbatim. The mutex that arbitrates the claim.
216 pub windows_mutex: String,
217 /// Windows: `<identifier>-sic`, verbatim. The class of the hidden window that receives
218 /// `WM_COPYDATA` (`dwData == 1542`).
219 pub windows_window_class: String,
220 /// Windows: `<identifier>-siw`, verbatim. The title of that window.
221 pub windows_window_title: String,
222 /// macOS: `/tmp/<identifier>_si.sock`, with every `.` and `-` in the identifier replaced by `_`.
223 pub macos_socket: std::path::PathBuf,
224 /// Linux: `<identifier>.SingleInstance`, verbatim. This crate does not probe it.
225 pub linux_dbus_name: String,
226}
227
228impl LegacySingleInstanceNames {
229 /// Derive the names for a Tauri `identifier`.
230 pub fn for_identifier(identifier: &str) -> Self {
231 Self {
232 windows_mutex: format!("{identifier}-sim"),
233 windows_window_class: format!("{identifier}-sic"),
234 windows_window_title: format!("{identifier}-siw"),
235 macos_socket: std::path::PathBuf::from(format!(
236 "/tmp/{}_si.sock",
237 crate::legacy_instance::socket_stem(identifier)
238 )),
239 linux_dbus_name: format!("{identifier}.SingleInstance"),
240 }
241 }
242}
243
244/// Holds one primary claim. Dropping it releases the claim, the same as [`destroy`] does for all.
245pub struct SingleInstanceGuard {
246 token: u64,
247}
248
249impl Drop for SingleInstanceGuard {
250 fn drop(&mut self) {
251 release(self.token);
252 }
253}
254
255/// Options for [`SingleInstance::claim_with_options`].
256#[derive(Debug, Clone, Default, PartialEq, Eq)]
257pub struct ClaimOptions {
258 /// The Tauri identifier (`tauri.conf.json`) the legacy `tauri-plugin-single-instance` keyed its
259 /// endpoint by. When set, a live legacy holder is probed first and receives this launch, and an
260 /// unreachable one is a [`ClaimFailure::Legacy`]. `None` skips the probe.
261 pub legacy_identifier: Option<String>,
262 /// With `legacy_identifier`: also HOLD and SERVICE the legacy claim while this process is the
263 /// primary, so an older build (on the plugin) launched later finds a live holder, forwards its
264 /// launch and exits, instead of starting a second instance. Default `false`.
265 ///
266 /// The launch the old build forwards (its cwd and argv) reaches the same `on_message` callback,
267 /// on a dedicated thread, as an [`InstanceMessage`] with no action. The claim is released by
268 /// [`SingleInstance::destroy`] or by dropping the guard: the service thread is stopped and
269 /// joined, and the mutex, window or socket is gone when that returns.
270 ///
271 /// Order, which makes it atomic with becoming primary: the primary endpoint is hosted first; only
272 /// the process that won it takes the legacy claim, so two new builds cannot both hold it. If a
273 /// live old build already holds the legacy claim, the primary is released and this launch is
274 /// forwarded to it, as without this option. A second launch of the new build never probes the
275 /// legacy claim; it forwards over the typed channel.
276 ///
277 /// Do not combine with `tauri_plugin_single_instance` registered in the same process: both would
278 /// claim the same names. Windows and macOS only; on Linux (D-Bus) the hold is a no-op.
279 pub hold_legacy: bool,
280}
281
282impl ClaimOptions {
283 /// Probe, and forward to, a live legacy holder keyed by `identifier`.
284 pub fn with_legacy(identifier: &str) -> Self {
285 Self {
286 legacy_identifier: Some(identifier.to_owned()),
287 hold_legacy: false,
288 }
289 }
290
291 /// Also hold and service the legacy claim (see [`ClaimOptions::hold_legacy`]).
292 pub fn holding_legacy(mut self) -> Self {
293 self.hold_legacy = true;
294 self
295 }
296}
297
298/// The typed result of [`SingleInstance::claim`].
299#[must_use = "a Forwarded or Failed launch must exit; ignoring the outcome continues the launch"]
300pub enum ClaimOutcome {
301 /// This process is the primary. Keep the guard alive.
302 Primary(SingleInstanceGuard),
303 /// A live primary accepted this launch's message. Exit.
304 ///
305 /// For a legacy `tauri-plugin-single-instance` holder on macOS the plugin has no reply, so
306 /// "accepted" means it read the launch to its end and closed the connection, or, when it stayed
307 /// silent for 2 s, that its socket belongs to this app's own process. That silent case cannot be
308 /// told from a slow callback, so it is still `Forwarded` but is logged loudly on stderr as
309 /// "forwarded but NOT confirmed". A socket whose owning process has exited, or belongs to another
310 /// program and never answers, is not a holder (a descriptor leaked into a sidecar can keep such a
311 /// socket accepting after the app is gone): the claim carries on and this process can become the
312 /// primary.
313 Forwarded,
314 /// Arbitration could not be decided. Do not continue as a primary.
315 Failed(ClaimFailure),
316}
317
318impl ClaimOutcome {
319 /// True only for [`ClaimOutcome::Primary`].
320 pub fn is_primary(&self) -> bool {
321 matches!(self, Self::Primary(_))
322 }
323}
324
325/// Standalone single-instance arbitration.
326pub struct SingleInstance;
327
328impl SingleInstance {
329 /// Release every single-instance claim this process holds, so a relaunched process (for an update,
330 /// say) can become the primary before this one exits. Guards stay valid and own nothing afterwards.
331 ///
332 /// What it releases:
333 ///
334 /// * every claim made through this type (the rightkit-daemon endpoint), and
335 /// * every release registered with [`SingleInstance::on_destroy`], each run exactly once.
336 ///
337 /// It is idempotent: a second call finds nothing left and does nothing. It does not touch any
338 /// `tauri-plugin-single-instance` claim unless a hook for it was registered, because
339 /// [`ClaimOptions::legacy_identifier`] only *probes* for a legacy holder; this type never creates
340 /// a legacy claim, and the plugin's mutex and window (Windows), unix-socket listener (macOS) and
341 /// D-Bus name (Linux) are owned by the plugin's own state inside the app. A process that still
342 /// registers the plugin must release it too (see [`SingleInstance::on_destroy`]), or the
343 /// relaunched process is refused as a second instance.
344 pub fn destroy() {
345 run_destroy_hooks();
346 release_all();
347 }
348
349 /// Register a release that [`SingleInstance::destroy`] runs once, before it drops this type's
350 /// own claims. This is how an app that still registers `tauri-plugin-single-instance` makes one
351 /// `destroy()` call release both claims:
352 ///
353 /// ```text
354 /// let handle = app.handle().clone();
355 /// SingleInstance::on_destroy(move || tauri_plugin_single_instance::destroy(&handle));
356 /// // relaunch-for-update:
357 /// SingleInstance::destroy(); // plugin claim, then rightkit claim
358 /// app.exit(0);
359 /// ```
360 ///
361 /// Without the hook, call `tauri_plugin_single_instance::destroy(&app)` and
362 /// `SingleInstance::destroy()` both, in either order, before spawning or exiting.
363 ///
364 /// The plugin's `destroy` closes its mutex handle (Windows) or removes its socket file (macOS),
365 /// which frees the name for the relaunch. On Windows it also calls `DestroyWindow` for its hidden
366 /// window; Win32 allows that only on the thread that created the window, so from another thread
367 /// the window lingers until the process exits. The relaunched process is unaffected, because
368 /// arbitration there is the mutex, and a new mutex is created.
369 pub fn on_destroy(hook: impl FnOnce() + Send + 'static) {
370 DESTROY_HOOKS
371 .lock()
372 .unwrap_or_else(|e| e.into_inner())
373 .push(Box::new(hook));
374 }
375
376 /// Claim the primary for `id` (lowercase letters, digits and `-`). `on_message` receives each
377 /// launch that is forwarded to this primary. The outgoing message is this process's argv and cwd.
378 pub fn claim(
379 id: &str,
380 on_message: impl Fn(InstanceMessage) + Send + Sync + 'static,
381 ) -> ClaimOutcome {
382 Self::claim_with(id, InstanceMessage::from_process(), on_message)
383 }
384
385 /// Like [`SingleInstance::claim`], but the outgoing message (used when this launch is forwarded)
386 /// is `message`, for example one carrying an action and arguments.
387 pub fn claim_with(
388 id: &str,
389 message: InstanceMessage,
390 on_message: impl Fn(InstanceMessage) + Send + Sync + 'static,
391 ) -> ClaimOutcome {
392 Self::claim_with_options(id, ClaimOptions::default(), message, on_message)
393 }
394
395 /// [`SingleInstance::claim_with`] with options, such as the legacy identifier to probe.
396 pub fn claim_with_options(
397 id: &str,
398 options: ClaimOptions,
399 message: InstanceMessage,
400 on_message: impl Fn(InstanceMessage) + Send + Sync + 'static,
401 ) -> ClaimOutcome {
402 match Suite::resolve() {
403 Ok(suite) => Self::claim_in_with(&suite, id, options, message, on_message),
404 Err(e) => ClaimOutcome::Failed(ClaimFailure::Discovery(e.to_string())),
405 }
406 }
407
408 /// Claim inside an explicit suite root. Tests use this; apps normally call [`SingleInstance::claim`].
409 pub fn claim_in(
410 suite: &Suite,
411 id: &str,
412 message: InstanceMessage,
413 on_message: impl Fn(InstanceMessage) + Send + Sync + 'static,
414 ) -> ClaimOutcome {
415 Self::claim_in_with(suite, id, ClaimOptions::default(), message, on_message)
416 }
417
418 /// [`SingleInstance::claim_in`] with options.
419 pub fn claim_in_with(
420 suite: &Suite,
421 id: &str,
422 options: ClaimOptions,
423 message: InstanceMessage,
424 on_message: impl Fn(InstanceMessage) + Send + Sync + 'static,
425 ) -> ClaimOutcome {
426 let service = match crate::instance::derive_service_name(id, "-instance") {
427 Ok(name) => name,
428 Err(e) => {
429 eprintln!("rightkit-tauri-shell: single-instance is NOT active, invalid id: {e}");
430 return ClaimOutcome::Failed(ClaimFailure::InvalidId(e.to_string()));
431 }
432 };
433 let client_id = crate::instance::normalize_app_id(id);
434 let hold_identifier = options
435 .legacy_identifier
436 .clone()
437 .filter(|_| options.hold_legacy);
438 if hold_identifier.is_none() {
439 if let Some(identifier) = options.legacy_identifier.as_deref() {
440 let argv = message.argv.clone();
441 match crate::legacy_instance::forward_checked(identifier, &argv, &message.cwd) {
442 Ok(Some(delivery)) => {
443 delivery.log_unconfirmed("SingleInstance::claim");
444 return ClaimOutcome::Forwarded;
445 }
446 Ok(None) => {}
447 Err(e) => return ClaimOutcome::Failed(ClaimFailure::Legacy(e)),
448 }
449 }
450 }
451 let mut cfg = ServiceConfig::new(
452 &service,
453 &format!("instance:{id}"),
454 100,
455 env!("CARGO_PKG_VERSION"),
456 Allowlist::new().allow(&client_id, [MESSAGE_METHOD]),
457 );
458 // A claim must come back quickly (see `ClaimFailure`): bound the election.
459 cfg.takeover_timeout = HOST_TIMEOUT;
460 let on_message: crate::legacy_hold::OnMessage = Arc::new(on_message);
461 let handler = Arc::new(Receive {
462 on_message: on_message.clone(),
463 });
464 match host(suite, cfg, handler) {
465 Ok(hosted) => {
466 let Some(identifier) = hold_identifier else {
467 return ClaimOutcome::Primary(SingleInstanceGuard {
468 token: hold(hosted),
469 });
470 };
471 match crate::legacy_hold::acquire(
472 &identifier,
473 &message.argv,
474 &message.cwd,
475 on_message,
476 ) {
477 Ok(crate::legacy_hold::Acquired::Held(legacy)) => {
478 let token = hold(hosted);
479 legacy_holds().push((token, legacy));
480 ClaimOutcome::Primary(SingleInstanceGuard { token })
481 }
482 Ok(crate::legacy_hold::Acquired::Forwarded) => {
483 drop(hosted);
484 ClaimOutcome::Forwarded
485 }
486 Err(e) => {
487 drop(hosted);
488 ClaimOutcome::Failed(ClaimFailure::Legacy(e))
489 }
490 }
491 }
492 Err(HostError::Held(_)) => forward(suite, &client_id, &service, &message),
493 Err(e) => ClaimOutcome::Failed(ClaimFailure::Host(e.to_string())),
494 }
495 }
496}
497
498/// Connect to the live primary (retrying while it starts), then deliver the message once. Only the
499/// connect is retried: a call that failed after the primary received it is never repeated, so the
500/// callback cannot run twice for one launch.
501fn forward(suite: &Suite, id: &str, service: &str, message: &InstanceMessage) -> ClaimOutcome {
502 let params = match serde_json::to_value(message) {
503 Ok(params) => params,
504 Err(e) => return ClaimOutcome::Failed(ClaimFailure::Forward(e.to_string())),
505 };
506 let mut last = String::from("live primary did not accept a connection");
507 for _ in 0..CONNECT_ATTEMPTS {
508 match Client::connect(suite, service, id) {
509 Ok(client) => {
510 return match client.call(MESSAGE_METHOD, params) {
511 Ok(_) => ClaimOutcome::Forwarded,
512 Err(e) => ClaimOutcome::Failed(ClaimFailure::Forward(e.to_string())),
513 };
514 }
515 Err(e) => {
516 last = e.to_string();
517 std::thread::sleep(CONNECT_RETRY);
518 }
519 }
520 }
521 ClaimOutcome::Failed(ClaimFailure::Forward(last))
522}
523
524struct Receive {
525 on_message: crate::legacy_hold::OnMessage,
526}
527
528impl Handler for Receive {
529 fn call(
530 &self,
531 _cx: &CallContext,
532 method: &str,
533 params: &Value,
534 _emit: &Emit,
535 ) -> Result<Value, ServiceError> {
536 if method != MESSAGE_METHOD {
537 return Err(ServiceError::new("unknown_method", method.to_string()));
538 }
539 // A malformed payload (wrong types, args that are not an object) is refused, not guessed at.
540 let message: InstanceMessage = serde_json::from_value(params.clone())
541 .map_err(|e| ServiceError::new("invalid_message", e.to_string()))?;
542 (self.on_message)(message);
543 Ok(json!({"ok": true}))
544 }
545}