1use 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 Primary(Hosted),
31 Secondary,
33 Unavailable(String),
35 InvalidAppId(InvalidAppId),
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum InvalidAppIdReason {
44 Empty,
46 TooLong { name_len: usize, max: usize },
48 Rejected(String),
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct InvalidAppId {
55 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
86pub(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
101pub(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
150pub fn try_service_name(app_id: &str) -> Result<String, InvalidAppId> {
155 derive_service_name(app_id, "-shell")
156}
157
158pub fn service_name(app_id: &str) -> String {
161 format!("{}-shell", normalize_app_id(app_id))
162}
163
164pub 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
180pub 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 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 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 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 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 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}