Skip to main content

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}