agentmux_launcher/start_at_login.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `app:startatlogin`: one setting, three views
5//! (`docs/specs/SPEC_START_WITH_OS_2026_09_25.md` ยง3.9).
6//!
7//! The setting is the single source of truth, owned by srv like every other
8//! setting. The Settings toggle writes it with `setconfig`; the tray's check
9//! item writes it the same way, over the launcher's srv connection
10//! (`notify::request_start_at_login`, the path the tray's "pause
11//! notifications" already uses). srv broadcasts every change to all clients,
12//! so the Settings page, the tray check mark and the OS login entry all follow
13//! the one value: this module receives it (`observe`), refreshes the tray, and
14//! reconciles the login entry to it (`autostart::plan`).
15//!
16//! Off unless the user turns it on (owner decision, 2026-09-25).
17
18use std::sync::Mutex;
19
20/// Settings key.
21pub const SETTINGS_KEY: &str = "app:startatlogin";
22
23/// The last value srv reported. `None` until the first config frame arrives:
24/// the tray shows the item disabled until then, rather than guess.
25static SEEN: Mutex<Option<Option<bool>>> = Mutex::new(None);
26
27/// Serializes reconciles, so two quick toggles cannot interleave their writes.
28static RECONCILE: Mutex<()> = Mutex::new(());
29
30/// The setting as the tray should show it: `None` = not known yet.
31pub fn current() -> Option<bool> {
32 SEEN.lock().unwrap_or_else(|e| e.into_inner()).map(|v| v.unwrap_or(false))
33}
34
35/// srv reported the setting (on connect, and after every settings change).
36/// `None` means the key is not set.
37pub fn observe(setting: Option<bool>) {
38 {
39 let mut seen = SEEN.lock().unwrap_or_else(|e| e.into_inner());
40 // Every settings change broadcasts the whole config; only act on this key.
41 if *seen == Some(setting) {
42 return;
43 }
44 *seen = Some(setting);
45 }
46 crate::tray::notify_menu::wake();
47 // Blocking I/O (schtasks on Windows), so off the async session.
48 std::thread::Builder::new()
49 .name("agentmux-start-at-login".into())
50 .spawn(reconcile_latest)
51 .ok();
52}
53
54/// The tray's check item was clicked.
55pub fn toggle() {
56 if let Some(on) = current() {
57 crate::notify::request_start_at_login(!on);
58 }
59 // Put the item back to the known value until srv confirms the change:
60 // some menus flip their own check mark on click.
61 crate::tray::notify_menu::wake();
62}
63
64fn reconcile_latest() {
65 let _guard = RECONCILE.lock().unwrap_or_else(|e| e.into_inner());
66 // Apply the newest value, not the one that spawned this thread, so a
67 // burst of changes ends in the last state.
68 let Some(setting) = *SEEN.lock().unwrap_or_else(|e| e.into_inner()) else { return };
69 let channel = crate::autostart::current_channel();
70 // Only a write needs the target. Resolving it must not stand in the way of
71 // turning start-at-login off (ReAgent P1 on #3788).
72 let target = crate::autostart::stable_target();
73 let target_str = target.as_ref().ok().map(|t| t.display().to_string());
74 let entry = crate::autostart::read_entry();
75 let plan = crate::autostart::plan(setting, entry.as_ref(), &channel, target_str.as_deref());
76 let result = match plan {
77 crate::autostart::Plan::Nothing => return,
78 crate::autostart::Plan::Write => match &target {
79 Ok(t) => crate::autostart::enable(t, &channel),
80 Err(e) => Err(format!("no target to register: {e}")),
81 },
82 crate::autostart::Plan::Remove => crate::autostart::disable(),
83 crate::autostart::Plan::Adopt => {
84 crate::notify::request_start_at_login(true);
85 Ok(())
86 }
87 };
88 match result {
89 Ok(()) => crate::log(&format!(
90 "start-at-login: {plan:?} (setting={setting:?}, channel={channel}, target={target_str:?}, was={entry:?})"
91 )),
92 Err(e) => crate::log(&format!("start-at-login: {plan:?} failed: {e}")),
93 }
94}