Skip to main content

lean_ctx/
proxy_autostart.rs

1#[cfg(any(target_os = "macos", target_os = "linux"))]
2use std::path::PathBuf;
3
4#[cfg(target_os = "macos")]
5const PLIST_LABEL: &str = "com.leanctx.proxy";
6#[cfg(target_os = "linux")]
7const SYSTEMD_SERVICE: &str = "lean-ctx-proxy";
8
9pub fn install(port: u16, quiet: bool) {
10    let binary = find_binary();
11    if binary.is_empty() {
12        if !quiet {
13            tracing::error!("Cannot find lean-ctx binary for autostart");
14        }
15        return;
16    }
17
18    #[cfg(target_os = "macos")]
19    install_launchagent(&binary, port, quiet);
20
21    #[cfg(target_os = "linux")]
22    install_systemd(&binary, port, quiet);
23
24    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
25    {
26        let _ = (&binary, quiet);
27        println!("  Autostart not supported on this platform");
28        println!("  Run manually: lean-ctx proxy start --port={port}");
29    }
30}
31
32pub fn stop() {
33    #[cfg(target_os = "macos")]
34    {
35        let plist_path = launchagent_path();
36        if plist_path.exists() {
37            crate::core::launchd::bootout(PLIST_LABEL, &plist_path);
38        }
39    }
40
41    #[cfg(target_os = "linux")]
42    {
43        let _ = std::process::Command::new("systemctl")
44            .args(["--user", "stop", SYSTEMD_SERVICE])
45            .output();
46    }
47}
48
49pub fn start() {
50    #[cfg(target_os = "macos")]
51    {
52        let plist_path = launchagent_path();
53        if plist_path.exists() {
54            crate::core::launchd::bootstrap(PLIST_LABEL, &plist_path);
55        }
56    }
57
58    #[cfg(target_os = "linux")]
59    {
60        let _ = std::process::Command::new("systemctl")
61            .args(["--user", "start", SYSTEMD_SERVICE])
62            .output();
63    }
64}
65
66pub fn uninstall(_quiet: bool) {
67    #[cfg(target_os = "macos")]
68    uninstall_launchagent(_quiet);
69
70    #[cfg(target_os = "linux")]
71    uninstall_systemd(_quiet);
72}
73
74/// Whether this platform has a proxy-autostart backend (LaunchAgent on macOS,
75/// systemd user service on Linux). Windows and other targets have none, so a
76/// missing autostart there must not be treated as a failure by `doctor` (#416).
77pub fn is_supported() -> bool {
78    cfg!(any(target_os = "macos", target_os = "linux"))
79}
80
81/// Returns true if the proxy autostart is installed (plist/systemd service file exists).
82pub fn is_installed() -> bool {
83    #[cfg(target_os = "macos")]
84    {
85        launchagent_path().exists()
86    }
87    #[cfg(target_os = "linux")]
88    {
89        systemd_path().exists()
90    }
91    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
92    {
93        false
94    }
95}
96
97pub fn status() {
98    #[cfg(target_os = "macos")]
99    {
100        let plist_path = launchagent_path();
101        if plist_path.exists() {
102            println!("  LaunchAgent: installed at {}", plist_path.display());
103            if crate::core::launchd::is_loaded(PLIST_LABEL) {
104                println!("  Status: loaded");
105            } else {
106                println!("  Status: not loaded (run: lean-ctx proxy start)");
107            }
108        } else {
109            println!("  LaunchAgent: not installed");
110        }
111    }
112
113    #[cfg(target_os = "linux")]
114    {
115        let service_path = systemd_path();
116        if service_path.exists() {
117            println!("  systemd user service: installed");
118            let output = std::process::Command::new("systemctl")
119                .args(["--user", "is-active", SYSTEMD_SERVICE])
120                .output();
121            match output {
122                Ok(o) => {
123                    let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
124                    println!("  Status: {state}");
125                }
126                Err(_) => println!("  Status: unknown"),
127            }
128        } else {
129            println!("  systemd service: not installed");
130        }
131    }
132
133    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
134    {
135        println!("  Autostart not available on this platform");
136    }
137}
138
139#[cfg(target_os = "macos")]
140fn launchagent_path() -> PathBuf {
141    dirs::home_dir()
142        .unwrap_or_else(|| PathBuf::from("/tmp"))
143        .join("Library/LaunchAgents")
144        .join(format!("{PLIST_LABEL}.plist"))
145}
146
147#[cfg(target_os = "macos")]
148fn install_launchagent(binary: &str, port: u16, quiet: bool) {
149    let plist_dir = dirs::home_dir()
150        .unwrap_or_else(|| PathBuf::from("/tmp"))
151        .join("Library/LaunchAgents");
152    let _ = std::fs::create_dir_all(&plist_dir);
153
154    let plist_path = plist_dir.join(format!("{PLIST_LABEL}.plist"));
155    // GH #439: proxy logs are STATE — resolve through the typed dir so a
156    // post-split install writes to $XDG_STATE_HOME/lean-ctx/logs instead of a
157    // re-created ~/.lean-ctx. Legacy single-dir installs still resolve here.
158    let log_dir = crate::core::paths::state_dir()
159        .unwrap_or_else(|_| std::env::temp_dir().join("lean-ctx"))
160        .join("logs");
161    let _ = std::fs::create_dir_all(&log_dir);
162
163    // #356: wrap the launchd invocation in a deny-~/Documents seatbelt sandbox
164    // so the proxy (a TCC-standalone process) can never trip the privacy prompt.
165    let port_arg = format!("--port={port}");
166    let program_args = crate::core::tcc_guard_sandbox::program_args_xml(
167        &crate::core::tcc_guard_sandbox::wrap_launchd_args(binary, &["proxy", "start", &port_arg]),
168        "        ",
169    );
170
171    // #449: pin the directory layout. A launchd-spawned proxy inherits only
172    // launchd's minimal environment (no HOME, no XDG vars), so it resolves a
173    // *different* config/data dir than the CLI that installed it — it never sees
174    // the user's config.toml edits (live-upstream reload reads nothing) and
175    // derives a mismatched session token. Bake the exact dirs this CLI resolves
176    // into the plist so the managed proxy always agrees with the CLI.
177    let env_vars = crate::core::tcc_guard_sandbox::pinned_layout_env_xml();
178
179    let plist = format!(
180        r#"<?xml version="1.0" encoding="UTF-8"?>
181<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
182<plist version="1.0">
183<dict>
184    <key>Label</key>
185    <string>{PLIST_LABEL}</string>
186    <key>ProgramArguments</key>
187    <array>
188{program_args}
189    </array>
190{env_vars}    <key>RunAtLoad</key>
191    <true/>
192    <key>KeepAlive</key>
193    <true/>
194    <key>StandardOutPath</key>
195    <string>{stdout}</string>
196    <key>StandardErrorPath</key>
197    <string>{stderr}</string>
198</dict>
199</plist>"#,
200        stdout = log_dir.join("proxy.stdout.log").display(),
201        stderr = log_dir.join("proxy.stderr.log").display(),
202    );
203
204    let _ = std::fs::write(&plist_path, &plist);
205
206    let ok = crate::core::launchd::bootstrap(PLIST_LABEL, &plist_path);
207
208    if !quiet {
209        if ok {
210            println!("  Installed LaunchAgent: {}", plist_path.display());
211            println!("  Proxy will start on login and restart if stopped");
212        } else {
213            println!("  Created LaunchAgent at {}", plist_path.display());
214            println!("  Load reported a problem; check: launchctl print {PLIST_LABEL}");
215        }
216    }
217}
218
219#[cfg(target_os = "macos")]
220fn uninstall_launchagent(quiet: bool) {
221    let plist_path = launchagent_path();
222    if !plist_path.exists() {
223        if !quiet {
224            println!("  LaunchAgent not installed, nothing to remove");
225        }
226        return;
227    }
228
229    crate::core::launchd::bootout(PLIST_LABEL, &plist_path);
230
231    let _ = std::fs::remove_file(&plist_path);
232    if !quiet {
233        println!("  Removed LaunchAgent: {}", plist_path.display());
234    }
235}
236
237#[cfg(target_os = "linux")]
238fn systemd_path() -> PathBuf {
239    dirs::home_dir()
240        .unwrap_or_else(|| PathBuf::from("/tmp"))
241        .join(".config/systemd/user")
242        .join(format!("{SYSTEMD_SERVICE}.service"))
243}
244
245#[cfg(target_os = "linux")]
246fn install_systemd(binary: &str, port: u16, quiet: bool) {
247    let service_dir = dirs::home_dir()
248        .unwrap_or_else(|| PathBuf::from("/tmp"))
249        .join(".config/systemd/user");
250    let _ = std::fs::create_dir_all(&service_dir);
251
252    let service_path = service_dir.join(format!("{SYSTEMD_SERVICE}.service"));
253
254    let unit = format!(
255        r"[Unit]
256Description=lean-ctx API Proxy
257After=network.target
258StartLimitIntervalSec=300
259StartLimitBurst=5
260
261[Service]
262Type=simple
263ExecStart={binary} proxy start --port={port}
264Restart=on-failure
265RestartSec=5
266StandardOutput=journal
267StandardError=journal
268Environment=RUST_LOG=info
269
270[Install]
271WantedBy=default.target
272"
273    );
274
275    let _ = std::fs::write(&service_path, &unit);
276
277    let _ = std::process::Command::new("systemctl")
278        .args(["--user", "daemon-reload"])
279        .output();
280
281    let result = std::process::Command::new("systemctl")
282        .args(["--user", "enable", "--now", SYSTEMD_SERVICE])
283        .output();
284
285    if !quiet {
286        match result {
287            Ok(o) if o.status.success() => {
288                println!("  Installed systemd user service: {SYSTEMD_SERVICE}");
289                println!("  Proxy will start on login and restart if stopped");
290            }
291            Ok(o) => {
292                let err = String::from_utf8_lossy(&o.stderr);
293                println!("  Created service file but enable failed: {err}");
294            }
295            Err(e) => {
296                println!("  Created service file at {}", service_path.display());
297                println!("  Could not enable: {e}");
298            }
299        }
300    }
301}
302
303#[cfg(target_os = "linux")]
304fn uninstall_systemd(quiet: bool) {
305    let service_path = systemd_path();
306    if !service_path.exists() {
307        if !quiet {
308            println!("  systemd service not installed, nothing to remove");
309        }
310        return;
311    }
312
313    let _ = std::process::Command::new("systemctl")
314        .args(["--user", "stop", SYSTEMD_SERVICE])
315        .output();
316    let _ = std::process::Command::new("systemctl")
317        .args(["--user", "disable", SYSTEMD_SERVICE])
318        .output();
319    let _ = std::fs::remove_file(&service_path);
320    let _ = std::process::Command::new("systemctl")
321        .args(["--user", "daemon-reload"])
322        .output();
323
324    if !quiet {
325        println!("  Removed systemd service: {SYSTEMD_SERVICE}");
326    }
327}
328
329pub fn find_binary() -> String {
330    crate::core::portable_binary::resolve_portable_binary()
331}