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
97/// Returns true when the installed manager is actively responsible for the proxy.
98/// Callers must not spawn a second foreground proxy while this is true.
99pub fn is_loaded() -> bool {
100    #[cfg(target_os = "macos")]
101    {
102        is_installed() && crate::core::launchd::is_loaded(PLIST_LABEL)
103    }
104    #[cfg(target_os = "linux")]
105    {
106        is_installed()
107            && std::process::Command::new("systemctl")
108                .args(["--user", "is-active", "--quiet", SYSTEMD_SERVICE])
109                .status()
110                .is_ok_and(|status| status.success())
111    }
112    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
113    {
114        false
115    }
116}
117
118pub fn status() {
119    #[cfg(target_os = "macos")]
120    {
121        let plist_path = launchagent_path();
122        if plist_path.exists() {
123            println!("  LaunchAgent: installed at {}", plist_path.display());
124            if crate::core::launchd::is_loaded(PLIST_LABEL) {
125                println!("  Status: loaded");
126            } else {
127                println!("  Status: not loaded (run: lean-ctx proxy start)");
128            }
129        } else {
130            println!("  LaunchAgent: not installed");
131        }
132    }
133
134    #[cfg(target_os = "linux")]
135    {
136        let service_path = systemd_path();
137        if service_path.exists() {
138            println!("  systemd user service: installed");
139            let output = std::process::Command::new("systemctl")
140                .args(["--user", "is-active", SYSTEMD_SERVICE])
141                .output();
142            match output {
143                Ok(o) => {
144                    let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
145                    println!("  Status: {state}");
146                }
147                Err(_) => println!("  Status: unknown"),
148            }
149        } else {
150            println!("  systemd service: not installed");
151        }
152    }
153
154    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
155    {
156        println!("  Autostart not available on this platform");
157    }
158}
159
160#[cfg(target_os = "macos")]
161fn launchagent_path() -> PathBuf {
162    dirs::home_dir()
163        .unwrap_or_else(|| PathBuf::from("/tmp"))
164        .join("Library/LaunchAgents")
165        .join(format!("{PLIST_LABEL}.plist"))
166}
167
168#[cfg(target_os = "macos")]
169fn install_launchagent(binary: &str, port: u16, quiet: bool) {
170    let plist_dir = dirs::home_dir()
171        .unwrap_or_else(|| PathBuf::from("/tmp"))
172        .join("Library/LaunchAgents");
173    let _ = std::fs::create_dir_all(&plist_dir);
174
175    let plist_path = plist_dir.join(format!("{PLIST_LABEL}.plist"));
176    // GH #439: proxy logs are STATE — resolve through the typed dir so a
177    // post-split install writes to $XDG_STATE_HOME/lean-ctx/logs instead of a
178    // re-created ~/.lean-ctx. Legacy single-dir installs still resolve here.
179    let log_dir = crate::core::paths::state_dir()
180        .unwrap_or_else(|_| std::env::temp_dir().join("lean-ctx"))
181        .join("logs");
182    let _ = std::fs::create_dir_all(&log_dir);
183
184    // #356: wrap the launchd invocation in a deny-~/Documents seatbelt sandbox
185    // so the proxy (a TCC-standalone process) can never trip the privacy prompt.
186    let port_arg = format!("--port={port}");
187    let program_args = crate::core::tcc_guard_sandbox::program_args_xml(
188        &crate::core::tcc_guard_sandbox::wrap_launchd_args(binary, &["proxy", "start", &port_arg]),
189        "        ",
190    );
191
192    // #449: pin the directory layout. A launchd-spawned proxy inherits only
193    // launchd's minimal environment (no HOME, no XDG vars), so it resolves a
194    // *different* config/data dir than the CLI that installed it — it never sees
195    // the user's config.toml edits (live-upstream reload reads nothing) and
196    // derives a mismatched session token. Bake the exact dirs this CLI resolves
197    // into the plist so the managed proxy always agrees with the CLI.
198    let env_vars = crate::core::tcc_guard_sandbox::pinned_layout_env_xml();
199
200    let plist = format!(
201        r#"<?xml version="1.0" encoding="UTF-8"?>
202<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
203<plist version="1.0">
204<dict>
205    <key>Label</key>
206    <string>{PLIST_LABEL}</string>
207    <key>ProgramArguments</key>
208    <array>
209{program_args}
210    </array>
211{env_vars}    <key>RunAtLoad</key>
212    <true/>
213    <key>KeepAlive</key>
214    <true/>
215    <key>StandardOutPath</key>
216    <string>{stdout}</string>
217    <key>StandardErrorPath</key>
218    <string>{stderr}</string>
219</dict>
220</plist>"#,
221        stdout = log_dir.join("proxy.stdout.log").display(),
222        stderr = log_dir.join("proxy.stderr.log").display(),
223    );
224
225    let _ = std::fs::write(&plist_path, &plist);
226
227    let ok = crate::core::launchd::bootstrap(PLIST_LABEL, &plist_path);
228
229    if !quiet {
230        if ok {
231            println!("  Installed LaunchAgent: {}", plist_path.display());
232            println!("  Proxy will start on login and restart if stopped");
233        } else {
234            println!("  Created LaunchAgent at {}", plist_path.display());
235            println!("  Load reported a problem; check: launchctl print {PLIST_LABEL}");
236        }
237    }
238}
239
240#[cfg(target_os = "macos")]
241fn uninstall_launchagent(quiet: bool) {
242    let plist_path = launchagent_path();
243    if !plist_path.exists() {
244        if !quiet {
245            println!("  LaunchAgent not installed, nothing to remove");
246        }
247        return;
248    }
249
250    crate::core::launchd::bootout(PLIST_LABEL, &plist_path);
251
252    let _ = std::fs::remove_file(&plist_path);
253    if !quiet {
254        println!("  Removed LaunchAgent: {}", plist_path.display());
255    }
256}
257
258#[cfg(target_os = "linux")]
259fn systemd_path() -> PathBuf {
260    dirs::home_dir()
261        .unwrap_or_else(|| PathBuf::from("/tmp"))
262        .join(".config/systemd/user")
263        .join(format!("{SYSTEMD_SERVICE}.service"))
264}
265
266#[cfg(target_os = "linux")]
267fn install_systemd(binary: &str, port: u16, quiet: bool) {
268    let service_dir = dirs::home_dir()
269        .unwrap_or_else(|| PathBuf::from("/tmp"))
270        .join(".config/systemd/user");
271    let _ = std::fs::create_dir_all(&service_dir);
272
273    let service_path = service_dir.join(format!("{SYSTEMD_SERVICE}.service"));
274
275    let unit = format!(
276        r"[Unit]
277Description=lean-ctx API Proxy
278After=network.target
279StartLimitIntervalSec=300
280StartLimitBurst=5
281
282[Service]
283Type=simple
284ExecStart={binary} proxy start --port={port}
285Restart=on-failure
286RestartSec=5
287StandardOutput=journal
288StandardError=journal
289Environment=RUST_LOG=info
290
291[Install]
292WantedBy=default.target
293"
294    );
295
296    let _ = std::fs::write(&service_path, &unit);
297
298    let _ = std::process::Command::new("systemctl")
299        .args(["--user", "daemon-reload"])
300        .output();
301
302    let result = std::process::Command::new("systemctl")
303        .args(["--user", "enable", "--now", SYSTEMD_SERVICE])
304        .output();
305
306    if !quiet {
307        match result {
308            Ok(o) if o.status.success() => {
309                println!("  Installed systemd user service: {SYSTEMD_SERVICE}");
310                println!("  Proxy will start on login and restart if stopped");
311            }
312            Ok(o) => {
313                let err = String::from_utf8_lossy(&o.stderr);
314                println!("  Created service file but enable failed: {err}");
315            }
316            Err(e) => {
317                println!("  Created service file at {}", service_path.display());
318                println!("  Could not enable: {e}");
319            }
320        }
321    }
322}
323
324#[cfg(target_os = "linux")]
325fn uninstall_systemd(quiet: bool) {
326    let service_path = systemd_path();
327    if !service_path.exists() {
328        if !quiet {
329            println!("  systemd service not installed, nothing to remove");
330        }
331        return;
332    }
333
334    let _ = std::process::Command::new("systemctl")
335        .args(["--user", "stop", SYSTEMD_SERVICE])
336        .output();
337    let _ = std::process::Command::new("systemctl")
338        .args(["--user", "disable", SYSTEMD_SERVICE])
339        .output();
340    let _ = std::fs::remove_file(&service_path);
341    let _ = std::process::Command::new("systemctl")
342        .args(["--user", "daemon-reload"])
343        .output();
344
345    if !quiet {
346        println!("  Removed systemd service: {SYSTEMD_SERVICE}");
347    }
348}
349
350pub fn find_binary() -> String {
351    crate::core::portable_binary::resolve_portable_binary()
352}