1use std::path::{Path, PathBuf};
20
21use serde::{Deserialize, Serialize};
22#[cfg(not(windows))]
23use std::fs;
24
25use crate::login_item::{self, LoginItemStatus};
26
27pub const BACKGROUND_ARG: &str = "--background";
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub enum MacosBackend {
32 #[default]
35 LaunchAgent,
36 ServiceManagement,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct AutostartOptions {
51 pub args: Vec<String>,
53 pub low_priority_io: bool,
56 pub migrate_from_plugin_entry: bool,
59 pub startup_wm_class: Option<String>,
62 pub config_home: Option<PathBuf>,
68 pub macos_unsupported: bool,
71 pub macos_backend: MacosBackend,
73}
74
75pub fn reports_unsupported(options: &AutostartOptions, target_macos: bool) -> bool {
77 target_macos && options.macos_unsupported
78}
79
80impl Default for AutostartOptions {
81 fn default() -> Self {
82 Self {
83 args: vec![BACKGROUND_ARG.to_owned()],
84 low_priority_io: false,
85 migrate_from_plugin_entry: false,
86 startup_wm_class: None,
87 config_home: None,
88 macos_unsupported: false,
89 macos_backend: MacosBackend::LaunchAgent,
90 }
91 }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "camelCase")]
96pub struct AutostartStatus {
97 pub supported: bool,
98 pub enabled: bool,
99 pub entry: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub login_item: Option<LoginItemStatus>,
106}
107
108pub struct AutostartSpec {
109 pub app_id: String,
111 pub app_name: String,
112 pub executable: PathBuf,
113 pub options: AutostartOptions,
114 pub legacy_plugin_name: Option<String>,
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
121#[serde(rename_all = "snake_case")]
122pub enum AutostartMigration {
123 NoLegacy,
125 Migrated,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct LegacyAutostartNames {
133 pub windows_run_value: String,
138 pub macos_launch_agent_label: String,
140 pub macos_plist_relative: PathBuf,
142 pub linux_desktop_relative: PathBuf,
145}
146
147impl LegacyAutostartNames {
148 pub fn for_entry_name(name: &str) -> Self {
150 Self {
151 windows_run_value: name.to_owned(),
152 macos_launch_agent_label: name.to_owned(),
153 macos_plist_relative: PathBuf::from("Library/LaunchAgents")
154 .join(format!("{name}.plist")),
155 linux_desktop_relative: PathBuf::from(".config/autostart")
156 .join(format!("{name}.desktop")),
157 }
158 }
159}
160
161fn xml(s: &str) -> String {
162 s.replace('&', "&")
163 .replace('<', "<")
164 .replace('>', ">")
165 .replace('"', """)
166}
167
168pub fn macos_program_arguments(exe: &Path) -> Vec<String> {
170 macos_program_arguments_with(exe, &[BACKGROUND_ARG.to_owned()])
171}
172
173pub fn macos_program_arguments_with(exe: &Path, args: &[String]) -> Vec<String> {
176 let s = exe.to_string_lossy();
177 let mut out: Vec<String>;
178 let in_bundle = s.find(".app/Contents/MacOS/");
179 if let Some(idx) = in_bundle {
180 let bundle = &s[..idx + 4];
181 out = vec![
182 "/usr/bin/open".into(),
183 "-g".into(),
184 "-a".into(),
185 bundle.into(),
186 ];
187 if !args.is_empty() {
188 out.push("--args".into());
189 }
190 } else {
191 out = vec![s.into_owned()];
192 }
193 out.extend(args.iter().cloned());
194 out
195}
196
197pub fn macos_plist(label: &str, args: &[String]) -> String {
198 macos_plist_with(label, args, false)
199}
200
201pub fn macos_plist_with(label: &str, args: &[String], low_priority_io: bool) -> String {
203 let items: String = args
204 .iter()
205 .map(|a| format!(" <string>{}</string>\n", xml(a)))
206 .collect();
207 let low = if low_priority_io {
208 " <key>LowPriorityIO</key>\n <true/>\n"
209 } else {
210 ""
211 };
212 format!(
213 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n <key>Label</key>\n <string>{}</string>\n <key>ProgramArguments</key>\n <array>\n{items} </array>\n <key>RunAtLoad</key>\n <true/>\n <key>ProcessType</key>\n <string>Interactive</string>\n{low}</dict>\n</plist>\n",
214 xml(label)
215 )
216}
217
218fn desktop_exec_quote(v: &str) -> String {
219 if !v
220 .chars()
221 .any(|c| matches!(c, ' ' | '\t' | '\n' | '"' | '$' | '`' | '\\'))
222 {
223 return v.to_owned();
224 }
225 let mut out = String::from("\"");
226 for c in v.chars() {
227 if matches!(c, '"' | '$' | '`' | '\\') {
228 out.push('\\');
229 }
230 out.push(c);
231 }
232 out.push('"');
233 out
234}
235
236pub fn linux_desktop_entry(name: &str, exe: &Path) -> String {
237 linux_desktop_entry_with(name, exe, &[BACKGROUND_ARG.to_owned()])
238}
239
240pub fn linux_desktop_entry_with(name: &str, exe: &Path, args: &[String]) -> String {
242 linux_desktop_entry_for(name, exe, args, None)
243}
244
245pub fn linux_desktop_entry_for(
248 name: &str,
249 exe: &Path,
250 args: &[String],
251 wm_class: Option<&str>,
252) -> String {
253 let class = wm_class
256 .filter(|c| !c.chars().any(char::is_control))
257 .map(str::trim)
258 .filter(|c| !c.is_empty())
259 .map(|c| format!("StartupWMClass={c}\n"))
260 .unwrap_or_default();
261 let mut exec = desktop_exec_quote(&exe.to_string_lossy());
262 for arg in args {
263 exec.push(' ');
264 exec.push_str(&desktop_exec_quote(arg));
265 }
266 format!("[Desktop Entry]\nType=Application\nName={name}\nExec={exec}\nTerminal=false\n{class}X-GNOME-Autostart-enabled=true\n")
267}
268
269pub fn windows_run_command(exe: &Path, args: &[String]) -> String {
271 let mut command = format!("\"{}\"", exe.display());
272 for arg in args {
273 command.push(' ');
274 if arg.contains(' ') || arg.contains('"') {
275 command.push('"');
276 command.push_str(&arg.replace('"', "\\\""));
277 command.push('"');
278 } else {
279 command.push_str(arg);
280 }
281 }
282 command
283}
284
285#[cfg_attr(windows, allow(dead_code))]
289fn home() -> Option<PathBuf> {
290 std::env::var_os("RIGHTKIT_SHELL_HOME")
291 .filter(|v| !v.is_empty())
292 .or_else(|| std::env::var_os("HOME"))
293 .or_else(|| std::env::var_os("USERPROFILE"))
294 .map(PathBuf::from)
295}
296
297#[cfg(not(windows))]
300fn login_item_path(stem: &str, config_home: Option<&Path>) -> Option<PathBuf> {
301 #[cfg(target_os = "macos")]
302 {
303 config_home
304 .map(Path::to_path_buf)
305 .or_else(home)
306 .map(|h| h.join("Library/LaunchAgents").join(format!("{stem}.plist")))
307 }
308 #[cfg(all(unix, not(target_os = "macos")))]
309 {
310 if let Some(cfg) = config_home {
311 return Some(cfg.join("autostart").join(format!("{stem}.desktop")));
312 }
313 let cfg = std::env::var_os("XDG_CONFIG_HOME")
314 .filter(|v| !v.is_empty())
315 .map(PathBuf::from)
316 .or_else(|| home().map(|h| h.join(".config")))?;
317 Some(cfg.join("autostart").join(format!("{stem}.desktop")))
318 }
319}
320
321#[cfg(not(windows))]
323fn legacy_login_item_path(stem: &str, config_home: Option<&Path>) -> Option<PathBuf> {
324 let names = LegacyAutostartNames::for_entry_name(stem);
325 #[cfg(target_os = "macos")]
326 {
327 config_home
328 .map(Path::to_path_buf)
329 .or_else(home)
330 .map(|h| h.join(names.macos_plist_relative))
331 }
332 #[cfg(not(target_os = "macos"))]
333 {
334 match config_home {
335 Some(cfg) => Some(cfg.join("autostart").join(format!("{stem}.desktop"))),
337 None => home().map(|h| h.join(names.linux_desktop_relative)),
338 }
339 }
340}
341
342#[cfg_attr(not(unix), allow(dead_code))]
346fn our_stem(spec: &AutostartSpec) -> String {
347 #[cfg(target_os = "macos")]
348 {
349 format!("{}.autostart", spec.app_id)
350 }
351 #[cfg(not(target_os = "macos"))]
352 {
353 spec.app_id.clone()
354 }
355}
356
357pub fn entry_path(spec: &AutostartSpec) -> Option<PathBuf> {
359 #[cfg(windows)]
360 {
361 let _ = spec;
362 None
363 }
364 #[cfg(not(windows))]
365 {
366 login_item_path(&our_stem(spec), spec.options.config_home.as_deref())
367 }
368}
369
370#[cfg(windows)]
371const RUN_KEY: &str = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run";
372#[cfg(windows)]
373const STARTUP_APPROVED_KEY: &str =
374 r"HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run";
375
376#[derive(Debug, Clone, PartialEq, Eq)]
380pub struct RegistryKeys {
381 pub run: String,
383 pub startup_approved: String,
386}
387
388#[allow(clippy::derivable_impls)] impl Default for RegistryKeys {
390 fn default() -> Self {
391 #[cfg(windows)]
392 {
393 Self {
394 run: RUN_KEY.to_owned(),
395 startup_approved: STARTUP_APPROVED_KEY.to_owned(),
396 }
397 }
398 #[cfg(not(windows))]
399 {
400 Self {
401 run: String::new(),
402 startup_approved: String::new(),
403 }
404 }
405 }
406}
407
408pub fn startup_approved_is_disabled(bytes: &[u8]) -> bool {
414 bytes.len() >= 8 && bytes.iter().rev().take(8).any(|b| *b != 0)
415}
416
417#[cfg(windows)]
418fn reg(args: &[&str]) -> Result<std::process::Output, String> {
419 use std::os::windows::process::CommandExt;
420 std::process::Command::new("reg.exe")
421 .creation_flags(0x0800_0000)
422 .args(args)
423 .output()
424 .map_err(|e| format!("run reg.exe: {e}"))
425}
426
427#[cfg(windows)]
429fn reg_read_binary(key: &str, name: &str) -> Option<Vec<u8>> {
430 let out = reg(&["QUERY", key, "/v", name]).ok()?;
431 if !out.status.success() {
432 return None;
433 }
434 let text = String::from_utf8_lossy(&out.stdout);
435 let line = text.lines().find(|l| l.contains("REG_BINARY"))?;
436 let hex = line.split("REG_BINARY").nth(1)?.trim();
437 if hex.len() % 2 != 0 {
438 return None;
439 }
440 (0..hex.len())
441 .step_by(2)
442 .map(|i| u8::from_str_radix(hex.get(i..i + 2)?, 16).ok())
443 .collect()
444}
445
446#[cfg(windows)]
447fn reg_write_binary(key: &str, name: &str, bytes: &[u8]) -> Result<(), String> {
448 let hex: String = bytes.iter().map(|b| format!("{b:02X}")).collect();
449 let out = reg(&["ADD", key, "/v", name, "/t", "REG_BINARY", "/d", &hex, "/f"])?;
450 if out.status.success() {
451 Ok(())
452 } else {
453 Err(String::from_utf8_lossy(&out.stderr).trim().to_owned())
454 }
455}
456
457pub fn uses_service_management(spec: &AutostartSpec) -> bool {
459 cfg!(target_os = "macos")
460 && login_item::service_management_selected(
461 spec.options.macos_backend == MacosBackend::ServiceManagement,
462 login_item::available(),
463 login_item::executable_in_app_bundle(&spec.executable),
464 spec.options.config_home.is_some()
465 || std::env::var_os("RIGHTKIT_SHELL_HOME").is_some_and(|v| !v.is_empty()),
466 )
467}
468
469fn set_enabled_service_management(
472 spec: &AutostartSpec,
473 enabled: bool,
474) -> Result<AutostartStatus, String> {
475 if enabled {
476 login_item::register()?;
477 } else {
478 login_item::unregister()?;
479 }
480 remove_entry(spec)?;
481 Ok(status(spec))
482}
483
484pub fn migrate_launch_agent(spec: &AutostartSpec) -> Result<AutostartMigration, String> {
491 if reports_unsupported(&spec.options, cfg!(target_os = "macos"))
492 || !uses_service_management(spec)
493 {
494 return Ok(AutostartMigration::NoLegacy);
495 }
496 let Some(path) = entry_path(spec) else {
497 return Ok(AutostartMigration::NoLegacy);
498 };
499 if !path.exists() {
500 return Ok(AutostartMigration::NoLegacy);
501 }
502 login_item::register()?;
503 remove_entry(spec)?;
504 Ok(AutostartMigration::Migrated)
505}
506
507pub fn status(spec: &AutostartSpec) -> AutostartStatus {
508 if reports_unsupported(&spec.options, cfg!(target_os = "macos")) {
509 return AutostartStatus {
510 supported: false,
511 enabled: false,
512 entry: None,
513 login_item: None,
514 };
515 }
516 if uses_service_management(spec) {
517 return match login_item::status() {
518 Ok(item) => AutostartStatus {
519 supported: true,
520 enabled: item.is_enabled(),
521 entry: None,
522 login_item: Some(item),
523 },
524 Err(_) => AutostartStatus {
525 supported: false,
526 enabled: false,
527 entry: None,
528 login_item: None,
529 },
530 };
531 }
532 #[cfg(windows)]
533 {
534 let enabled = reg(&["QUERY", RUN_KEY, "/v", &spec.app_id])
535 .map(|o| o.status.success())
536 .unwrap_or(false);
537 AutostartStatus {
538 supported: true,
539 enabled,
540 entry: None,
541 login_item: None,
542 }
543 }
544 #[cfg(not(windows))]
545 {
546 match entry_path(spec) {
547 Some(p) => AutostartStatus {
548 supported: true,
549 enabled: p.is_file(),
550 entry: Some(p.to_string_lossy().into_owned()),
551 login_item: None,
552 },
553 None => AutostartStatus {
554 supported: false,
555 enabled: false,
556 entry: None,
557 login_item: None,
558 },
559 }
560 }
561}
562
563pub fn set_enabled(spec: &AutostartSpec, enabled: bool) -> Result<AutostartStatus, String> {
566 if reports_unsupported(&spec.options, cfg!(target_os = "macos")) {
567 return if enabled {
569 Err("login launch is not supported by this app on macOS".into())
570 } else {
571 Ok(status(spec))
572 };
573 }
574 if uses_service_management(spec) {
575 set_enabled_service_management(spec, enabled)?;
576 } else if enabled {
577 write_entry(&RegistryKeys::default(), spec)?;
578 } else {
579 remove_entry(spec)?;
580 }
581 if spec.options.migrate_from_plugin_entry {
582 if let Some(legacy) = spec.legacy_plugin_name.as_deref() {
583 remove_legacy(&RegistryKeys::default(), spec, legacy)?;
584 }
585 }
586 Ok(status(spec))
587}
588
589pub fn migrate_plugin_entry(
593 spec: &AutostartSpec,
594 legacy_name: &str,
595) -> Result<AutostartMigration, String> {
596 migrate_plugin_entry_in(&RegistryKeys::default(), spec, legacy_name)
597}
598
599pub fn migrate_plugin_entry_in(
608 keys: &RegistryKeys,
609 spec: &AutostartSpec,
610 legacy_name: &str,
611) -> Result<AutostartMigration, String> {
612 if reports_unsupported(&spec.options, cfg!(target_os = "macos")) {
613 return Ok(AutostartMigration::NoLegacy);
615 }
616 if !legacy_present(keys, legacy_name, spec.options.config_home.as_deref()) {
617 return Ok(AutostartMigration::NoLegacy);
618 }
619 #[cfg(windows)]
620 let carried = reg_read_binary(&keys.startup_approved, legacy_name)
621 .filter(|bytes| startup_approved_is_disabled(bytes));
622 if uses_service_management(spec) {
623 login_item::register()?;
624 remove_entry(spec)?;
625 } else {
626 write_entry(keys, spec)?;
627 }
628 #[cfg(windows)]
629 if let Some(bytes) = carried {
630 reg_write_binary(&keys.startup_approved, &spec.app_id, &bytes)?;
631 }
632 remove_legacy(keys, spec, legacy_name)?;
633 Ok(AutostartMigration::Migrated)
634}
635
636fn write_entry(keys: &RegistryKeys, spec: &AutostartSpec) -> Result<(), String> {
637 #[cfg(not(windows))]
638 let _ = keys;
639 #[cfg(windows)]
640 {
641 let command = windows_run_command(&spec.executable, &spec.options.args);
642 let out = reg(&[
643 "ADD",
644 &keys.run,
645 "/v",
646 &spec.app_id,
647 "/t",
648 "REG_SZ",
649 "/d",
650 &command,
651 "/f",
652 ])?;
653 if !out.status.success() {
654 return Err(String::from_utf8_lossy(&out.stderr).trim().to_owned());
655 }
656 Ok(())
657 }
658 #[cfg(not(windows))]
659 {
660 let Some(path) = entry_path(spec) else {
661 return Ok(());
662 };
663 if let Some(dir) = path.parent() {
664 fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
665 }
666 #[cfg(target_os = "macos")]
667 let body = macos_plist_with(
668 &our_stem(spec),
669 &macos_program_arguments_with(&spec.executable, &spec.options.args),
670 spec.options.low_priority_io,
671 );
672 #[cfg(not(target_os = "macos"))]
673 let body = linux_desktop_entry_for(
674 &spec.app_name,
675 &spec.executable,
676 &spec.options.args,
677 spec.options.startup_wm_class.as_deref(),
678 );
679 fs::write(&path, body).map_err(|e| format!("write {}: {e}", path.display()))
680 }
681}
682
683fn remove_entry(spec: &AutostartSpec) -> Result<(), String> {
684 #[cfg(windows)]
685 {
686 let _ = reg(&["DELETE", RUN_KEY, "/v", &spec.app_id, "/f"]);
688 Ok(())
689 }
690 #[cfg(not(windows))]
691 {
692 let Some(path) = entry_path(spec) else {
693 return Ok(());
694 };
695 match fs::remove_file(&path) {
696 Ok(()) => Ok(()),
697 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
698 Err(e) => Err(format!("remove {}: {e}", path.display())),
699 }
700 }
701}
702
703fn legacy_present(keys: &RegistryKeys, legacy_name: &str, config_home: Option<&Path>) -> bool {
704 #[cfg(not(windows))]
705 let _ = keys;
706 #[cfg(windows)]
707 let _ = config_home;
708 #[cfg(windows)]
709 {
710 reg(&["QUERY", &keys.run, "/v", legacy_name])
711 .map(|o| o.status.success())
712 .unwrap_or(false)
713 }
714 #[cfg(not(windows))]
715 {
716 legacy_login_item_path(legacy_name, config_home).is_some_and(|p| p.exists())
717 }
718}
719
720fn remove_legacy(
722 keys: &RegistryKeys,
723 spec: &AutostartSpec,
724 legacy_name: &str,
725) -> Result<bool, String> {
726 #[cfg(not(windows))]
727 let _ = keys;
728 #[cfg(windows)]
729 {
730 if legacy_name == spec.app_id
731 || !legacy_present(keys, legacy_name, spec.options.config_home.as_deref())
732 {
733 return Ok(false);
734 }
735 let out = reg(&["DELETE", &keys.run, "/v", legacy_name, "/f"])?;
736 if !out.status.success() {
737 return Err(String::from_utf8_lossy(&out.stderr).trim().to_owned());
738 }
739 let _ = reg(&["DELETE", &keys.startup_approved, "/v", legacy_name, "/f"]);
741 Ok(true)
742 }
743 #[cfg(not(windows))]
744 {
745 let Some(legacy) = legacy_login_item_path(legacy_name, spec.options.config_home.as_deref())
746 else {
747 return Ok(false);
748 };
749 if entry_path(spec).as_ref() == Some(&legacy) || !legacy.exists() {
750 return Ok(false);
751 }
752 fs::remove_file(&legacy).map_err(|e| format!("remove {}: {e}", legacy.display()))?;
753 Ok(true)
754 }
755}