Skip to main content

rightkit_tauri_shell/
instance.rs

1//! Single-instance arbitration over `rightkit-daemon` (no extra plugin, no lock file).
2//!
3//! The first process hosts the `<service-id>-shell` service (see [`service_name`] for how an app id
4//! becomes `<service-id>`) on the suite's current-user-only socket /
5//! named pipe. A later process finds a live record, forwards its argv and working directory with
6//! `second-instance`, and exits. Because liveness is a probed endpoint plus a live pid, a crashed
7//! primary never blocks the next launch (a stale record is discarded).
8//!
9//! Donors: all three apps used `tauri-plugin-single-instance`; this keeps its contract
10//! (callback with argv + cwd) and adds suite isolation for tests.
11
12use std::sync::Arc;
13
14use rightkit_daemon::{
15    host, valid_service_name, Allowlist, CallContext, Client, Emit, Handler, HostError, Hosted,
16    ServiceConfig, ServiceError, Suite, MAX_SERVICE_NAME_LEN,
17};
18use serde_json::{json, Value};
19
20pub const METHOD: &str = "second-instance";
21
22#[derive(Debug, Clone)]
23pub struct SecondInstance {
24    pub argv: Vec<String>,
25    pub cwd: String,
26}
27
28pub enum Arbitration {
29    /// This process is the instance; keep the guard alive for the process lifetime.
30    Primary(Hosted),
31    /// Another live instance exists and was told about this launch.
32    Secondary,
33    /// No suite root could be resolved (fail closed on discovery, proceed without arbitration).
34    Unavailable(String),
35    /// The app id cannot be turned into a service name (see [`service_name`]). This is a bug in the
36    /// caller, not an environment problem: arbitration did not run, so single-instance protection
37    /// would be OFF if this were ignored. Never start as a second primary on it; fix the id.
38    InvalidAppId(InvalidAppId),
39}
40
41/// Why an app id cannot name a single-instance service.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum InvalidAppIdReason {
44    /// The id is empty.
45    Empty,
46    /// The derived service name exceeds [`MAX_SERVICE_NAME_LEN`] bytes.
47    TooLong { name_len: usize, max: usize },
48    /// `rightkit-daemon` refused the derived name for a reason this crate did not predict.
49    Rejected(String),
50}
51
52/// An app id that cannot be turned into a valid `rightkit-daemon` name.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct InvalidAppId {
55    /// The id as the caller passed it.
56    pub app_id: String,
57    pub reason: InvalidAppIdReason,
58}
59
60impl std::fmt::Display for InvalidAppId {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        match &self.reason {
63            InvalidAppIdReason::Empty => {
64                write!(
65                    f,
66                    "app id {:?} is empty; it cannot name a service",
67                    self.app_id
68                )
69            }
70            InvalidAppIdReason::TooLong { name_len, max } => write!(
71                f,
72                "app id {:?} gives a {name_len}-byte service name; the limit is {max}",
73                self.app_id
74            ),
75            InvalidAppIdReason::Rejected(name) => write!(
76                f,
77                "app id {:?} gives service name {name:?}, which rightkit-daemon refused",
78                self.app_id
79            ),
80        }
81    }
82}
83
84impl std::error::Error for InvalidAppId {}
85
86/// Normalise an app id for use inside a service name: ASCII-lowercase it, then replace every
87/// character outside `[a-z0-9-]` with `-` (one `-` per character; nothing is collapsed or trimmed).
88/// `"app.mailright"` becomes `"app-mailright"`, `"Mail_Right"` becomes `"mail-right"`. An id that is
89/// already valid is returned unchanged. Distinct ids can normalise to the same string
90/// (`"a.b"` and `"a-b"`), which makes them one app for single-instance purposes.
91pub(crate) fn normalize_app_id(app_id: &str) -> String {
92    app_id
93        .chars()
94        .map(|c| match c.to_ascii_lowercase() {
95            c if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' => c,
96            _ => '-',
97        })
98        .collect()
99}
100
101/// `<normalised app id><suffix>`, validated against `rightkit-daemon`'s rule.
102pub(crate) fn derive_service_name(app_id: &str, suffix: &str) -> Result<String, InvalidAppId> {
103    let name = format!("{}{suffix}", normalize_app_id(app_id));
104    let fail = |reason| InvalidAppId {
105        app_id: app_id.to_owned(),
106        reason,
107    };
108    if normalize_app_id(app_id).is_empty() {
109        return Err(fail(InvalidAppIdReason::Empty));
110    }
111    if name.len() > MAX_SERVICE_NAME_LEN {
112        return Err(fail(InvalidAppIdReason::TooLong {
113            name_len: name.len(),
114            max: MAX_SERVICE_NAME_LEN,
115        }));
116    }
117    debug_assert!(valid_service_name(&name), "normalisation produced {name:?}");
118    Ok(name)
119}
120
121struct Forward {
122    on_second: Arc<dyn Fn(SecondInstance) + Send + Sync>,
123}
124
125impl Handler for Forward {
126    fn call(
127        &self,
128        _cx: &CallContext,
129        method: &str,
130        params: &Value,
131        _emit: &Emit,
132    ) -> Result<Value, ServiceError> {
133        if method != METHOD {
134            return Err(ServiceError::new("unknown_method", method.to_string()));
135        }
136        let argv = params["argv"]
137            .as_array()
138            .map(|a| {
139                a.iter()
140                    .filter_map(|v| v.as_str().map(str::to_string))
141                    .collect()
142            })
143            .unwrap_or_default();
144        let cwd = params["cwd"].as_str().unwrap_or_default().to_string();
145        (self.on_second)(SecondInstance { argv, cwd });
146        Ok(json!({"ok": true}))
147    }
148}
149
150/// The service name arbitration hosts for `app_id`: the normalised id plus `-shell`,
151/// so an id that is already `[a-z0-9-]` gives exactly `<id>-shell` (running instances keep being
152/// found across upgrades) and `"app.mailright"` gives `"app-mailright-shell"`. `rightkit-daemon`
153/// allows only 1 to 48 bytes of `[a-z0-9-]`, so an empty id or one longer than 42 bytes is refused.
154pub fn try_service_name(app_id: &str) -> Result<String, InvalidAppId> {
155    derive_service_name(app_id, "-shell")
156}
157
158/// [`try_service_name`] without the check: the normalised name even when it is empty or too long,
159/// which `arbitrate` would refuse. Prefer [`try_service_name`].
160pub fn service_name(app_id: &str) -> String {
161    format!("{}-shell", normalize_app_id(app_id))
162}
163
164/// Arbitrate single-instance for `app_id`.
165///
166/// Naming rule: the service is named `<app id>-shell` after the id is ASCII-lowercased and every
167/// character outside `[a-z0-9-]` becomes `-` (so `"app.mailright"` and `"app-mailright"` are the
168/// same app). An empty id, or one whose service name would exceed 48 bytes (id longer than 42),
169/// returns [`Arbitration::InvalidAppId`], never [`Arbitration::Unavailable`]: a naming mistake is
170/// not an environment failure and must not be treated as "arbitration is off". The caller must not
171/// continue as primary on `InvalidAppId` or `Unavailable`.
172pub fn arbitrate(
173    app_id: &str,
174    version: &str,
175    on_second: Arc<dyn Fn(SecondInstance) + Send + Sync>,
176) -> Arbitration {
177    arbitrate_with_legacy(app_id, version, None, on_second)
178}
179
180/// Arbitration that also probes for a live `tauri-plugin-single-instance` holder under
181/// `legacy_identifier` (the Tauri identifier) first. A live legacy holder receives this launch and
182/// the result is [`Arbitration::Secondary`]; an unreachable one is [`Arbitration::Unavailable`]. A
183/// socket that only a leaked descriptor keeps open is not a live holder, and a macOS holder that
184/// stays silent is logged on stderr as unconfirmed (see
185/// [`ClaimOutcome::Forwarded`](crate::single_instance::ClaimOutcome::Forwarded)).
186pub fn arbitrate_with_legacy(
187    app_id: &str,
188    version: &str,
189    legacy_identifier: Option<&str>,
190    on_second: Arc<dyn Fn(SecondInstance) + Send + Sync>,
191) -> Arbitration {
192    // Name first: a bad id must surface even when the suite root also cannot be resolved.
193    if let Err(e) = try_service_name(app_id) {
194        return invalid_app_id(e);
195    }
196    let suite = match Suite::resolve() {
197        Ok(s) => s,
198        Err(e) => return Arbitration::Unavailable(e.to_string()),
199    };
200    arbitrate_in_suite_with(&suite, app_id, version, legacy_identifier, on_second)
201}
202
203fn invalid_app_id(e: InvalidAppId) -> Arbitration {
204    eprintln!("rightkit-tauri-shell: single-instance is NOT active, invalid app id: {e}");
205    Arbitration::InvalidAppId(e)
206}
207
208#[cfg(test)]
209fn arbitrate_in_suite(
210    suite: &Suite,
211    app_id: &str,
212    version: &str,
213    on_second: Arc<dyn Fn(SecondInstance) + Send + Sync>,
214) -> Arbitration {
215    arbitrate_in_suite_with(suite, app_id, version, None, on_second)
216}
217
218fn arbitrate_in_suite_with(
219    suite: &Suite,
220    app_id: &str,
221    version: &str,
222    legacy_identifier: Option<&str>,
223    on_second: Arc<dyn Fn(SecondInstance) + Send + Sync>,
224) -> Arbitration {
225    let service = match try_service_name(app_id) {
226        Ok(name) => name,
227        Err(e) => return invalid_app_id(e),
228    };
229    let argv: Vec<String> = std::env::args().collect();
230    let cwd = std::env::current_dir()
231        .map(|p| p.to_string_lossy().into_owned())
232        .unwrap_or_default();
233    if let Some(identifier) = legacy_identifier {
234        match crate::legacy_instance::forward_checked(identifier, &argv, &cwd) {
235            Ok(Some(delivery)) => {
236                delivery.log_unconfirmed("instance::arbitrate_with_legacy");
237                return Arbitration::Secondary;
238            }
239            Ok(None) => {}
240            Err(e) => {
241                return Arbitration::Unavailable(format!(
242                    "a legacy single-instance holder refused this launch: {e}"
243                ))
244            }
245        }
246    }
247    // Caller identity on both ends is the normalised id, so equivalent spellings agree.
248    let client_id = normalize_app_id(app_id);
249    let cfg = ServiceConfig::new(
250        &service,
251        &format!("shell:{app_id}"),
252        100,
253        version,
254        Allowlist::new().allow(&client_id, [METHOD]),
255    );
256    match host(suite, cfg, Arc::new(Forward { on_second })) {
257        Ok(hosted) => Arbitration::Primary(hosted),
258        Err(HostError::Held(_)) => {
259            // The incumbent may be mid-startup: retry the connect only. Once a call has reached it,
260            // the launch is delivered, so a failed call is never repeated.
261            let mut connected = None;
262            for _ in 0..20 {
263                match Client::connect(suite, &service, &client_id) {
264                    Ok(client) => {
265                        connected = Some(client);
266                        break;
267                    }
268                    Err(_) => std::thread::sleep(std::time::Duration::from_millis(100)),
269                }
270            }
271            let Some(client) = connected else {
272                return Arbitration::Unavailable(
273                    "live instance did not accept a connection for the forwarded launch".into(),
274                );
275            };
276            match client.call(METHOD, json!({"argv": argv, "cwd": cwd})) {
277                Ok(_) => Arbitration::Secondary,
278                Err(e) => Arbitration::Unavailable(format!(
279                    "live instance did not accept the forwarded launch: {e}"
280                )),
281            }
282        }
283        Err(HostError::InvalidName(name)) => invalid_app_id(InvalidAppId {
284            app_id: app_id.to_owned(),
285            reason: InvalidAppIdReason::Rejected(name),
286        }),
287        Err(e) => Arbitration::Unavailable(e.to_string()),
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use std::process::{Command, Stdio};
295    use std::sync::mpsc;
296    use std::time::Duration;
297
298    #[test]
299    fn arbitration_forwards_secondary_launch_and_releases_primary_guard() {
300        let suite = Suite::per_run_temp("shell-instance").unwrap();
301        let (tx, rx) = mpsc::channel();
302        let primary = match arbitrate_in_suite(
303            &suite,
304            "test-shell",
305            "1.0.0",
306            Arc::new(move |second| {
307                tx.send(second).unwrap();
308            }),
309        ) {
310            Arbitration::Primary(hosted) => hosted,
311            _ => panic!("first launch must host arbitration"),
312        };
313        // Arbitration checks process identity, so the secondary must be a separate process.
314        let child = Command::new(std::env::current_exe().unwrap())
315            .args([
316                "--exact",
317                "instance::tests::secondary_process",
318                "--nocapture",
319            ])
320            .env("RK_SHELL_INSTANCE_TEST_ROOT", suite.root())
321            .stdout(Stdio::null())
322            .status()
323            .unwrap();
324        assert!(child.success());
325        let forwarded = rx.recv_timeout(Duration::from_secs(5)).unwrap();
326        assert!(forwarded
327            .argv
328            .iter()
329            .any(|arg| arg == "instance::tests::secondary_process"));
330        assert_eq!(
331            forwarded.cwd,
332            std::env::current_dir().unwrap().to_string_lossy()
333        );
334        drop(primary);
335        assert!(matches!(
336            arbitrate_in_suite(&suite, "test-shell", "1.0.0", Arc::new(|_| {})),
337            Arbitration::Primary(_)
338        ));
339    }
340
341    fn kind(a: &Arbitration) -> &'static str {
342        match a {
343            Arbitration::Primary(_) => "primary",
344            Arbitration::Secondary => "secondary",
345            Arbitration::Unavailable(_) => "unavailable",
346            Arbitration::InvalidAppId(_) => "invalid-app-id",
347        }
348    }
349
350    #[test]
351    fn valid_ids_keep_their_exact_service_name() {
352        assert_eq!(try_service_name("mailright").unwrap(), "mailright-shell");
353        assert_eq!(
354            try_service_name("view-right2").unwrap(),
355            "view-right2-shell"
356        );
357        assert_eq!(service_name("mailright"), "mailright-shell");
358        assert_eq!(normalize_app_id("test-shell"), "test-shell");
359    }
360
361    #[test]
362    fn dotted_uppercase_and_underscore_ids_normalise() {
363        assert_eq!(
364            try_service_name("app.mailright").unwrap(),
365            "app-mailright-shell"
366        );
367        assert_eq!(try_service_name("Mail_Right").unwrap(), "mail-right-shell");
368        assert_eq!(try_service_name("a..b").unwrap(), "a--b-shell");
369        assert_eq!(try_service_name("é").unwrap(), "--shell");
370        assert!(valid_service_name(
371            &try_service_name("App.Mail_Right").unwrap()
372        ));
373    }
374
375    #[test]
376    fn unnameable_ids_are_typed_errors_not_unavailable() {
377        let empty = try_service_name("").unwrap_err();
378        assert_eq!(empty.reason, InvalidAppIdReason::Empty);
379        let long = "a".repeat(43);
380        let err = try_service_name(&long).unwrap_err();
381        assert_eq!(
382            err.reason,
383            InvalidAppIdReason::TooLong {
384                name_len: 49,
385                max: 48
386            }
387        );
388        assert!(try_service_name(&"a".repeat(42)).is_ok());
389        let suite = Suite::per_run_temp("shell-instance-invalid").unwrap();
390        assert_eq!(
391            kind(&arbitrate_in_suite(&suite, "", "1.0.0", Arc::new(|_| {}))),
392            "invalid-app-id"
393        );
394        assert_eq!(
395            kind(&arbitrate_in_suite(
396                &suite,
397                &long,
398                "1.0.0",
399                Arc::new(|_| {})
400            )),
401            "invalid-app-id"
402        );
403        assert_eq!(
404            kind(&arbitrate("", "1.0.0", Arc::new(|_| {}))),
405            "invalid-app-id"
406        );
407    }
408
409    #[test]
410    fn dotted_id_second_launch_sees_the_primary() {
411        let suite = Suite::per_run_temp("shell-instance-dotted").unwrap();
412        let (tx, rx) = mpsc::channel();
413        let primary = arbitrate_in_suite(
414            &suite,
415            "app.mailright",
416            "1.0.0",
417            Arc::new(move |second| {
418                tx.send(second).unwrap();
419            }),
420        );
421        assert_eq!(kind(&primary), "primary");
422        // The secondary asks with a differently spelled but equivalent id.
423        let status = Command::new(std::env::current_exe().unwrap())
424            .args([
425                "--exact",
426                "instance::tests::dotted_secondary_process",
427                "--nocapture",
428            ])
429            .env("RK_SHELL_INSTANCE_DOTTED_ROOT", suite.root())
430            .stdout(Stdio::null())
431            .status()
432            .unwrap();
433        assert!(status.success());
434        rx.recv_timeout(Duration::from_secs(5)).unwrap();
435        drop(primary);
436    }
437
438    #[test]
439    fn dotted_secondary_process() {
440        let Some(root) = std::env::var_os("RK_SHELL_INSTANCE_DOTTED_ROOT") else {
441            return;
442        };
443        let suite = Suite::at(std::path::PathBuf::from(root)).unwrap();
444        let outcome = arbitrate_in_suite(
445            &suite,
446            "App.MailRight",
447            "1.0.0",
448            Arc::new(|_| panic!("secondary callback must not run")),
449        );
450        assert_eq!(kind(&outcome), "secondary");
451    }
452
453    #[test]
454    fn secondary_process() {
455        let Some(root) = std::env::var_os("RK_SHELL_INSTANCE_TEST_ROOT") else {
456            return;
457        };
458        let suite = Suite::at(std::path::PathBuf::from(root)).unwrap();
459        assert!(matches!(
460            arbitrate_in_suite(
461                &suite,
462                "test-shell",
463                "1.0.0",
464                Arc::new(|_| panic!("secondary callback must not run"))
465            ),
466            Arbitration::Secondary
467        ));
468    }
469}