1#[cfg(windows)]
59use agentmux_common::win32::NoWindow;
60use std::path::Path;
61
62pub const BACKGROUND_FLAG: &str = "--background";
70
71pub fn background_requested(args: &[String]) -> bool {
77 args.iter().any(|a| a == BACKGROUND_FLAG)
78}
79
80pub fn login_start() -> bool {
83 background_requested(&std::env::args().collect::<Vec<_>>())
84}
85
86pub fn second_instance_opens_window(args: &[String]) -> bool {
91 !background_requested(args)
92}
93
94pub fn background_env_for(args: &[String]) -> Vec<(&'static str, &'static str)> {
106 if background_requested(args) {
107 vec![
108 ("AGENTMUX_BACKGROUND_SERVICE", "1"),
109 ("AGENTMUX_TRAY", "1"),
110 ]
111 } else {
112 Vec::new()
113 }
114}
115
116#[cfg(any(target_os = "windows", target_os = "linux", test))]
121pub const AUTOSTART_ID: &str = "AgentMux";
122
123#[cfg(any(not(target_os = "windows"), test))]
125pub const MACOS_LABEL: &str = "com.agentmux.launcher";
126
127#[cfg(any(target_os = "windows", test))]
144pub fn scheduled_task_xml(exe: &Path, channel: &str) -> String {
145 let exe = xml_escape(&exe.display().to_string());
146 let channel = xml_escape(channel);
147 format!(
148 r#"<?xml version="1.0" encoding="UTF-16"?>
149<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
150 <RegistrationInfo>
151 <Description>Starts AgentMux in the background at logon. Created by AgentMux; remove it by turning off auto-start in AgentMux, or with: schtasks /Delete /TN "{id}" /F {marker}{channel}]</Description>
152 <URI>\{id}</URI>
153 </RegistrationInfo>
154 <Triggers>
155 <LogonTrigger>
156 <Enabled>true</Enabled>
157 <Delay>PT30S</Delay>
158 </LogonTrigger>
159 </Triggers>
160 <Principals>
161 <Principal id="Author">
162 <LogonType>InteractiveToken</LogonType>
163 <RunLevel>LeastPrivilege</RunLevel>
164 </Principal>
165 </Principals>
166 <Settings>
167 <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
168 <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
169 <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
170 <AllowHardTerminate>false</AllowHardTerminate>
171 <StartWhenAvailable>false</StartWhenAvailable>
172 <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
173 <IdleSettings>
174 <StopOnIdleEnd>false</StopOnIdleEnd>
175 <RestartOnIdle>false</RestartOnIdle>
176 </IdleSettings>
177 <AllowStartOnDemand>true</AllowStartOnDemand>
178 <Enabled>true</Enabled>
179 <Hidden>false</Hidden>
180 <RunOnlyIfIdle>false</RunOnlyIfIdle>
181 <WakeToRun>false</WakeToRun>
182 <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
183 <Priority>7</Priority>
184 </Settings>
185 <Actions Context="Author">
186 <Exec>
187 <Command>"{exe}"</Command>
188 <Arguments>--background</Arguments>
189 </Exec>
190 </Actions>
191</Task>
192"#,
193 id = AUTOSTART_ID,
194 exe = exe,
195 marker = TASK_CHANNEL_MARKER,
196 channel = channel,
197 )
198}
199
200const TASK_CHANNEL_MARKER: &str = "[agentmux-channel=";
203
204#[cfg(any(not(target_os = "windows"), test))]
212pub fn launch_agent_plist(exe: &Path, channel: &str) -> String {
213 let exe = xml_escape(&exe.display().to_string());
214 let channel = xml_escape(channel);
215 format!(
216 r#"<?xml version="1.0" encoding="UTF-8"?>
217<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
218<plist version="1.0">
219<dict>
220 <key>Label</key>
221 <string>{label}</string>
222 <key>ProgramArguments</key>
223 <array>
224 <string>{exe}</string>
225 <string>--background</string>
226 </array>
227 <key>RunAtLoad</key>
228 <true/>
229 <key>KeepAlive</key>
230 <false/>
231 <key>ProcessType</key>
232 <string>Interactive</string>
233 <key>{channel_key}</key>
234 <string>{channel}</string>
235</dict>
236</plist>
237"#,
238 label = MACOS_LABEL,
239 exe = exe,
240 channel_key = PLIST_CHANNEL_KEY,
241 channel = channel,
242 )
243}
244
245const PLIST_CHANNEL_KEY: &str = "AgentMuxChannel";
247
248#[cfg(any(not(target_os = "windows"), test))]
260pub fn xdg_desktop_entry(exe: &Path, channel: &str) -> String {
261 format!(
262 "[Desktop Entry]\n\
263 Type=Application\n\
264 Name=AgentMux\n\
265 Comment=Starts AgentMux in the background at login\n\
266 TryExec={try_exec}\n\
267 Exec=\"{exe}\" {flag}\n\
268 Terminal=false\n\
269 X-GNOME-Autostart-enabled=true\n\
270 X-GNOME-Autostart-Delay=10\n\
271 {channel_key}={channel}\n",
272 try_exec = desktop_string_escape(&exe.display().to_string()),
273 exe = desktop_exec_escape(exe),
274 flag = BACKGROUND_FLAG,
275 channel_key = XDG_CHANNEL_KEY,
276 channel = desktop_string_escape(channel),
277 )
278}
279
280const XDG_CHANNEL_KEY: &str = "X-AgentMux-Channel";
282
283#[cfg(any(not(target_os = "windows"), test))]
287fn desktop_string_escape(s: &str) -> String {
288 let mut out = String::with_capacity(s.len());
289 for c in s.chars() {
290 match c {
291 '\\' => out.push_str("\\\\"),
292 '\n' => out.push_str("\\n"),
293 '\t' => out.push_str("\\t"),
294 '\r' => out.push_str("\\r"),
295 _ => out.push(c),
296 }
297 }
298 out
299}
300
301fn desktop_string_unescape(s: &str) -> String {
302 let mut out = String::with_capacity(s.len());
303 let mut it = s.chars();
304 while let Some(c) = it.next() {
305 if c != '\\' {
306 out.push(c);
307 continue;
308 }
309 match it.next() {
310 Some('n') => out.push('\n'),
311 Some('t') => out.push('\t'),
312 Some('r') => out.push('\r'),
313 Some('s') => out.push(' '),
314 Some(o) => out.push(o),
315 None => out.push('\\'),
316 }
317 }
318 out
319}
320
321#[cfg(any(not(target_os = "windows"), test))]
340fn desktop_exec_escape(exe: &Path) -> String {
341 let raw = exe.display().to_string();
342 let mut out = String::with_capacity(raw.len());
343 for c in raw.chars() {
344 match c {
345 '"' | '\\' | '`' | '$' => {
347 out.push('\\');
348 out.push(c);
349 }
350 '%' => out.push_str("%%"),
352 '\n' | '\r' => {}
354 _ => out.push(c),
355 }
356 }
357 out
358}
359
360fn xml_escape(s: &str) -> String {
368 let mut out = String::with_capacity(s.len());
369 for c in s.chars() {
370 match c {
371 '&' => out.push_str("&"),
372 '<' => out.push_str("<"),
373 '>' => out.push_str(">"),
374 '"' => out.push_str("""),
375 '\'' => out.push_str("'"),
376 _ => out.push(c),
377 }
378 }
379 out
380}
381
382#[cfg(not(target_os = "windows"))]
386pub fn artifact_path() -> Option<std::path::PathBuf> {
387 #[cfg(target_os = "macos")]
388 {
389 let home = std::env::var("HOME").ok()?;
390 Some(
391 Path::new(&home)
392 .join("Library/LaunchAgents")
393 .join(format!("{}.plist", MACOS_LABEL)),
394 )
395 }
396 #[cfg(target_os = "linux")]
397 {
398 let base = std::env::var("XDG_CONFIG_HOME")
400 .ok()
401 .filter(|s| !s.is_empty())
402 .unwrap_or_else(|| format!("{}/.config", std::env::var("HOME").unwrap_or_default()));
403 Some(
404 Path::new(&base)
405 .join("autostart")
406 .join(format!("{}.desktop", AUTOSTART_ID.to_lowercase())),
407 )
408 }
409 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
410 {
411 None
412 }
413}
414
415#[cfg(target_os = "windows")]
421fn schtasks_command() -> std::process::Command {
422 let mut cmd = std::process::Command::new("schtasks");
423 cmd.no_window();
424 cmd
425}
426
427pub fn is_enabled() -> bool {
435 #[cfg(target_os = "windows")]
436 {
437 schtasks_command()
439 .args(["/Query", "/TN", AUTOSTART_ID])
440 .stdout(std::process::Stdio::null())
441 .stderr(std::process::Stdio::null())
442 .status()
443 .map(|s| s.success())
444 .unwrap_or(false)
445 }
446 #[cfg(not(target_os = "windows"))]
447 {
448 artifact_path().map(|p| p.exists()).unwrap_or(false)
449 }
450}
451
452pub fn enable(exe: &Path, channel: &str) -> Result<(), String> {
456 #[cfg(target_os = "windows")]
457 {
458 let xml = scheduled_task_xml(exe, channel);
463 let dir = std::env::temp_dir();
464 let path = dir.join(format!("agentmux-autostart-{}.xml", std::process::id()));
465 let mut bytes: Vec<u8> = vec![0xFF, 0xFE]; for unit in xml.encode_utf16() {
467 bytes.extend_from_slice(&unit.to_le_bytes());
468 }
469 std::fs::write(&path, &bytes).map_err(|e| format!("write task xml: {}", e))?;
470
471 let out = schtasks_command()
476 .args(["/Create", "/TN", AUTOSTART_ID, "/XML"])
477 .arg(&path)
478 .arg("/F") .output();
480 let _ = std::fs::remove_file(&path);
482
483 match out {
484 Ok(o) if o.status.success() => Ok(()),
485 Ok(o) => Err(format!(
486 "schtasks /Create failed ({}): {}{}",
487 o.status,
488 String::from_utf8_lossy(&o.stderr).trim(),
489 String::from_utf8_lossy(&o.stdout).trim()
490 )),
491 Err(e) => Err(format!("run schtasks: {}", e)),
492 }
493 }
494 #[cfg(not(target_os = "windows"))]
495 {
496 let path = artifact_path().ok_or_else(|| "unsupported platform".to_string())?;
497 let contents = if cfg!(target_os = "macos") {
498 launch_agent_plist(exe, channel)
499 } else {
500 xdg_desktop_entry(exe, channel)
501 };
502 if let Some(parent) = path.parent() {
503 std::fs::create_dir_all(parent)
504 .map_err(|e| format!("create {}: {}", parent.display(), e))?;
505 }
506 std::fs::write(&path, contents).map_err(|e| format!("write {}: {}", path.display(), e))
507 }
508}
509
510pub fn disable() -> Result<(), String> {
518 #[cfg(target_os = "windows")]
519 {
520 if !is_enabled() {
521 return Ok(());
522 }
523 match schtasks_command()
524 .args(["/Delete", "/TN", AUTOSTART_ID, "/F"])
525 .stdout(std::process::Stdio::null())
526 .stderr(std::process::Stdio::null())
527 .status()
528 {
529 Ok(s) if s.success() => Ok(()),
530 Ok(s) => Err(format!("schtasks /Delete failed: {}", s)),
531 Err(e) => Err(format!("run schtasks: {}", e)),
532 }
533 }
534 #[cfg(not(target_os = "windows"))]
535 {
536 let Some(path) = artifact_path() else {
537 return Ok(());
538 };
539 match std::fs::remove_file(&path) {
540 Ok(()) => Ok(()),
541 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
544 Err(e) => Err(format!("remove {}: {}", path.display(), e)),
545 }
546 }
547}
548
549pub fn current_channel() -> String {
568 std::env::current_exe()
569 .ok()
570 .and_then(|exe| {
571 let dir = exe.parent()?.to_path_buf();
572 crate::data_dir::resolve_paths(&dir, env!("CARGO_PKG_VERSION")).ok()
573 })
574 .map(|p| p.common.channel)
575 .unwrap_or_else(|| "unknown".to_string())
576}
577
578const STABLE_EXE_ENV: &str = "AGENTMUX_STABLE_EXE";
581
582static STABLE_EXE: std::sync::OnceLock<Option<std::ffi::OsString>> = std::sync::OnceLock::new();
583
584pub fn take_stable_exe_env() {
591 let value = std::env::var_os(STABLE_EXE_ENV).filter(|v| !v.is_empty());
592 std::env::remove_var(STABLE_EXE_ENV);
593 let _ = STABLE_EXE.set(value);
594}
595
596pub fn stable_target() -> Result<std::path::PathBuf, String> {
604 stable_target_from(
605 STABLE_EXE.get().cloned().flatten(),
606 std::env::current_exe().map_err(|e| e.to_string()),
607 )
608}
609
610fn stable_target_from(
611 stable: Option<std::ffi::OsString>,
612 current: Result<std::path::PathBuf, String>,
613) -> Result<std::path::PathBuf, String> {
614 match stable.map(std::path::PathBuf::from) {
615 Some(p) if p.is_file() => Ok(p),
616 _ => current,
617 }
618}
619
620#[derive(Debug, Clone, PartialEq, Eq)]
622pub struct Entry {
623 pub target: Option<String>,
625 pub channel: Option<String>,
628}
629
630pub fn read_entry() -> Option<Entry> {
632 #[cfg(target_os = "windows")]
633 {
634 let out = schtasks_command()
635 .args(["/Query", "/TN", AUTOSTART_ID, "/XML"])
636 .stderr(std::process::Stdio::null())
637 .output()
638 .ok()?;
639 if !out.status.success() {
640 return None;
641 }
642 Some(parse_task_xml(&decode_console_output(&out.stdout)))
643 }
644 #[cfg(not(target_os = "windows"))]
645 {
646 let text = std::fs::read_to_string(artifact_path()?).ok()?;
647 Some(if cfg!(target_os = "macos") {
648 parse_plist(&text)
649 } else {
650 parse_desktop_entry(&text)
651 })
652 }
653}
654
655#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
657fn decode_console_output(bytes: &[u8]) -> String {
658 let body = bytes.strip_prefix(&[0xFF, 0xFE]).unwrap_or(bytes);
659 if body.len() >= 2 && body.len() % 2 == 0 && body.iter().skip(1).step_by(2).take(64).all(|b| *b == 0) {
660 let units: Vec<u16> = body.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect();
661 return String::from_utf16_lossy(&units);
662 }
663 String::from_utf8_lossy(body).into_owned()
664}
665
666fn between<'a>(s: &'a str, start: &str, end: &str) -> Option<&'a str> {
667 let from = s.find(start)? + start.len();
668 let len = s[from..].find(end)?;
669 Some(&s[from..from + len])
670}
671
672fn xml_unescape(s: &str) -> String {
673 s.replace("<", "<")
674 .replace(">", ">")
675 .replace(""", "\"")
676 .replace("'", "'")
677 .replace("&", "&")
678}
679
680#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
681fn parse_task_xml(xml: &str) -> Entry {
682 let target = between(xml, "<Command>", "</Command>")
683 .map(|c| xml_unescape(c.trim()).trim_matches('"').to_string());
684 let channel = between(xml, "<Description>", "</Description>")
685 .and_then(|d| between(d, TASK_CHANNEL_MARKER, "]"))
686 .map(xml_unescape);
687 Entry { target, channel }
688}
689
690#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
691fn parse_plist(text: &str) -> Entry {
692 let target = between(text, "<key>ProgramArguments</key>", "</array>")
693 .and_then(|a| between(a, "<string>", "</string>"))
694 .map(xml_unescape);
695 let channel = between(text, &format!("<key>{PLIST_CHANNEL_KEY}</key>"), "</string>")
696 .and_then(|rest| rest.split_once("<string>").map(|(_, v)| xml_unescape(v)));
697 Entry { target, channel }
698}
699
700#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
701fn parse_desktop_entry(text: &str) -> Entry {
702 let value = |key: &str| {
703 text.lines()
704 .find_map(|l| l.strip_prefix(key).and_then(|r| r.strip_prefix('=')))
705 .map(desktop_string_unescape)
706 };
707 Entry { target: value("TryExec"), channel: value(XDG_CHANNEL_KEY) }
710}
711
712#[derive(Debug, Clone, PartialEq, Eq)]
715pub enum Plan {
716 Nothing,
718 Write,
720 Remove,
722 Adopt,
726}
727
728pub fn plan(setting: Option<bool>, entry: Option<&Entry>, channel: &str, target: Option<&str>) -> Plan {
740 let owned = |e: &Entry| e.channel.as_deref() == Some(channel);
741 match (setting, entry) {
742 (Some(true), Some(e)) if owned(e) && target.is_some() && e.target.as_deref() == target => Plan::Nothing,
743 (Some(true), _) => Plan::Write,
744 (Some(false), Some(e)) if owned(e) => Plan::Remove,
745 (None, Some(e)) if e.channel.is_none() => Plan::Adopt,
746 _ => Plan::Nothing,
747 }
748}
749
750pub fn handle_cli(args: &[String]) -> bool {
751 let has = |v: &str| args.iter().any(|a| a == v);
752
753 if has("--autostart-status") {
754 println!("{}", if is_enabled() { "enabled" } else { "disabled" });
755 return true;
756 }
757 if has("--enable-autostart") {
758 match stable_target() {
759 Ok(exe) => match enable(&exe, ¤t_channel()) {
760 Ok(()) => println!("auto-start enabled ({})", exe.display()),
761 Err(e) => {
762 eprintln!("failed to enable auto-start: {}", e);
763 std::process::exit(1);
764 }
765 },
766 Err(e) => {
767 eprintln!("failed to resolve the auto-start target: {}", e);
768 std::process::exit(1);
769 }
770 }
771 return true;
772 }
773 if has("--disable-autostart") {
774 match disable() {
775 Ok(()) => println!("auto-start disabled"),
776 Err(e) => {
777 eprintln!("failed to disable auto-start: {}", e);
778 std::process::exit(1);
779 }
780 }
781 return true;
782 }
783 false
784}
785
786#[cfg(test)]
787mod autostart_tests {
788 use super::*;
789 use std::path::PathBuf;
790
791 fn exe() -> PathBuf {
792 PathBuf::from(r"C:\Program Files\AgentMux\agentmux.exe")
793 }
794
795 #[test]
796 fn scheduled_task_uses_interactive_token_not_a_service_account() {
797 let xml = scheduled_task_xml(&exe(), "test");
802 assert!(xml.contains("<LogonType>InteractiveToken</LogonType>"));
803 assert!(
804 xml.contains("<RunLevel>LeastPrivilege</RunLevel>"),
805 "auto-start must never require elevation"
806 );
807 }
808
809 #[test]
810 fn scheduled_task_survives_battery_power() {
811 let xml = scheduled_task_xml(&exe(), "test");
816 assert!(xml.contains("<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>"));
817 assert!(xml.contains("<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>"));
818 }
819
820 #[test]
821 fn scheduled_task_has_no_execution_time_limit() {
822 let xml = scheduled_task_xml(&exe(), "test");
825 assert!(xml.contains("<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>"));
826 }
827
828 #[test]
829 fn generated_xml_escapes_paths() {
830 let xml = scheduled_task_xml(Path::new(r"C:\Tools & Apps\<agentmux>.exe"), "test");
834 assert!(xml.contains("Tools & Apps"));
835 assert!(xml.contains("<agentmux>"));
836 assert!(
837 !xml.contains("Tools & Apps"),
838 "raw ampersand would make the document invalid"
839 );
840 }
841
842 #[test]
843 fn launch_agent_does_not_let_launchd_resurrect_a_user_quit() {
844 let plist = launch_agent_plist(Path::new("/Applications/AgentMux.app/Contents/MacOS/agentmux"), "test");
848 assert!(plist.contains("<key>RunAtLoad</key>\n <true/>"));
849 assert!(plist.contains("<key>KeepAlive</key>\n <false/>"));
850 assert!(plist.contains(MACOS_LABEL));
851 }
852
853 #[test]
854 fn xdg_entry_is_a_valid_autostart_desktop_file() {
855 let d = xdg_desktop_entry(Path::new("/opt/agentmux/agentmux"), "test");
856 assert!(d.starts_with("[Desktop Entry]\n"));
857 assert!(d.contains("Type=Application\n"));
858 assert!(d.contains("X-GNOME-Autostart-enabled=true\n"));
859 assert!(
860 d.contains("Exec=\"/opt/agentmux/agentmux\" --background\n"),
861 "Exec must quote the path so spaces don't split the command"
862 );
863 }
864
865 #[test]
866 fn xdg_entry_cannot_be_injected_with_extra_keys_via_the_path() {
867 let d = xdg_desktop_entry(Path::new("/opt/x\nHidden=true"), "test");
870 assert!(!d.contains("\nHidden=true\n"), "newline must not survive into the file");
871 }
872
873 #[test]
890 #[ignore = "mutates real login configuration; run explicitly with --ignored"]
891 fn autostart_round_trip_against_the_real_platform_registry() {
892 assert!(
895 !is_enabled(),
896 "autostart already enabled on this machine — refusing to overwrite a real registration"
897 );
898
899 let exe = std::env::current_exe().expect("current_exe");
900 enable(&exe, "test").expect("enable should succeed");
901 assert!(is_enabled(), "enable() must be observable through the platform");
902
903 enable(&exe, "test").expect("re-enable should replace, not fail");
905 assert!(is_enabled());
906
907 disable().expect("disable should succeed");
908 assert!(!is_enabled(), "disable() must actually remove the artifact");
909
910 disable().expect("disable must be idempotent");
912 }
913
914 #[test]
919 fn the_background_flag_maps_onto_the_env_the_host_actually_reads() {
920 let args = vec!["agentmux.exe".to_string(), BACKGROUND_FLAG.to_string()];
921 assert!(background_requested(&args));
922 let env = background_env_for(&args);
923 assert!(
924 env.contains(&("AGENTMUX_BACKGROUND_SERVICE", "1")),
925 "without this the auto-start entry is inert"
926 );
927 }
928
929 #[test]
934 fn background_mode_also_forces_the_visible_indicator_on() {
935 let args = vec![BACKGROUND_FLAG.to_string()];
936 assert!(background_env_for(&args).contains(&("AGENTMUX_TRAY", "1")));
937 }
938
939 #[test]
940 fn a_normal_launch_sets_no_background_env() {
941 let args = vec!["agentmux.exe".to_string(), "--url=http://x".to_string()];
942 assert!(!background_requested(&args));
943 assert!(
944 background_env_for(&args).is_empty(),
945 "an ordinary launch must be completely unaffected"
946 );
947 }
948
949 #[test]
950 fn exec_escaping_covers_the_desktop_entry_grammar() {
951 let d = xdg_desktop_entry(Path::new(r#"/opt/a"b\c`d$e%f/agentmux"#), "test");
954 assert!(d.contains(r#"\"b"#), "quote must be escaped, not end the argument");
955 assert!(d.contains(r"\\c"), "backslash must be escaped");
956 assert!(d.contains(r"\`d"), "backtick must be escaped");
957 assert!(d.contains(r"\$e"), "dollar must be escaped");
958 assert!(d.contains("%%f"), "percent must be doubled or it reads as a field code");
959 }
960
961 #[test]
962 fn every_platform_artifact_launches_with_the_background_flag() {
963 assert!(scheduled_task_xml(&exe(), "test").contains("--background"));
967 assert!(launch_agent_plist(&exe(), "test").contains("<string>--background</string>"));
968 assert!(xdg_desktop_entry(&exe(), "test").contains("--background"));
969 }
970
971 #[test]
974 fn a_login_start_that_finds_agentmux_running_opens_no_window() {
975 let manual = vec!["agentmux".to_string()];
976 let login = vec!["agentmux".to_string(), BACKGROUND_FLAG.to_string()];
977 assert!(second_instance_opens_window(&manual));
978 assert!(!second_instance_opens_window(&login));
979 }
980
981 fn entry(target: &str, channel: Option<&str>) -> Entry {
982 Entry { target: Some(target.to_string()), channel: channel.map(str::to_string) }
983 }
984
985 #[test]
986 fn plan_on_writes_unless_the_entry_is_already_ours_and_current() {
987 let t = "/home/u/Desktop/AgentMux.AppImage";
988 assert_eq!(plan(Some(true), None, "stable", Some(t)), Plan::Write);
989 assert_eq!(plan(Some(true), Some(&entry(t, Some("stable"))), "stable", Some(t)), Plan::Nothing);
990 assert_eq!(plan(Some(true), Some(&entry("/old/path", Some("stable"))), "stable", Some(t)), Plan::Write);
992 assert_eq!(plan(Some(true), Some(&entry(t, Some("local-main-x"))), "stable", Some(t)), Plan::Write);
994 }
995
996 #[test]
997 fn plan_off_removes_only_this_channels_entry() {
998 let t = "/opt/agentmux/agentmux";
999 assert_eq!(plan(Some(false), Some(&entry(t, Some("stable"))), "stable", Some(t)), Plan::Remove);
1000 assert_eq!(plan(Some(false), Some(&entry(t, Some("local-main-x"))), "stable", Some(t)), Plan::Nothing);
1001 assert_eq!(plan(Some(false), Some(&entry(t, None)), "stable", Some(t)), Plan::Nothing);
1002 assert_eq!(plan(Some(false), None, "stable", Some(t)), Plan::Nothing);
1003 }
1004
1005 #[test]
1007 fn plan_needs_the_target_only_to_write() {
1008 let mine = entry("/x", Some("stable"));
1009 assert_eq!(plan(Some(false), Some(&mine), "stable", None), Plan::Remove);
1010 assert_eq!(plan(None, Some(&entry("/x", None)), "stable", None), Plan::Adopt);
1011 assert_eq!(plan(Some(true), Some(&mine), "stable", None), Plan::Write);
1013 }
1014
1015 #[test]
1017 fn plan_never_set_is_off_but_adopts_an_entry_from_before_the_setting() {
1018 let t = "/x";
1019 assert_eq!(plan(None, None, "stable", Some(t)), Plan::Nothing);
1020 assert_eq!(plan(None, Some(&entry(t, None)), "stable", Some(t)), Plan::Adopt);
1021 assert_eq!(plan(None, Some(&entry(t, Some("local-main-x"))), "stable", Some(t)), Plan::Nothing);
1022 }
1023
1024 #[test]
1025 fn every_artifact_reads_back_its_own_target_and_channel() {
1026 let exe = Path::new("/home/u/My Apps/Agent&Mux.AppImage");
1027 let want = Entry { target: Some(exe.display().to_string()), channel: Some("local-main-b28b7a".into()) };
1028 assert_eq!(parse_desktop_entry(&xdg_desktop_entry(exe, "local-main-b28b7a")), want);
1029 assert_eq!(parse_plist(&launch_agent_plist(exe, "local-main-b28b7a")), want);
1030 let win = Path::new(r"C:\Users\u\AppData\Local\Programs\AgentMux & Co\agentmux.exe");
1031 assert_eq!(
1032 parse_task_xml(&scheduled_task_xml(win, "stable")),
1033 Entry { target: Some(win.display().to_string()), channel: Some("stable".into()) }
1034 );
1035 }
1036
1037 #[test]
1039 fn a_pre_ownership_desktop_entry_reads_as_unowned_with_no_target() {
1040 let old = "[Desktop Entry]\nType=Application\nExec=\"/x/agentmux-launcher\" --background\n";
1041 assert_eq!(parse_desktop_entry(old), Entry { target: None, channel: None });
1042 }
1043
1044 #[test]
1045 fn the_desktop_entry_goes_inert_without_its_target_and_waits_for_the_session() {
1046 let d = xdg_desktop_entry(Path::new("/opt/agentmux/agentmux"), "stable");
1047 assert!(d.contains("\nTryExec=/opt/agentmux/agentmux\n"), "{d}");
1048 assert!(d.contains("\nX-GNOME-Autostart-Delay=10\n"), "{d}");
1049 assert!(d.contains("\nX-AgentMux-Channel=stable\n"), "{d}");
1050 }
1051
1052 #[test]
1053 fn try_exec_cannot_be_used_to_inject_keys() {
1054 let d = xdg_desktop_entry(Path::new("/opt/x\nHidden=true"), "stable");
1055 assert!(!d.lines().any(|l| l == "Hidden=true"), "{d}");
1056 }
1057
1058 #[test]
1059 fn schtasks_utf16_output_is_decoded() {
1060 let mut bytes = vec![0xFF, 0xFE];
1061 for u in "<Command>\"C:\\a.exe\"</Command>".encode_utf16() {
1062 bytes.extend_from_slice(&u.to_le_bytes());
1063 }
1064 assert_eq!(decode_console_output(&bytes), "<Command>\"C:\\a.exe\"</Command>");
1065 assert_eq!(decode_console_output(b"plain"), "plain");
1066 }
1067
1068 #[test]
1069 fn the_stable_target_prefers_the_wrapper_path_then_this_exe() {
1070 let dir = std::env::temp_dir().join(format!("agentmux-stable-{}", std::process::id()));
1071 std::fs::create_dir_all(&dir).unwrap();
1072 let wrapper = dir.join("agentmux");
1073 std::fs::write(&wrapper, "").unwrap();
1074 let cur = || Ok(PathBuf::from("/cache/agentmux-launcher"));
1075
1076 assert_eq!(stable_target_from(Some(wrapper.clone().into()), cur()), Ok(wrapper.clone()));
1077 assert_eq!(stable_target_from(Some(dir.join("gone").into()), cur()), cur());
1079 assert_eq!(stable_target_from(None, cur()), cur());
1080 let _ = std::fs::remove_dir_all(&dir);
1081 }
1082}