diff --git a/README.md b/README.md index 65e8f7af..30f1ff46 100644 --- a/README.md +++ b/README.md @@ -701,17 +701,19 @@ chmod +x .git/hooks/post-commit ### Daemon Debugging -The daemon is a long-running MCP server for clients that share one local socket. Its logs go to stderr; when installed as a Linux user service, systemd captures them in journald. +The daemon is a long-running MCP server for clients that share one local socket. It is opt-in: `tracedecay daemon install-service` installs a per-user service on Linux systemd or a per-user LaunchAgent on macOS. ```bash -tracedecay daemon install-service # install/start Linux systemd user service +tracedecay daemon install-service # install/start per-user daemon service tracedecay daemon status # service path, socket state, log command systemctl --user status tracedecay.service --no-pager journalctl --user -u tracedecay.service -f +launchctl print "gui/$(id -u)/com.tracedecay.daemon" +tail -f ~/.tracedecay/daemon.err.log tracedecay status --runtime --json # process + DB/WAL/SHM telemetry snapshot ``` -Scheduler logs use stable `event=... key=value` fields such as `event=scheduler_tick`, `event=scheduler_task`, `task=memory_curator`, `outcome=skipped`, and `reason=not_configured`, so they can be filtered directly from journald. +Scheduler logs use stable `event=... key=value` fields such as `event=scheduler_tick`, `event=scheduler_task`, `task=memory_curator`, `outcome=skipped`, and `reason=not_configured`, so they can be filtered directly from journald on Linux or the daemon log file on macOS. ### Upgrading from 5.x @@ -760,7 +762,7 @@ tracedecay update-plugin # Refresh generated plugin code/assets only; tracedecay uninstall [--agent NAME] [--profile NAME] # Remove agent integration tracedecay serve # Start MCP server tracedecay daemon status # Show daemon service/socket/log hints -tracedecay daemon install-service # Install/start Linux systemd user service +tracedecay daemon install-service # Install/start per-user daemon service tracedecay monitor # Live TUI showing MCP calls across all projects tracedecay update # Refresh binary, generated plugins, and daemon tracedecay upgrade # Self-update to latest version diff --git a/SECURITY.md b/SECURITY.md index 5d846f4b..4799763d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -100,9 +100,9 @@ The edit tools target a single file with a unique anchor and re-index in place. **Windows:** Authenticode code signing via the [SignPath.io Foundation](https://signpath.io/foundation) program is being rolled out so Windows binaries are signed as part of the release workflow (addresses the Smart App Control block reported in #79). Until that lands in a published release, Windows binaries remain unsigned. -### No background daemon +### Opt-in background daemon -tracedecay runs **no background daemon, system service, or autostart process**. The standalone `tracedecay daemon` command and its launchd/systemd/Windows-Service autostart were removed in 6.0.0. Index freshness is maintained entirely on demand: an on-demand staleness check on each MCP tool call (30-second cooldown) plus a catch-up sync when the MCP server connects. The server lives only for the lifetime of the attached agent and runs with **standard user privileges** — it never requests elevation. +tracedecay installs **no background daemon, system service, or autostart process by default**. Users can explicitly opt in with `tracedecay daemon install-service`, which installs a per-user systemd service on Linux or a per-user LaunchAgent on macOS. The daemon runs with **standard user privileges** and never requests elevation. Index freshness still relies on on-demand staleness checks, catch-up syncs when MCP clients connect, and bounded hook notifications; the daemon provides shared MCP process/socket reuse and scheduled automation for projects that connect to it. ### Subprocess-isolated extraction diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 0a3600e3..70cbf9f0 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -462,6 +462,21 @@ chmod +x .git/hooks/post-commit The MCP server does not run a background file watcher. Instead, MCP tool calls perform a lightweight staleness check and run an incremental sync when indexed files are stale. Agent file/shell hooks notify the daemon about targeted edits and branch-affecting commands, and the daemon's MCP server schedules the resulting sync/branch work. Multiple MCP servers on the same project coordinate via a per-project sync lock: only one sync runs at a time. +### Optional daemon service + +If you want the daemon available across terminal sessions and after login, install the per-user service: + +```bash +tracedecay daemon install-service +tracedecay daemon status +``` + +On Linux this installs a systemd user service. On macOS this installs a LaunchAgent at `~/Library/LaunchAgents/com.tracedecay.daemon.plist`. Remove it with: + +```bash +tracedecay daemon uninstall-service +``` + ### CLI-Only Workflows If you don't keep an agent attached, use a git post-commit hook to refresh the index on commit: diff --git a/docs/plans/2026-07-01-macos-launchd-daemon-support.md b/docs/plans/2026-07-01-macos-launchd-daemon-support.md new file mode 100644 index 00000000..992a46f1 --- /dev/null +++ b/docs/plans/2026-07-01-macos-launchd-daemon-support.md @@ -0,0 +1,537 @@ +# Final Plan: macOS launchd Support for the TraceDecay Daemon + +**Status:** Final implementation plan +**Target branch:** `main` +**Scope:** `src/daemon.rs`, focused tests, README / user guide / security docs +**Outcome:** macOS gets the same user-facing daemon service support Linux already +has: `tracedecay daemon install-service`, `uninstall-service`, `status`, and +post-update service refresh work for a per-user background daemon. + +--- + +## 1. Goal + +Make the existing Linux daemon-service workflow work on macOS with native +launchd: + +```bash +tracedecay daemon install-service +tracedecay daemon status +tracedecay daemon uninstall-service +tracedecay update +``` + +Today macOS fails because the service layer always goes through +`systemd_user_service_path()` in `src/daemon.rs`, which hard-errors outside +Linux: + +> daemon service install is currently supported on Linux systemd user services + +After this change, macOS users can install TraceDecay as a per-user LaunchAgent +that starts at GUI login, restarts on failure, serves the same Unix socket +daemon as `tracedecay daemon run`, and is refreshed by `tracedecay update`. + +**Linux parity means OS-managed daemon process parity.** The macOS service should +match what Linux systemd support provides today: + +- write the service definition; +- start and enable it when requested; +- preserve a previously installed custom socket path during refresh; +- stop/disable/remove it during uninstall; +- report service path, socket reachability, and useful log/service commands; +- refresh the installed service after an update. + +## 2. Non-goals + +- **Windows service support.** Windows remains on the existing non-Unix fallback. +- **Auto-installing the daemon from `install --agent X`.** Linux does not do this + either; users explicitly opt into the OS service with `daemon install-service`. +- **Persisted project scheduler registry.** The daemon scheduler is currently + seeded when project clients connect and send a `DaemonHandshake`. This plan + does not add a boot-time registry of projects to resume before any client has + connected. That would be beyond Linux parity and should be a separate design. +- **Changing storage roots.** macOS continues to use the current TraceDecay + `user_data_dir()` behavior (`~/.tracedecay` unless `TRACEDECAY_DATA_DIR` is + set). Do not silently move daemon sockets or logs to + `~/Library/Application Support`. + +## 3. Existing Code Shape + +The current service API is already narrow enough for a clean platform dispatch. + +| Function | Current behavior | +|---|---| +| `install_service(spec, start)` | writes systemd unit, optionally `daemon-reload` + `enable --now` | +| `refresh_service(spec)` | rewrites systemd unit, `daemon-reload`, `enable`, `restart` | +| `refresh_installed_service(spec)` | skips missing unit, preserves installed socket path, refreshes | +| `uninstall_service(stop)` | optionally `disable --now`, removes unit, `daemon-reload` | +| `installed_service_socket_path()` | reads installed unit and parses `--socket` | +| `service_status(socket_path)` | prints service path, socket state, log command | + +Every one of those paths currently depends on `systemd_user_service_path()`. +That is the right seam to replace with platform dispatch. + +The daemon engine itself is already usable on macOS: + +- `run_foreground_unix` binds a Unix socket and handles SIGTERM; +- `notify_hook_event` has a Unix implementation; +- the scheduler code is Unix-gated, not Linux-specific; +- client handshake/profile handling is independent of systemd. + +## 4. Architecture Decision + +Keep the public Rust API signature-compatible and add private platform service +helpers inside `src/daemon.rs`. + +```text +Public API + install_service + refresh_service + refresh_installed_service + uninstall_service + installed_service_socket_path + service_status + | + v +ServiceRunner::current() + | + +-- Linux -> systemd user service + +-- macOS -> launchd per-user LaunchAgent + +-- other -> existing unsupported-service error +``` + +Use an enum, not traits, because there are only two supported backends and the +implementation is private: + +```rust +enum ServiceRunner { + Systemd, + Launchd, +} +``` + +The pure rendering/parsing helpers must remain unit-testable on any platform. +The process-control helpers are platform-gated and tested with fake command +runners where possible. + +## 5. macOS launchd Behavior + +Use modern launchd domain commands. Do **not** use legacy `launchctl load` / +`unload` for the implementation because they hide many errors and are explicitly +documented as legacy on current macOS. + +### 5.1 LaunchAgent identity + +```rust +const LAUNCHD_LABEL: &str = "com.tracedecay.daemon"; +const LAUNCHD_PLIST_NAME: &str = "com.tracedecay.daemon.plist"; +``` + +Paths: + +| Item | macOS path | +|---|---| +| LaunchAgent plist | `~/Library/LaunchAgents/com.tracedecay.daemon.plist` | +| socket | `/daemon.sock` unless `--socket` overrides | +| stdout log | `/daemon.out.log` | +| stderr log | `/daemon.err.log` | + +The plist path follows macOS convention. The socket/log paths follow existing +TraceDecay storage behavior for parity with the current daemon code. + +### 5.2 launchctl domain helpers + +Add: + +```rust +#[cfg(target_os = "macos")] +fn launchd_domain() -> Result; // "gui/" + +#[cfg(target_os = "macos")] +fn launchd_service_target() -> Result; // "gui//com.tracedecay.daemon" + +#[cfg(target_os = "macos")] +fn run_launchctl(args: &[&str]) -> Result; +``` + +`run_launchctl` should capture stdout/stderr and include both in errors. Keep the +shape close to `run_systemctl`, but return output for status checks. + +Use `gui/` because this is a per-user LaunchAgent that should start at GUI +login. If a future headless/background-user mode is needed, that should be a +separate option. + +### 5.3 Service-control mapping + +| Operation | Linux systemd | macOS launchd | +|---|---|---| +| install with start | `daemon-reload`; `enable --now tracedecay.service` | write plist; `bootout gui//com.tracedecay.daemon` (tolerating not-loaded, for idempotent re-install); `enable ...`; `bootstrap gui/ `; `kickstart -k ...` | +| install with `--no-start` | write unit only | write plist; `disable gui//com.tracedecay.daemon` so launchd does not autostart the agent at the next login | +| refresh | write unit; `daemon-reload`; `enable`; `restart` | write plist; `bootout gui//com.tracedecay.daemon` (tolerating not-loaded); `enable ...`; `bootstrap gui/ `; `kickstart -k ...` | +| uninstall with stop | `disable --now`; remove unit; `daemon-reload` | `bootout gui//com.tracedecay.daemon` if loaded; `disable gui//com.tracedecay.daemon`; remove plist | +| uninstall with `--no-stop` | remove unit only | remove plist only | +| status | unit path + socket + journald hint | plist path + socket + `launchctl print` / log hints | + +Implementation details: + +- Treat "not bootstrapped/not found" during `bootout` as non-fatal for uninstall + and refresh, just like the Linux uninstall ignores failed `disable --now`. +- After install/refresh with start, verify either the socket becomes connectable + briefly or `launchctl print ` succeeds. This catches command + failures that otherwise appear only in logs. +- Do not call `bootstrap` or `kickstart` for `--no-start`; persist a `disable` + instead so the plist in `~/Library/LaunchAgents` stays inert at login. + +## 6. Plist Rendering + +Add: + +```rust +impl DaemonServiceSpec { + pub fn render_launchd_plist(&self) -> Result; +} +``` + +The plist: + +```xml + + + + + Label + com.tracedecay.daemon + + ProgramArguments + + {absolute_tracedecay_bin} + daemon + run + --socket + {socket_path} + + + EnvironmentVariables + + PATH + {daemon_service_path_env(bin)} + HOME + {home} + + + RunAtLoad + + + KeepAlive + + SuccessfulExit + + + + ThrottleInterval + 2 + + StandardOutPath + {user_data_dir}/daemon.out.log + + StandardErrorPath + {user_data_dir}/daemon.err.log + + +``` + +Renderer requirements: + +- XML-escape `&`, `<`, `>`, `"`, and `'`. +- Require an absolute binary path for launchd. `which_tracedecay()` should already + produce one in normal installs; error clearly if it does not. +- Include `TRACEDECAY_DATA_DIR` in `EnvironmentVariables` when it is set during + install. This preserves custom profile roots across launchd restarts. +- Create the log/socket data directory before bootstrapping, because launchd can + create log files but cannot create missing parent directories. +- Set LaunchAgent plist permissions explicitly after writing. Use at most `0644` + and avoid group/world-writable files. + +## 7. Plist Parsing + +Add: + +```rust +fn socket_path_from_launchd_plist(plist: &str) -> Option; +``` + +Minimum acceptable parser: + +1. find the `ProgramArguments` array; +2. collect `...` values in order; +3. XML-unescape those string values; +4. return the value after `--socket`, or the value from `--socket=...` if ever + emitted in the future. + +Do not return escaped XML text. Existing custom socket preservation depends on +this parser during `refresh_installed_service`. + +If adding a small plist parsing dependency is acceptable, prefer a real plist +parser. If not, keep the ad hoc parser tightly scoped and heavily tested. + +## 8. `src/daemon.rs` Changes + +### 8.1 Platform path helpers + +Replace internal calls to `systemd_user_service_path()` with: + +```rust +fn service_unit_path() -> Result; +``` + +Behavior: + +- Linux: existing `~/.config/systemd/user/tracedecay.service`; +- macOS: `~/Library/LaunchAgents/com.tracedecay.daemon.plist`; +- other: service install unsupported. + +Keep `systemd_user_service_path()` as a private Linux helper. + +### 8.2 Render/parse dispatch + +Add: + +```rust +impl DaemonServiceSpec { + fn render_unit(&self) -> Result; +} + +fn socket_path_from_unit_text(text: &str) -> Option; +``` + +Linux dispatches to existing systemd helpers. macOS dispatches to the new plist +helpers. + +### 8.3 ServiceRunner methods + +```rust +impl ServiceRunner { + fn current() -> Result; + fn install(&self, service_path: &Path, start: bool, socket_path: &Path) -> Result<()>; + fn refresh(&self, service_path: &Path, socket_path: &Path) -> Result<()>; + fn uninstall(&self, service_path: &Path, stop: bool) -> Result<()>; + fn log_hint(&self) -> String; + fn service_detail_hint(&self) -> Option; +} +``` + +Use `socket_path` only for optional post-start verification. Keep the public API +signatures unchanged. + +### 8.4 Public function rewiring + +Refactor: + +- `install_service` +- `refresh_service` +- `refresh_installed_service` +- `write_service_unit` +- `installed_service_socket_path` +- `service_socket_path_from_unit_file` +- `uninstall_service` +- `service_status` + +The public surface remains unchanged. Only internal platform dispatch changes. + +### 8.5 Status output + +Keep status stable and useful: + +```text +service: /Users/you/Library/LaunchAgents/com.tracedecay.daemon.plist +socket: /Users/you/.tracedecay/daemon.sock (connectable) +service-detail: launchctl print gui/501/com.tracedecay.daemon +logs: tail -f "/Users/you/.tracedecay/daemon.err.log" +``` + +For Linux, keep the existing journald hint. + +Do not make status depend on parsing unstable `launchctl print` output. It is +fine to include the command as a diagnostic hint. If the implementation probes +service load state, treat it as best-effort. + +## 9. Docs + +Update all docs that currently describe daemon support as Linux-only or absent: + +- `README.md` + - daemon debugging section: show Linux and macOS commands; + - CLI reference: remove "Linux systemd" qualifier from `daemon install-service`; + - mention macOS logs under `/daemon.err.log`. +- `docs/USER-GUIDE.md` + - add macOS daemon setup under the install or keeping-fresh flow; + - show install, status, uninstall. +- `SECURITY.md` + - replace the current "No background daemon" statement with an accurate + opt-in model: no daemon is installed by default, but users can explicitly + install a per-user systemd/launchd service that runs with standard user + privileges. + +## 10. Tests + +### 10.1 Ungated unit tests + +These run on every platform: + +- `render_launchd_plist_includes_label_program_arguments_socket_and_logs` +- `render_launchd_plist_escapes_xml_special_characters` +- `render_launchd_plist_includes_trace_decay_data_dir_when_set` +- `socket_path_from_launchd_plist_round_trips_rendered_socket` +- `socket_path_from_launchd_plist_unescapes_xml` +- `socket_path_from_launchd_plist_returns_none_for_malformed_input` +- `service_unit_path_unsupported_platform_error_mentions_service_install` + if practical to test via helper injection. + +### 10.2 Linux regression tests + +Existing Linux tests must keep passing: + +- `user_service_runs_daemon_with_socket_path` +- `refresh_service_rewrites_unit_and_restarts_daemon` +- `refresh_installed_service_skips_missing_unit` +- `refresh_installed_service_preserves_existing_socket_path` + +If the systemd renderer signature changes to return `Result`, update the +tests mechanically without changing expected Linux output. + +### 10.3 macOS command tests without real launchd + +Add tests around command planning/fake command runner, not real `launchctl`: + +- install with start plans `bootout` (tolerated), `enable`, `bootstrap`, + `kickstart`; +- install with `--no-start` writes the plist and disables the agent; +- refresh preserves existing socket path and plans `bootout` (tolerated), + `enable`, `bootstrap`, `kickstart`; +- uninstall with stop plans `bootout`, `disable`, remove plist; +- uninstall with `--no-stop` removes plist only. + +These should not require root, a GUI session, or a real LaunchAgent. + +### 10.4 Optional ignored macOS smoke test + +One ignored/manual test is acceptable, but it must avoid clobbering a user's real +daemon: + +- use a test-only label like `com.tracedecay.daemon.test.`; +- write to a temporary plist path; +- use a temporary `TRACEDECAY_DATA_DIR`; +- always attempt cleanup with `bootout` and file removal. + +Do not use `com.tracedecay.daemon` in ignored tests. + +## 11. Risks and Decisions + +| Risk | Decision | +|---|---| +| legacy `launchctl load/unload` masks failures | use `bootstrap/bootout/enable/kickstart` | +| plist parent/log parent missing | create LaunchAgents dir and data dir before bootstrap | +| plist rejected due to permissions | set plist permissions explicitly | +| custom `TRACEDECAY_DATA_DIR` lost under launchd | persist it into plist env when set | +| custom socket path lost on update | parse plist and preserve existing `--socket` during refresh | +| status overpromises service state | show socket state and diagnostic launchctl command; probe best-effort only | +| scheduler expectations after reboot | document Linux parity: daemon starts at login; project schedulers start after project handshake | +| Homebrew binary path changes | `tracedecay update` refreshes plist with current binary path | +| log growth | same operational class as journald, but file rotation is a follow-up | + +## 12. Implementation Order + +1. **Pure service dispatch refactor** + - add `ServiceRunner`; + - add `service_unit_path`; + - add render/parse dispatch helpers; + - keep Linux behavior identical; + - run existing daemon tests. + +2. **Add launchd plist renderer/parser** + - add XML escape/unescape; + - add data-dir/log path handling; + - add ungated parser/renderer tests. + +3. **Add macOS launchctl backend** + - implement domain/target helpers; + - implement `bootstrap`, `bootout`, `enable`, `disable`, `kickstart`; + - create required directories and set plist permissions; + - add fake-command tests. + +4. **Wire refresh/uninstall/status** + - preserve existing socket paths; + - add macOS log and `launchctl print` hints; + - keep public API and CLI unchanged. + +5. **Docs** + - README; + - user guide; + - SECURITY.md. + +6. **Manual macOS verification** + - run on a real macOS GUI session; + - verify install/start/status/refresh/uninstall; + - verify reboot/login start. + +## 13. Verification + +Automated: + +```bash +cargo nextest run -p tracedecay daemon +cargo test -p tracedecay daemon_install_service_command_parses_socket_and_no_start +``` + +Manual macOS: + +```bash +tracedecay daemon install-service +tracedecay daemon status +launchctl print "gui/$(id -u)/com.tracedecay.daemon" +tail -f ~/.tracedecay/daemon.err.log +tracedecay update +tracedecay daemon uninstall-service +``` + +Expected: + +- plist exists at `~/Library/LaunchAgents/com.tracedecay.daemon.plist`; +- socket reports connectable after install/start; +- `launchctl print gui/$(id -u)/com.tracedecay.daemon` succeeds while installed; +- killing the daemon process causes launchd to restart it; +- after reboot and GUI login, launchd starts the daemon; +- `tracedecay update` refreshes plist binary path and keeps the installed socket + path; +- uninstall removes the plist and the launchd job. + +Scheduler verification, matching current Linux behavior: + +1. enable scheduler config for a project; +2. connect a project client through the daemon once; +3. verify `event=scheduler_tick` and task logs in `daemon.err.log`; +4. restart the daemon and reconnect the project client; +5. verify scheduler starts again. + +Do not require scheduler ticks immediately after reboot before any project +client has connected; that is not Linux parity. + +## 14. Source References + +| Topic | File:line | +|---|---| +| Linux-only service path gate | `src/daemon.rs:1715 systemd_user_service_path` | +| systemd unit renderer | `src/daemon.rs:154 render_systemd_user_unit` | +| public install/refresh/uninstall/status | `src/daemon.rs:455 / :466 / :474 / :540 / :560` | +| socket-path parser | `src/daemon.rs:523 socket_path_from_service_unit` | +| systemctl runner | `src/daemon.rs:1731 run_systemctl` | +| PATH env helper | `src/daemon.rs:177 daemon_service_path_env` | +| default socket path | `src/daemon.rs:237 default_socket_path` | +| foreground Unix daemon | `src/daemon.rs:988 run_foreground_unix` | +| scheduler starts from project server | `src/daemon.rs:1097 project_server` | +| scheduler config gate | `src/daemon.rs:1421 automation_scheduler_configured` | +| daemon action dispatch | `src/main.rs:742 Commands::Daemon` | +| post-update daemon refresh | `src/main.rs:413 refresh_daemon_service` | +| daemon CLI enum | `src/cli.rs:446 DaemonAction` | +| current security doc conflict | `SECURITY.md:103 No background daemon` | diff --git a/src/daemon.rs b/src/daemon.rs index d7048036..340c606b 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -3,10 +3,7 @@ use std::collections::HashMap; use std::fmt::Write; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; -#[cfg(unix)] -use std::os::unix::net::UnixStream as StdUnixStream; use std::path::{Path, PathBuf}; -use std::process::Command; #[cfg(unix)] use std::sync::Arc; @@ -33,6 +30,13 @@ pub const HOOK_EVENT_METHOD: &str = "tracedecay/hookEvent"; #[cfg(unix)] const HOOK_EVENT_NOTIFY_TIMEOUT: Duration = Duration::from_millis(750); +mod service; +pub use service::{ + daemon_reachable, default_socket_path, install_service, installed_service_socket_path, + refresh_installed_service, refresh_service, service_spec, service_status, + socket_path_or_default, uninstall_service, DaemonServiceSpec, +}; + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DaemonHookEvent { pub agent: String, @@ -99,12 +103,6 @@ impl DaemonHookEvent { } } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DaemonServiceSpec { - pub tracedecay_bin: PathBuf, - pub socket_path: PathBuf, -} - /// Per-connection metadata sent before JSON-RPC traffic. /// /// The daemon process is shared. This handshake tells that shared process which @@ -151,113 +149,6 @@ impl DaemonHandshake { } } -impl DaemonServiceSpec { - pub fn render_systemd_user_unit(&self) -> String { - let service_path = daemon_service_path_env(&self.tracedecay_bin); - format!( - "[Unit]\n\ - Description=TraceDecay daemon\n\ - After=network.target\n\ - \n\ - [Service]\n\ - Type=simple\n\ - Environment=\"PATH={}\"\n\ - ExecStart={} daemon run --socket {}\n\ - Restart=on-failure\n\ - RestartSec=2\n\ - \n\ - [Install]\n\ - WantedBy=default.target\n", - systemd_escape_env_value(&service_path), - self.tracedecay_bin.display(), - self.socket_path.display() - ) - } -} - -fn daemon_service_path_env(tracedecay_bin: &Path) -> String { - let mut dirs = Vec::new(); - - if let Some(parent) = tracedecay_bin - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - { - push_unique_path(&mut dirs, parent.to_path_buf()); - } - - if let Some(home) = std::env::var_os("HOME").filter(|home| !home.is_empty()) { - let home = PathBuf::from(home); - push_unique_path(&mut dirs, home.join(".cargo/bin")); - push_unique_path(&mut dirs, home.join(".local/bin")); - } - - if let Some(path) = std::env::var_os("PATH").filter(|path| !path.is_empty()) { - for dir in std::env::split_paths(&path) { - push_unique_path(&mut dirs, dir); - } - } - - for dir in [ - "/usr/local/sbin", - "/usr/local/bin", - "/usr/sbin", - "/usr/bin", - "/sbin", - "/bin", - "/opt/homebrew/bin", - ] { - push_unique_path(&mut dirs, PathBuf::from(dir)); - } - - std::env::join_paths(&dirs).map_or_else( - |_| { - dirs.iter() - .map(|path| path.to_string_lossy()) - .collect::>() - .join(":") - }, - |path| path.to_string_lossy().into_owned(), - ) -} - -fn push_unique_path(paths: &mut Vec, path: PathBuf) { - if path.as_os_str().is_empty() || paths.iter().any(|existing| existing == &path) { - return; - } - paths.push(path); -} - -fn systemd_escape_env_value(value: &str) -> String { - value - .replace('\\', "\\\\") - .replace('"', "\\\"") - .replace('%', "%%") -} - -pub fn default_socket_path() -> Result { - if let Some(path) = std::env::var_os(SOCKET_ENV).filter(|path| !path.is_empty()) { - return Ok(PathBuf::from(path)); - } - let data_dir = crate::config::user_data_dir().ok_or_else(|| TraceDecayError::Config { - message: "could not determine TraceDecay user data directory".to_string(), - })?; - Ok(data_dir.join("daemon.sock")) -} - -pub fn socket_path_or_default(socket: Option) -> Result { - socket.map_or_else(default_socket_path, |path| Ok(PathBuf::from(path))) -} - -pub fn service_spec( - tracedecay_bin: impl Into, - socket: Option, -) -> Result { - Ok(DaemonServiceSpec { - tracedecay_bin: tracedecay_bin.into(), - socket_path: socket_path_or_default(socket)?, - }) -} - #[cfg(unix)] pub async fn notify_hook_event(project_path: &Path, event: DaemonHookEvent) { let _ = timeout( @@ -459,162 +350,6 @@ fn read_hook_marker_secs(path: &Path) -> Option { .ok() } -pub fn install_service(spec: &DaemonServiceSpec, start: bool) -> Result { - let service_path = write_service_unit(spec)?; - - if start { - run_systemctl(&["daemon-reload"])?; - run_systemctl(&["enable", "--now", SERVICE_NAME])?; - } - - Ok(service_path) -} - -pub fn refresh_service(spec: &DaemonServiceSpec) -> Result { - let service_path = write_service_unit(spec)?; - run_systemctl(&["daemon-reload"])?; - run_systemctl(&["enable", SERVICE_NAME])?; - run_systemctl(&["restart", SERVICE_NAME])?; - Ok(service_path) -} - -pub fn refresh_installed_service(spec: &DaemonServiceSpec) -> Result> { - let service_path = systemd_user_service_path()?; - if !service_path.exists() { - return Ok(None); - } - let mut refreshed_spec = spec.clone(); - if let Some(socket_path) = service_socket_path_from_unit_file(&service_path)? { - refreshed_spec.socket_path = socket_path; - } - refresh_service(&refreshed_spec).map(Some) -} - -fn write_service_unit(spec: &DaemonServiceSpec) -> Result { - let service_path = systemd_user_service_path()?; - let parent = service_path - .parent() - .ok_or_else(|| TraceDecayError::Config { - message: format!("service path '{}' has no parent", service_path.display()), - })?; - std::fs::create_dir_all(parent).map_err(|e| TraceDecayError::Config { - message: format!( - "failed to create service directory '{}': {e}", - parent.display() - ), - })?; - std::fs::write(&service_path, spec.render_systemd_user_unit()).map_err(|e| { - TraceDecayError::Config { - message: format!("failed to write service '{}': {e}", service_path.display()), - } - })?; - - Ok(service_path) -} - -pub fn installed_service_socket_path() -> Result> { - let service_path = systemd_user_service_path()?; - if !service_path.exists() { - return Ok(None); - } - service_socket_path_from_unit_file(&service_path) -} - -fn service_socket_path_from_unit_file(service_path: &Path) -> Result> { - let unit = std::fs::read_to_string(service_path).map_err(|e| TraceDecayError::Config { - message: format!("failed to read service '{}': {e}", service_path.display()), - })?; - Ok(socket_path_from_service_unit(&unit)) -} - -fn socket_path_from_service_unit(unit: &str) -> Option { - unit.lines() - .filter_map(|line| line.trim().strip_prefix("ExecStart=")) - .find_map(|exec_start| { - let mut args = exec_start.split_whitespace(); - while let Some(arg) = args.next() { - if arg == "--socket" { - return args.next().map(PathBuf::from); - } - if let Some(path) = arg.strip_prefix("--socket=") { - return Some(PathBuf::from(path)); - } - } - None - }) -} - -pub fn uninstall_service(stop: bool) -> Result { - let service_path = systemd_user_service_path()?; - if stop { - let _ = run_systemctl(&["disable", "--now", SERVICE_NAME]); - } - match std::fs::remove_file(&service_path) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => { - return Err(TraceDecayError::Config { - message: format!("failed to remove service '{}': {e}", service_path.display()), - }); - } - } - if stop { - let _ = run_systemctl(&["daemon-reload"]); - } - Ok(service_path) -} - -pub fn service_status(socket_path: &Path) -> String { - let socket_state = daemon_socket_state(socket_path); - format!( - "service: {}\nsocket: {} ({})\nlogs: journalctl --user -u {} -f\n", - systemd_user_service_path().map_or_else( - |e| format!("unavailable: {e}"), - |path| path.display().to_string() - ), - socket_path.display(), - socket_state, - SERVICE_NAME - ) -} - -/// Whether a daemon is accepting connections at the default socket path. -/// -/// Installers use this to warn when a daemon-scheduled feature is enabled but -/// no daemon service is running to execute it. -#[cfg(unix)] -pub fn daemon_reachable() -> bool { - default_socket_path().is_ok_and(|path| StdUnixStream::connect(path).is_ok()) -} - -/// The daemon (and its scheduler) is unix-only; see [`run_foreground`]. -#[cfg(not(unix))] -pub fn daemon_reachable() -> bool { - false -} - -#[cfg(unix)] -fn daemon_socket_state(socket_path: &Path) -> &'static str { - if !socket_path.exists() { - return "missing"; - } - match StdUnixStream::connect(socket_path) { - Ok(_) => "connectable", - Err(e) if e.kind() == std::io::ErrorKind::ConnectionRefused => "stale", - Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => "present but not accessible", - Err(_) => "present but unreachable", - } -} - -#[cfg(not(unix))] -fn daemon_socket_state(socket_path: &Path) -> &'static str { - if socket_path.exists() { - "present" - } else { - "missing" - } -} - fn format_daemon_log_line(event: &str, fields: &[(&str, String)]) -> String { let mut line = format!("[tracedecay] event={}", quote_log_value(event)); for (key, value) in fields { @@ -1734,43 +1469,6 @@ impl crate::mcp::McpTransport for UnixStreamTransport { } } -fn systemd_user_service_path() -> Result { - if cfg!(not(target_os = "linux")) { - return Err(TraceDecayError::Config { - message: "daemon service install is currently supported on Linux systemd user services" - .to_string(), - }); - } - let config_home = std::env::var_os("XDG_CONFIG_HOME") - .map(PathBuf::from) - .or_else(|| dirs::home_dir().map(|home| home.join(".config"))) - .ok_or_else(|| TraceDecayError::Config { - message: "could not determine XDG config directory".to_string(), - })?; - Ok(config_home.join("systemd/user").join(SERVICE_NAME)) -} - -fn run_systemctl(args: &[&str]) -> Result<()> { - let output = Command::new("systemctl") - .arg("--user") - .args(args) - .output() - .map_err(|e| TraceDecayError::Config { - message: format!("failed to run systemctl --user {}: {e}", args.join(" ")), - })?; - if output.status.success() { - return Ok(()); - } - Err(TraceDecayError::Config { - message: format!( - "systemctl --user {} failed with status {}\n{}", - args.join(" "), - output.status, - String::from_utf8_lossy(&output.stderr) - ), - }) -} - #[cfg(not(unix))] fn unsupported_platform() -> TraceDecayError { TraceDecayError::Config { @@ -1781,70 +1479,18 @@ fn unsupported_platform() -> TraceDecayError { #[cfg(test)] #[allow(clippy::expect_used)] mod tests { - use std::ffi::{OsStr, OsString}; use std::path::PathBuf; - use std::sync::Mutex; + #[cfg(unix)] use serde_json::json; #[cfg(unix)] use serde_json::Value; - #[cfg(target_os = "linux")] - use std::os::unix::fs::PermissionsExt; #[cfg(unix)] use tempfile::TempDir; #[cfg(unix)] use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; - use super::{DaemonClientIdentity, DaemonHandshake, DaemonServiceSpec, SOCKET_ENV}; - - static ENV_LOCK: Mutex<()> = Mutex::new(()); - - struct EnvVarGuard { - key: &'static str, - previous: Option, - } - - impl EnvVarGuard { - fn set(key: &'static str, value: impl AsRef) -> Self { - let previous = std::env::var_os(key); - std::env::set_var(key, value); - Self { key, previous } - } - - fn unset(key: &'static str) -> Self { - let previous = std::env::var_os(key); - std::env::remove_var(key); - Self { key, previous } - } - } - - impl Drop for EnvVarGuard { - fn drop(&mut self) { - if let Some(previous) = self.previous.take() { - std::env::set_var(self.key, previous); - } else { - std::env::remove_var(self.key); - } - } - } - - struct CurrentDirGuard { - previous: PathBuf, - } - - impl CurrentDirGuard { - fn set(path: impl AsRef) -> Self { - let previous = std::env::current_dir().expect("current dir"); - std::env::set_current_dir(path).expect("set current dir"); - Self { previous } - } - } - - impl Drop for CurrentDirGuard { - fn drop(&mut self) { - std::env::set_current_dir(&self.previous).expect("restore current dir"); - } - } + use super::{DaemonClientIdentity, DaemonHandshake}; fn test_client_identity() -> DaemonClientIdentity { test_client_identity_for(PathBuf::from("/profiles/client")) @@ -1915,61 +1561,6 @@ mod tests { ); } - #[test] - fn service_status_includes_journalctl_debug_command() { - let status = super::service_status(&PathBuf::from("/tmp/tracedecay.sock")); - - assert!(status.contains("logs: journalctl --user -u tracedecay.service -f")); - } - - #[cfg(unix)] - #[test] - fn service_status_reports_missing_socket() { - let dir = TempDir::new().expect("temp dir"); - let socket = dir.path().join("missing.sock"); - - let status = super::service_status(&socket); - - assert!( - status.contains(&format!("socket: {} (missing)", socket.display())), - "status should report missing socket, got:\n{status}" - ); - } - - #[cfg(unix)] - #[test] - fn service_status_reports_unconnectable_socket_file() { - let dir = TempDir::new().expect("temp dir"); - let socket = dir.path().join("unconnectable.sock"); - std::fs::write(&socket, "").expect("unconnectable socket placeholder"); - - let status = super::service_status(&socket); - - assert!( - status.contains(&format!("socket: {} (stale)", socket.display())) - || status.contains(&format!( - "socket: {} (present but unreachable)", - socket.display() - )), - "status should report an unconnectable socket, got:\n{status}" - ); - } - - #[cfg(unix)] - #[test] - fn service_status_reports_connectable_socket() { - let dir = TempDir::new().expect("temp dir"); - let socket = dir.path().join("daemon.sock"); - let _listener = std::os::unix::net::UnixListener::bind(&socket).expect("bind socket"); - - let status = super::service_status(&socket); - - assert!( - status.contains(&format!("socket: {} (connectable)", socket.display())), - "status should report connectable socket, got:\n{status}" - ); - } - #[cfg(unix)] #[test] fn scheduler_task_start_log_uses_task_key_and_project() { @@ -2034,187 +1625,6 @@ mod tests { ); } - #[test] - fn user_service_runs_daemon_with_socket_path() { - let spec = DaemonServiceSpec { - tracedecay_bin: PathBuf::from("/usr/local/bin/tracedecay"), - socket_path: PathBuf::from("/tmp/tracedecay.sock"), - }; - - let unit = spec.render_systemd_user_unit(); - - assert!(unit.contains( - "ExecStart=/usr/local/bin/tracedecay daemon run --socket /tmp/tracedecay.sock" - )); - assert!(unit.contains("Environment=\"PATH=")); - assert!(unit.contains("Restart=on-failure")); - } - - #[cfg(target_os = "linux")] - #[test] - fn refresh_service_rewrites_unit_and_restarts_daemon() { - let _env_lock = ENV_LOCK.lock().expect("env lock"); - let dir = TempDir::new().expect("temp dir"); - let config_home = dir.path().join("config"); - let fake_bin = dir.path().join("bin"); - let home = dir.path().join("home"); - std::fs::create_dir_all(&fake_bin).expect("fake bin dir"); - std::fs::create_dir_all(&home).expect("home dir"); - - let systemctl = fake_bin.join("systemctl"); - let log = dir.path().join("systemctl.log"); - std::fs::write( - &systemctl, - "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$TRACEDECAY_SYSTEMCTL_LOG\"\n", - ) - .expect("fake systemctl"); - std::fs::set_permissions(&systemctl, std::fs::Permissions::from_mode(0o755)) - .expect("systemctl permissions"); - - let _config_guard = EnvVarGuard::set("XDG_CONFIG_HOME", &config_home); - let _home_guard = EnvVarGuard::set("HOME", &home); - let _path_guard = EnvVarGuard::set("PATH", &fake_bin); - let _log_guard = EnvVarGuard::set("TRACEDECAY_SYSTEMCTL_LOG", &log); - let spec = DaemonServiceSpec { - tracedecay_bin: PathBuf::from("/opt/tracedecay/bin/tracedecay"), - socket_path: PathBuf::from("/run/user/1000/tracedecay.sock"), - }; - - let service_path = super::refresh_service(&spec).expect("refresh service"); - - assert_eq!( - service_path, - config_home.join("systemd/user").join(super::SERVICE_NAME) - ); - let unit = std::fs::read_to_string(&service_path).expect("service unit"); - assert!(unit.contains( - "ExecStart=/opt/tracedecay/bin/tracedecay daemon run --socket /run/user/1000/tracedecay.sock" - )); - assert_eq!( - std::fs::read_to_string(log).expect("systemctl log"), - "--user daemon-reload\n--user enable tracedecay.service\n--user restart tracedecay.service\n" - ); - } - - #[cfg(target_os = "linux")] - #[test] - fn refresh_installed_service_skips_missing_unit() { - let _env_lock = ENV_LOCK.lock().expect("env lock"); - let dir = TempDir::new().expect("temp dir"); - let config_home = dir.path().join("config"); - let fake_bin = dir.path().join("bin"); - let home = dir.path().join("home"); - std::fs::create_dir_all(&fake_bin).expect("fake bin dir"); - std::fs::create_dir_all(&home).expect("home dir"); - - let _config_guard = EnvVarGuard::set("XDG_CONFIG_HOME", &config_home); - let _home_guard = EnvVarGuard::set("HOME", &home); - let _path_guard = EnvVarGuard::set("PATH", &fake_bin); - let spec = DaemonServiceSpec { - tracedecay_bin: PathBuf::from("/opt/tracedecay/bin/tracedecay"), - socket_path: PathBuf::from("/run/user/1000/tracedecay.sock"), - }; - - let service_path = config_home.join("systemd/user").join(super::SERVICE_NAME); - let outcome = super::refresh_installed_service(&spec).expect("refresh service"); - - assert_eq!(outcome, None); - assert!(!service_path.exists()); - } - - #[cfg(target_os = "linux")] - #[test] - fn refresh_installed_service_preserves_existing_socket_path() { - let _env_lock = ENV_LOCK.lock().expect("env lock"); - let dir = TempDir::new().expect("temp dir"); - let config_home = dir.path().join("config"); - let fake_bin = dir.path().join("bin"); - let home = dir.path().join("home"); - std::fs::create_dir_all(&fake_bin).expect("fake bin dir"); - std::fs::create_dir_all(&home).expect("home dir"); - - let systemctl = fake_bin.join("systemctl"); - let log = dir.path().join("systemctl.log"); - std::fs::write( - &systemctl, - "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$TRACEDECAY_SYSTEMCTL_LOG\"\n", - ) - .expect("fake systemctl"); - std::fs::set_permissions(&systemctl, std::fs::Permissions::from_mode(0o755)) - .expect("systemctl permissions"); - - let _config_guard = EnvVarGuard::set("XDG_CONFIG_HOME", &config_home); - let _home_guard = EnvVarGuard::set("HOME", &home); - let _path_guard = EnvVarGuard::set("PATH", &fake_bin); - let _log_guard = EnvVarGuard::set("TRACEDECAY_SYSTEMCTL_LOG", &log); - - let service_path = config_home.join("systemd/user").join(super::SERVICE_NAME); - std::fs::create_dir_all(service_path.parent().expect("service parent")) - .expect("service dir"); - std::fs::write( - &service_path, - "[Unit]\n\ - Description=TraceDecay daemon\n\ - \n\ - [Service]\n\ - ExecStart=/old/tracedecay daemon run --socket /custom/tracedecay.sock\n", - ) - .expect("existing service unit"); - - let spec = DaemonServiceSpec { - tracedecay_bin: PathBuf::from("/opt/tracedecay/bin/tracedecay"), - socket_path: PathBuf::from("/run/user/1000/tracedecay.sock"), - }; - - let outcome = super::refresh_installed_service(&spec).expect("refresh service"); - - assert_eq!(outcome, Some(service_path.clone())); - let unit = std::fs::read_to_string(service_path).expect("service unit"); - assert!(unit.contains( - "ExecStart=/opt/tracedecay/bin/tracedecay daemon run --socket /custom/tracedecay.sock" - )); - assert!(!unit.contains("/run/user/1000/tracedecay.sock")); - assert_eq!( - std::fs::read_to_string(log).expect("systemctl log"), - "--user daemon-reload\n--user enable tracedecay.service\n--user restart tracedecay.service\n" - ); - } - - #[test] - fn default_socket_path_is_profile_scoped_not_project_scoped() { - let _env_lock = ENV_LOCK.lock().expect("env lock"); - let profile = tempfile::TempDir::new().expect("profile temp dir"); - let project_a = tempfile::TempDir::new().expect("project a temp dir"); - let project_b = tempfile::TempDir::new().expect("project b temp dir"); - let override_socket = profile.path().join("override.sock"); - let _socket_guard = EnvVarGuard::unset(SOCKET_ENV); - let _data_dir_guard = EnvVarGuard::set( - crate::config::USER_DATA_DIR_ENV, - profile.path().join(".tracedecay"), - ); - - { - let _cwd_guard = CurrentDirGuard::set(project_a.path()); - assert_eq!( - super::default_socket_path().expect("default socket path"), - profile.path().join(".tracedecay/daemon.sock") - ); - } - { - let _cwd_guard = CurrentDirGuard::set(project_b.path()); - assert_eq!( - super::default_socket_path().expect("default socket path"), - profile.path().join(".tracedecay/daemon.sock") - ); - } - - let _override_guard = EnvVarGuard::set(SOCKET_ENV, &override_socket); - assert_eq!( - super::default_socket_path().expect("override socket path"), - override_socket - ); - } - #[test] fn daemon_handshake_round_trips_project_scope_and_timings() { let handshake = DaemonHandshake { diff --git a/src/daemon/service.rs b/src/daemon/service.rs new file mode 100644 index 00000000..f554a215 --- /dev/null +++ b/src/daemon/service.rs @@ -0,0 +1,1400 @@ +use std::fmt::Write; +#[cfg(target_os = "macos")] +use std::os::unix::fs::PermissionsExt; +#[cfg(unix)] +use std::os::unix::net::UnixStream as StdUnixStream; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::errors::{Result, TraceDecayError}; + +use super::SOCKET_ENV; + +const LAUNCHD_LABEL: &str = "com.tracedecay.daemon"; +const LAUNCHD_PLIST_NAME: &str = "com.tracedecay.daemon.plist"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DaemonServiceSpec { + pub tracedecay_bin: PathBuf, + pub socket_path: PathBuf, + pub data_dir_override: Option, +} + +impl DaemonServiceSpec { + pub fn render_systemd_user_unit(&self) -> String { + let service_path = daemon_service_path_env(&self.tracedecay_bin); + format!( + "[Unit]\n\ + Description=TraceDecay daemon\n\ + After=network.target\n\ + \n\ + [Service]\n\ + Type=simple\n\ + Environment=\"PATH={}\"\n\ + ExecStart={} daemon run --socket {}\n\ + Restart=on-failure\n\ + RestartSec=2\n\ + \n\ + [Install]\n\ + WantedBy=default.target\n", + systemd_escape_env_value(&service_path), + self.tracedecay_bin.display(), + self.socket_path.display() + ) + } + + pub fn render_launchd_plist(&self) -> Result { + if !self.tracedecay_bin.is_absolute() { + return Err(TraceDecayError::Config { + message: format!( + "launchd daemon service requires an absolute tracedecay binary path, got '{}'", + self.tracedecay_bin.display() + ), + }); + } + + let home = home_for_service_env()?; + let data_dir = match &self.data_dir_override { + Some(dir) => dir.clone(), + None => tracedecay_data_dir()?, + }; + let mut env_entries = vec![ + ( + "PATH".to_string(), + daemon_service_path_env(&self.tracedecay_bin), + ), + ("HOME".to_string(), home.display().to_string()), + ]; + if let Some(data_dir_override) = &self.data_dir_override { + env_entries.push(( + crate::config::USER_DATA_DIR_ENV.to_string(), + data_dir_override.display().to_string(), + )); + } + + let mut environment = String::new(); + for (key, value) in env_entries { + let _ = write!( + environment, + " {}\n {}\n", + plist_xml_escape(&key), + plist_xml_escape(&value) + ); + } + + Ok(format!( + "\n\ + \n\ + \n\ + \n\ + Label\n\ + {label}\n\ + \n\ + ProgramArguments\n\ + \n\ + {bin}\n\ + daemon\n\ + run\n\ + --socket\n\ + {socket}\n\ + \n\ + \n\ + EnvironmentVariables\n\ + \n\ + {environment}\ + \n\ + \n\ + RunAtLoad\n\ + \n\ + \n\ + KeepAlive\n\ + \n\ + SuccessfulExit\n\ + \n\ + \n\ + \n\ + ThrottleInterval\n\ + 2\n\ + \n\ + StandardOutPath\n\ + {stdout}\n\ + \n\ + StandardErrorPath\n\ + {stderr}\n\ + \n\ + \n", + label = plist_xml_escape(LAUNCHD_LABEL), + bin = plist_xml_escape(&self.tracedecay_bin.display().to_string()), + socket = plist_xml_escape(&self.socket_path.display().to_string()), + stdout = plist_xml_escape(&data_dir.join("daemon.out.log").display().to_string()), + stderr = plist_xml_escape(&data_dir.join("daemon.err.log").display().to_string()), + )) + } + + fn render_unit(&self) -> Result { + match ServiceRunner::current()? { + ServiceRunner::Systemd => Ok(self.render_systemd_user_unit()), + ServiceRunner::Launchd => self.render_launchd_plist(), + } + } +} + +fn daemon_service_path_env(tracedecay_bin: &Path) -> String { + let mut dirs = Vec::new(); + + if let Some(parent) = tracedecay_bin + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + push_unique_path(&mut dirs, parent.to_path_buf()); + } + + if let Some(home) = std::env::var_os("HOME").filter(|home| !home.is_empty()) { + let home = PathBuf::from(home); + push_unique_path(&mut dirs, home.join(".cargo/bin")); + push_unique_path(&mut dirs, home.join(".local/bin")); + } + + if let Some(path) = std::env::var_os("PATH").filter(|path| !path.is_empty()) { + for dir in std::env::split_paths(&path) { + push_unique_path(&mut dirs, dir); + } + } + + for dir in [ + "/usr/local/sbin", + "/usr/local/bin", + "/usr/sbin", + "/usr/bin", + "/sbin", + "/bin", + "/opt/homebrew/bin", + ] { + push_unique_path(&mut dirs, PathBuf::from(dir)); + } + + std::env::join_paths(&dirs).map_or_else( + |_| { + dirs.iter() + .map(|path| path.to_string_lossy()) + .collect::>() + .join(":") + }, + |path| path.to_string_lossy().into_owned(), + ) +} + +fn push_unique_path(paths: &mut Vec, path: PathBuf) { + if path.as_os_str().is_empty() || paths.iter().any(|existing| existing == &path) { + return; + } + paths.push(path); +} + +fn systemd_escape_env_value(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('%', "%%") +} + +fn plist_xml_escape(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '&' => escaped.push_str("&"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + '"' => escaped.push_str("""), + '\'' => escaped.push_str("'"), + _ => escaped.push(ch), + } + } + escaped +} + +fn plist_xml_unescape(value: &str) -> String { + value + .replace(""", "\"") + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") +} + +fn home_for_service_env() -> Result { + std::env::var_os("HOME") + .filter(|home| !home.is_empty()) + .map(PathBuf::from) + .or_else(dirs::home_dir) + .ok_or_else(|| TraceDecayError::Config { + message: "could not determine home directory for daemon service".to_string(), + }) +} + +fn tracedecay_data_dir() -> Result { + crate::config::user_data_dir().ok_or_else(|| TraceDecayError::Config { + message: "could not determine TraceDecay user data directory".to_string(), + }) +} + +pub fn default_socket_path() -> Result { + if let Some(path) = std::env::var_os(SOCKET_ENV).filter(|path| !path.is_empty()) { + return Ok(PathBuf::from(path)); + } + Ok(tracedecay_data_dir()?.join("daemon.sock")) +} + +pub fn socket_path_or_default(socket: Option) -> Result { + socket.map_or_else(default_socket_path, |path| Ok(PathBuf::from(path))) +} + +pub fn service_spec( + tracedecay_bin: impl Into, + socket: Option, +) -> Result { + Ok(DaemonServiceSpec { + tracedecay_bin: tracedecay_bin.into(), + socket_path: socket_path_or_default(socket)?, + data_dir_override: std::env::var_os(crate::config::USER_DATA_DIR_ENV) + .filter(|value| !value.is_empty()) + .map(PathBuf::from), + }) +} + +pub fn install_service(spec: &DaemonServiceSpec, start: bool) -> Result { + let runner = ServiceRunner::current()?; + let service_path = write_service_unit(spec)?; + runner.install(&service_path, start, &spec.socket_path)?; + + Ok(service_path) +} + +pub fn refresh_service(spec: &DaemonServiceSpec) -> Result { + let runner = ServiceRunner::current()?; + let service_path = write_service_unit(spec)?; + runner.refresh(&service_path, &spec.socket_path)?; + Ok(service_path) +} + +pub fn refresh_installed_service(spec: &DaemonServiceSpec) -> Result> { + let service_path = service_unit_path()?; + if !service_path.exists() { + return Ok(None); + } + let unit = read_service_unit(&service_path)?; + let mut refreshed_spec = spec.clone(); + if let Some(socket_path) = socket_path_from_unit_text(&unit) { + refreshed_spec.socket_path = socket_path; + } + if matches!(ServiceRunner::current(), Ok(ServiceRunner::Launchd)) { + // The installed plist is the source of truth for the daemon's data + // directory; the refreshing shell may not have the override set. + refreshed_spec.data_dir_override = + launchd_plist_env_value(&unit, crate::config::USER_DATA_DIR_ENV).map(PathBuf::from); + } + refresh_service(&refreshed_spec).map(Some) +} + +fn write_service_unit(spec: &DaemonServiceSpec) -> Result { + let service_path = service_unit_path()?; + let parent = service_path + .parent() + .ok_or_else(|| TraceDecayError::Config { + message: format!("service path '{}' has no parent", service_path.display()), + })?; + std::fs::create_dir_all(parent).map_err(|e| TraceDecayError::Config { + message: format!( + "failed to create service directory '{}': {e}", + parent.display() + ), + })?; + std::fs::write(&service_path, spec.render_unit()?).map_err(|e| TraceDecayError::Config { + message: format!("failed to write service '{}': {e}", service_path.display()), + })?; + #[cfg(target_os = "macos")] + std::fs::set_permissions(&service_path, std::fs::Permissions::from_mode(0o644)).map_err( + |e| TraceDecayError::Config { + message: format!( + "failed to set service permissions '{}': {e}", + service_path.display() + ), + }, + )?; + + Ok(service_path) +} + +pub fn installed_service_socket_path() -> Result> { + let service_path = service_unit_path()?; + if !service_path.exists() { + return Ok(None); + } + Ok(socket_path_from_unit_text(&read_service_unit( + &service_path, + )?)) +} + +fn read_service_unit(service_path: &Path) -> Result { + std::fs::read_to_string(service_path).map_err(|e| TraceDecayError::Config { + message: format!("failed to read service '{}': {e}", service_path.display()), + }) +} + +fn socket_path_from_args<'a>(mut args: impl Iterator) -> Option { + while let Some(arg) = args.next() { + if arg == "--socket" { + return args.next().map(PathBuf::from); + } + if let Some(path) = arg.strip_prefix("--socket=") { + return Some(PathBuf::from(path)); + } + } + None +} + +fn socket_path_from_service_unit(unit: &str) -> Option { + unit.lines() + .filter_map(|line| line.trim().strip_prefix("ExecStart=")) + .find_map(|exec_start| socket_path_from_args(exec_start.split_whitespace())) +} + +fn socket_path_from_launchd_plist(plist: &str) -> Option { + let program_arguments_start = plist.find("ProgramArguments")?; + let arguments_text = &plist[program_arguments_start..]; + let array_start = arguments_text.find("")? + "".len(); + let after_array_start = &arguments_text[array_start..]; + let array_end = after_array_start.find("")?; + let array_text = &after_array_start[..array_end]; + let strings = plist_string_values(array_text); + + socket_path_from_args(strings.iter().map(String::as_str)) +} + +fn launchd_plist_env_value(plist: &str, name: &str) -> Option { + let env_start = plist.find("EnvironmentVariables")?; + let after_env = &plist[env_start..]; + let dict_start = after_env.find("")? + "".len(); + let after_dict_start = &after_env[dict_start..]; + let dict_end = after_dict_start.find("")?; + let dict_text = &after_dict_start[..dict_end]; + + let key_tag = format!("{}", plist_xml_escape(name)); + let key_end = dict_text.find(&key_tag)? + key_tag.len(); + plist_string_values(&dict_text[key_end..]) + .into_iter() + .next() +} + +fn plist_string_values(text: &str) -> Vec { + let mut values = Vec::new(); + let mut remaining = text; + while let Some(start) = remaining.find("") { + let value_start = start + "".len(); + let after_start = &remaining[value_start..]; + let Some(end) = after_start.find("") else { + break; + }; + values.push(plist_xml_unescape(&after_start[..end])); + remaining = &after_start[end + "".len()..]; + } + values +} + +fn socket_path_from_unit_text(unit: &str) -> Option { + match ServiceRunner::current().ok()? { + ServiceRunner::Systemd => socket_path_from_service_unit(unit), + ServiceRunner::Launchd => socket_path_from_launchd_plist(unit), + } +} + +pub fn uninstall_service(stop: bool) -> Result { + let runner = ServiceRunner::current()?; + let service_path = service_unit_path()?; + runner.before_uninstall(stop)?; + match std::fs::remove_file(&service_path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + return Err(TraceDecayError::Config { + message: format!("failed to remove service '{}': {e}", service_path.display()), + }); + } + } + runner.after_uninstall(stop); + Ok(service_path) +} + +pub fn service_status(socket_path: &Path) -> String { + let socket_state = daemon_socket_state(socket_path); + let service = service_unit_path().map_or_else( + |e| format!("unavailable: {e}"), + |path| path.display().to_string(), + ); + let runner = ServiceRunner::current(); + let detail = runner + .as_ref() + .ok() + .and_then(ServiceRunner::service_detail_hint) + .map(|hint| format!("service-detail: {hint}\n")) + .unwrap_or_default(); + let logs = runner.map_or_else(|e| format!("unavailable: {e}"), |runner| runner.log_hint()); + format!( + "service: {}\nsocket: {} ({})\n{}logs: {}\n", + service, + socket_path.display(), + socket_state, + detail, + logs, + ) +} + +/// Whether a daemon is accepting connections at the default socket path. +/// +/// Installers use this to warn when a daemon-scheduled feature is enabled but +/// no daemon service is running to execute it. +#[cfg(unix)] +pub fn daemon_reachable() -> bool { + default_socket_path().is_ok_and(|path| StdUnixStream::connect(path).is_ok()) +} + +/// The daemon (and its scheduler) is unix-only; see [`super::run_foreground`]. +#[cfg(not(unix))] +pub fn daemon_reachable() -> bool { + false +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DaemonSocketState { + Missing, + Connectable, + #[cfg(unix)] + Stale, + #[cfg(unix)] + PresentNotAccessible, + #[cfg(unix)] + PresentUnreachable, + #[cfg(not(unix))] + Present, +} + +impl std::fmt::Display for DaemonSocketState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let text = match self { + Self::Missing => "missing", + Self::Connectable => "connectable", + #[cfg(unix)] + Self::Stale => "stale", + #[cfg(unix)] + Self::PresentNotAccessible => "present but not accessible", + #[cfg(unix)] + Self::PresentUnreachable => "present but unreachable", + #[cfg(not(unix))] + Self::Present => "present", + }; + f.write_str(text) + } +} + +#[cfg(unix)] +fn daemon_socket_state(socket_path: &Path) -> DaemonSocketState { + if !socket_path.exists() { + return DaemonSocketState::Missing; + } + match StdUnixStream::connect(socket_path) { + Ok(_) => DaemonSocketState::Connectable, + Err(e) if e.kind() == std::io::ErrorKind::ConnectionRefused => DaemonSocketState::Stale, + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { + DaemonSocketState::PresentNotAccessible + } + Err(_) => DaemonSocketState::PresentUnreachable, + } +} + +#[cfg(not(unix))] +fn daemon_socket_state(socket_path: &Path) -> DaemonSocketState { + if socket_path.exists() { + DaemonSocketState::Present + } else { + DaemonSocketState::Missing + } +} + +/// Both variants exist on every platform so that `match` dispatch stays +/// exhaustive everywhere; `current()` is the only constructor and returns an +/// error on platforms without a supported service manager. +enum ServiceRunner { + Systemd, + Launchd, +} + +impl ServiceRunner { + fn current() -> Result { + if cfg!(target_os = "linux") { + Ok(Self::Systemd) + } else if cfg!(target_os = "macos") { + Ok(Self::Launchd) + } else { + Err(unsupported_service_platform()) + } + } + + fn install(&self, service_path: &Path, start: bool, socket_path: &Path) -> Result<()> { + match self { + Self::Systemd => { + if start { + run_systemctl(&["daemon-reload"])?; + run_systemctl(&["enable", "--now", super::SERVICE_NAME])?; + } + Ok(()) + } + Self::Launchd => launchd_install(service_path, start, socket_path), + } + } + + fn refresh(&self, service_path: &Path, socket_path: &Path) -> Result<()> { + match self { + Self::Systemd => { + run_systemctl(&["daemon-reload"])?; + run_systemctl(&["enable", super::SERVICE_NAME])?; + run_systemctl(&["restart", super::SERVICE_NAME])?; + Ok(()) + } + Self::Launchd => launchd_refresh(service_path, socket_path), + } + } + + fn before_uninstall(&self, stop: bool) -> Result<()> { + match self { + Self::Systemd => { + if stop { + let _ = run_systemctl(&["disable", "--now", super::SERVICE_NAME]); + } + Ok(()) + } + Self::Launchd => launchd_before_uninstall(stop), + } + } + + fn after_uninstall(&self, stop: bool) { + match self { + Self::Systemd => { + if stop { + let _ = run_systemctl(&["daemon-reload"]); + } + } + Self::Launchd => {} + } + } + + fn log_hint(&self) -> String { + match self { + Self::Systemd => format!("journalctl --user -u {} -f", super::SERVICE_NAME), + Self::Launchd => crate::config::user_data_dir().map_or_else( + || "tail -f /daemon.err.log".to_string(), + |dir| format!("tail -f \"{}\"", dir.join("daemon.err.log").display()), + ), + } + } + + fn service_detail_hint(&self) -> Option { + match self { + Self::Systemd => None, + Self::Launchd => launchd_service_target() + .ok() + .map(|target| format!("launchctl print {target}")), + } + } +} + +fn service_unit_path() -> Result { + match ServiceRunner::current()? { + ServiceRunner::Systemd => systemd_user_service_path(), + ServiceRunner::Launchd => launchd_user_service_path(), + } +} + +fn systemd_user_service_path() -> Result { + let config_home = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| dirs::home_dir().map(|home| home.join(".config"))) + .ok_or_else(|| TraceDecayError::Config { + message: "could not determine XDG config directory".to_string(), + })?; + Ok(config_home.join("systemd/user").join(super::SERVICE_NAME)) +} + +fn launchd_user_service_path() -> Result { + let home = home_for_service_env()?; + Ok(home.join("Library/LaunchAgents").join(LAUNCHD_PLIST_NAME)) +} + +fn run_systemctl(args: &[&str]) -> Result<()> { + let output = Command::new("systemctl") + .arg("--user") + .args(args) + .output() + .map_err(|e| TraceDecayError::Config { + message: format!("failed to run systemctl --user {}: {e}", args.join(" ")), + })?; + if output.status.success() { + return Ok(()); + } + Err(TraceDecayError::Config { + message: format!( + "systemctl --user {} failed with status {}\n{}", + args.join(" "), + output.status, + String::from_utf8_lossy(&output.stderr) + ), + }) +} + +fn unsupported_service_platform() -> TraceDecayError { + TraceDecayError::Config { + message: "daemon service install is currently supported on Linux systemd user services and macOS launchd agents" + .to_string(), + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LaunchctlFailureMode { + /// Propagate any failure. + Fail, + /// Tolerate "service is not loaded" failures (e.g. `bootout` before the + /// agent was ever bootstrapped); propagate everything else. + TolerateNotLoaded, + /// Best effort: ignore any failure. + Ignore, +} + +#[derive(Debug, PartialEq, Eq)] +struct LaunchdCommand { + args: Vec, + failure_mode: LaunchctlFailureMode, +} + +impl LaunchdCommand { + fn new(args: &[&str], failure_mode: LaunchctlFailureMode) -> Self { + Self { + args: args.iter().map(|arg| String::from(*arg)).collect(), + failure_mode, + } + } +} + +/// Commands that (re)start the launchd agent. Booting the service out first +/// (tolerating "not loaded") makes the sequence idempotent, and enabling +/// before bootstrap clears any persisted disabled state so the bootstrap +/// cannot be rejected. +fn launchd_start_command_plan( + domain: &str, + target: &str, + service_path: &Path, +) -> Vec { + vec![ + LaunchdCommand::new( + &["bootout", target], + LaunchctlFailureMode::TolerateNotLoaded, + ), + LaunchdCommand::new(&["enable", target], LaunchctlFailureMode::Fail), + LaunchdCommand::new( + &["bootstrap", domain, &service_path.display().to_string()], + LaunchctlFailureMode::Fail, + ), + LaunchdCommand::new(&["kickstart", "-k", target], LaunchctlFailureMode::Fail), + ] +} + +fn launchd_uninstall_command_plan(target: &str) -> Vec { + vec![ + LaunchdCommand::new( + &["bootout", target], + LaunchctlFailureMode::TolerateNotLoaded, + ), + // Persist the stopped state so launchd does not revive the agent at + // the next login; best effort because the plist is removed anyway. + LaunchdCommand::new(&["disable", target], LaunchctlFailureMode::Ignore), + ] +} + +fn run_launchd_commands(commands: &[LaunchdCommand]) -> Result<()> { + for command in commands { + let args: Vec<&str> = command.args.iter().map(String::as_str).collect(); + match command.failure_mode { + LaunchctlFailureMode::Fail => { + run_launchctl(&args)?; + } + LaunchctlFailureMode::TolerateNotLoaded => run_launchctl_allow_not_loaded(&args)?, + LaunchctlFailureMode::Ignore => { + let _ = run_launchctl(&args); + } + } + } + Ok(()) +} + +fn launchd_domain() -> Result { + let output = Command::new("id") + .arg("-u") + .output() + .map_err(|e| TraceDecayError::Config { + message: format!("failed to determine user id for launchd domain: {e}"), + })?; + if !output.status.success() { + return Err(TraceDecayError::Config { + message: format!( + "id -u failed with status {}\n{}", + output.status, + String::from_utf8_lossy(&output.stderr) + ), + }); + } + let uid = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if uid.is_empty() { + return Err(TraceDecayError::Config { + message: "id -u returned an empty user id".to_string(), + }); + } + Ok(format!("gui/{uid}")) +} + +fn launchd_service_target() -> Result { + Ok(format!("{}/{}", launchd_domain()?, LAUNCHD_LABEL)) +} + +fn ensure_launchd_runtime_dirs() -> Result<()> { + let data_dir = tracedecay_data_dir()?; + std::fs::create_dir_all(&data_dir).map_err(|e| TraceDecayError::Config { + message: format!( + "failed to create daemon data directory '{}': {e}", + data_dir.display() + ), + }) +} + +fn launchd_install(service_path: &Path, start: bool, socket_path: &Path) -> Result<()> { + ensure_launchd_runtime_dirs()?; + let target = launchd_service_target()?; + if !start { + // launchd bootstraps every plist in ~/Library/LaunchAgents at login, + // so persist a disabled state to keep --no-start meaning "do not run". + run_launchctl(&["disable", &target])?; + return Ok(()); + } + launchd_start(&target, service_path, socket_path) +} + +fn launchd_refresh(service_path: &Path, socket_path: &Path) -> Result<()> { + ensure_launchd_runtime_dirs()?; + let target = launchd_service_target()?; + launchd_start(&target, service_path, socket_path) +} + +fn launchd_start(target: &str, service_path: &Path, socket_path: &Path) -> Result<()> { + let domain = launchd_domain()?; + run_launchd_commands(&launchd_start_command_plan(&domain, target, service_path))?; + verify_launchd_started(target, socket_path) +} + +fn launchd_before_uninstall(stop: bool) -> Result<()> { + if !stop { + return Ok(()); + } + let target = launchd_service_target()?; + run_launchd_commands(&launchd_uninstall_command_plan(&target)) +} + +fn verify_launchd_started(target: &str, socket_path: &Path) -> Result<()> { + if daemon_socket_state(socket_path) == DaemonSocketState::Connectable { + return Ok(()); + } + run_launchctl(&["print", target]).map(|_| ()) +} + +fn launchctl_spawn(args: &[&str]) -> Result { + Command::new("launchctl") + .args(args) + .output() + .map_err(|e| TraceDecayError::Config { + message: format!("failed to run launchctl {}: {e}", args.join(" ")), + }) +} + +fn launchctl_failure(args: &[&str], output: &std::process::Output) -> TraceDecayError { + TraceDecayError::Config { + message: format!( + "launchctl {} failed with status {}\nstdout:\n{}\nstderr:\n{}", + args.join(" "), + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ), + } +} + +fn run_launchctl(args: &[&str]) -> Result { + let output = launchctl_spawn(args)?; + if output.status.success() { + return Ok(output); + } + Err(launchctl_failure(args, &output)) +} + +fn run_launchctl_allow_not_loaded(args: &[&str]) -> Result<()> { + let output = launchctl_spawn(args)?; + if output.status.success() + || launchctl_stderr_is_not_loaded(&String::from_utf8_lossy(&output.stderr)) + { + return Ok(()); + } + Err(launchctl_failure(args, &output)) +} + +fn launchctl_stderr_is_not_loaded(stderr: &str) -> bool { + [ + "No such process", + "No such file or directory", + "Could not find service", + "Could not find specified service", + "service is not loaded", + ] + .iter() + .any(|needle| stderr.contains(needle)) +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use std::ffi::{OsStr, OsString}; + use std::path::PathBuf; + use std::sync::Mutex; + + #[cfg(target_os = "linux")] + use std::os::unix::fs::PermissionsExt; + #[cfg(unix)] + use tempfile::TempDir; + + use super::{DaemonServiceSpec, LaunchctlFailureMode, LaunchdCommand}; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + struct EnvVarGuard { + key: &'static str, + previous: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: impl AsRef) -> Self { + let previous = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, previous } + } + + fn unset(key: &'static str) -> Self { + let previous = std::env::var_os(key); + std::env::remove_var(key); + Self { key, previous } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(previous) = self.previous.take() { + std::env::set_var(self.key, previous); + } else { + std::env::remove_var(self.key); + } + } + } + + struct CurrentDirGuard { + previous: PathBuf, + } + + impl CurrentDirGuard { + fn set(path: impl AsRef) -> Self { + let previous = std::env::current_dir().expect("current dir"); + std::env::set_current_dir(path).expect("set current dir"); + Self { previous } + } + } + + impl Drop for CurrentDirGuard { + fn drop(&mut self) { + std::env::set_current_dir(&self.previous).expect("restore current dir"); + } + } + + #[cfg(target_os = "linux")] + #[test] + fn service_status_includes_journalctl_debug_command() { + let status = super::service_status(&PathBuf::from("/tmp/tracedecay.sock")); + + assert!(status.contains("logs: journalctl --user -u tracedecay.service -f")); + } + + #[cfg(target_os = "macos")] + #[test] + fn service_status_includes_launchd_debug_commands() { + let _env_lock = ENV_LOCK.lock().expect("env lock"); + let profile = tempfile::TempDir::new().expect("profile temp dir"); + let _data_dir_guard = EnvVarGuard::set(crate::config::USER_DATA_DIR_ENV, profile.path()); + + let status = super::service_status(&PathBuf::from("/tmp/tracedecay.sock")); + + assert!(status.contains("service-detail: launchctl print gui/")); + assert!(status.contains("/com.tracedecay.daemon")); + assert!(status.contains(&format!( + "logs: tail -f \"{}\"", + profile.path().join("daemon.err.log").display() + ))); + } + + #[cfg(unix)] + #[test] + fn service_status_reports_missing_socket() { + let dir = TempDir::new().expect("temp dir"); + let socket = dir.path().join("missing.sock"); + + let status = super::service_status(&socket); + + assert!( + status.contains(&format!("socket: {} (missing)", socket.display())), + "status should report missing socket, got:\n{status}" + ); + } + + #[cfg(unix)] + #[test] + fn service_status_reports_unconnectable_socket_file() { + let dir = TempDir::new().expect("temp dir"); + let socket = dir.path().join("unconnectable.sock"); + std::fs::write(&socket, "").expect("unconnectable socket placeholder"); + + let status = super::service_status(&socket); + + assert!( + status.contains(&format!("socket: {} (stale)", socket.display())) + || status.contains(&format!( + "socket: {} (present but unreachable)", + socket.display() + )), + "status should report an unconnectable socket, got:\n{status}" + ); + } + + #[cfg(unix)] + #[test] + fn service_status_reports_connectable_socket() { + let dir = TempDir::new().expect("temp dir"); + let socket = dir.path().join("daemon.sock"); + let _listener = std::os::unix::net::UnixListener::bind(&socket).expect("bind socket"); + + let status = super::service_status(&socket); + + assert!( + status.contains(&format!("socket: {} (connectable)", socket.display())), + "status should report connectable socket, got:\n{status}" + ); + } + + #[test] + fn user_service_runs_daemon_with_socket_path() { + let spec = DaemonServiceSpec { + tracedecay_bin: PathBuf::from("/usr/local/bin/tracedecay"), + socket_path: PathBuf::from("/tmp/tracedecay.sock"), + data_dir_override: None, + }; + + let unit = spec.render_systemd_user_unit(); + + assert!(unit.contains( + "ExecStart=/usr/local/bin/tracedecay daemon run --socket /tmp/tracedecay.sock" + )); + assert!(unit.contains("Environment=\"PATH=")); + assert!(unit.contains("Restart=on-failure")); + } + + // The launchd render tests use Unix-style absolute binary paths, which + // `Path::is_absolute` rejects on Windows; launchd is Unix-only anyway. + #[cfg(unix)] + #[test] + fn render_launchd_plist_includes_program_arguments_socket_logs_and_label() { + let _env_lock = ENV_LOCK.lock().expect("env lock"); + let profile = tempfile::TempDir::new().expect("profile temp dir"); + let home = tempfile::TempDir::new().expect("home temp dir"); + let _home_guard = EnvVarGuard::set("HOME", home.path()); + let spec = DaemonServiceSpec { + tracedecay_bin: PathBuf::from("/opt/tracedecay/bin/tracedecay"), + socket_path: profile.path().join("daemon.sock"), + data_dir_override: Some(profile.path().to_path_buf()), + }; + + let plist = spec.render_launchd_plist().expect("launchd plist"); + + assert!(plist.contains("Label")); + assert!(plist.contains("com.tracedecay.daemon")); + assert!(plist.contains("ProgramArguments")); + assert!(plist.contains("/opt/tracedecay/bin/tracedecay")); + assert!(plist.contains("daemon")); + assert!(plist.contains("run")); + assert!(plist.contains("--socket")); + assert!(plist.contains(&format!( + "{}", + profile.path().join("daemon.sock").display() + ))); + assert!(plist.contains(&format!( + "{}", + profile.path().join("daemon.out.log").display() + ))); + assert!(plist.contains(&format!( + "{}", + profile.path().join("daemon.err.log").display() + ))); + assert!(plist.contains("TRACEDECAY_DATA_DIR")); + assert!(plist.contains("RunAtLoad")); + assert!(plist.contains("KeepAlive")); + } + + #[cfg(unix)] + #[test] + fn render_launchd_plist_escapes_xml_and_parser_unescapes_socket_path() { + let _env_lock = ENV_LOCK.lock().expect("env lock"); + let profile = tempfile::TempDir::new().expect("profile temp dir"); + let home = tempfile::TempDir::new().expect("home temp dir"); + let _home_guard = EnvVarGuard::set("HOME", home.path()); + let _data_dir_guard = EnvVarGuard::set(crate::config::USER_DATA_DIR_ENV, profile.path()); + let socket_path = PathBuf::from("/tmp/trace&\"socket'.sock"); + let spec = DaemonServiceSpec { + tracedecay_bin: PathBuf::from("/opt/trace&decay/bin/tracedecay"), + socket_path: socket_path.clone(), + data_dir_override: None, + }; + + let plist = spec.render_launchd_plist().expect("launchd plist"); + + assert!(plist.contains("/opt/trace&decay/bin/tracedecay")); + assert!(plist.contains("/tmp/trace<decay>&"socket'.sock")); + assert_eq!( + super::socket_path_from_launchd_plist(&plist), + Some(socket_path) + ); + } + + #[test] + fn socket_path_from_launchd_plist_returns_none_for_malformed_input() { + assert_eq!( + super::socket_path_from_launchd_plist(""), + None + ); + assert_eq!( + super::socket_path_from_launchd_plist( + "ProgramArgumentstracedecay" + ), + None + ); + } + + #[test] + fn socket_path_from_launchd_plist_accepts_socket_equals_form() { + let plist = "\ + ProgramArguments\ + \ + /opt/tracedecay/bin/tracedecay\ + daemon\ + run\ + --socket=/tmp/tracedecay.sock\ + "; + + assert_eq!( + super::socket_path_from_launchd_plist(plist), + Some(PathBuf::from("/tmp/tracedecay.sock")) + ); + } + + #[cfg(unix)] + #[test] + fn launchd_plist_env_value_round_trips_data_dir_override() { + let _env_lock = ENV_LOCK.lock().expect("env lock"); + let profile = tempfile::TempDir::new().expect("profile temp dir"); + let home = tempfile::TempDir::new().expect("home temp dir"); + let _home_guard = EnvVarGuard::set("HOME", home.path()); + let spec = DaemonServiceSpec { + tracedecay_bin: PathBuf::from("/opt/tracedecay/bin/tracedecay"), + socket_path: profile.path().join("daemon.sock"), + data_dir_override: Some(profile.path().to_path_buf()), + }; + + let plist = spec.render_launchd_plist().expect("launchd plist"); + + assert_eq!( + super::launchd_plist_env_value(&plist, crate::config::USER_DATA_DIR_ENV), + Some(profile.path().display().to_string()) + ); + assert_eq!(super::launchd_plist_env_value(&plist, "MISSING_VAR"), None); + } + + #[cfg(unix)] + #[test] + fn launchd_plist_env_value_ignores_plist_without_override() { + let _env_lock = ENV_LOCK.lock().expect("env lock"); + let profile = tempfile::TempDir::new().expect("profile temp dir"); + let home = tempfile::TempDir::new().expect("home temp dir"); + let _home_guard = EnvVarGuard::set("HOME", home.path()); + let _data_dir_guard = EnvVarGuard::set(crate::config::USER_DATA_DIR_ENV, profile.path()); + let spec = DaemonServiceSpec { + tracedecay_bin: PathBuf::from("/opt/tracedecay/bin/tracedecay"), + socket_path: profile.path().join("daemon.sock"), + data_dir_override: None, + }; + + let plist = spec.render_launchd_plist().expect("launchd plist"); + + assert_eq!( + super::launchd_plist_env_value(&plist, crate::config::USER_DATA_DIR_ENV), + None + ); + } + + #[test] + fn launchd_command_plans_map_start_and_uninstall_sequences() { + let service_path = + PathBuf::from("/Users/me/Library/LaunchAgents/com.tracedecay.daemon.plist"); + + assert_eq!( + super::launchd_start_command_plan( + "gui/501", + "gui/501/com.tracedecay.daemon", + &service_path + ), + vec![ + LaunchdCommand::new( + &["bootout", "gui/501/com.tracedecay.daemon"], + LaunchctlFailureMode::TolerateNotLoaded + ), + LaunchdCommand::new( + &["enable", "gui/501/com.tracedecay.daemon"], + LaunchctlFailureMode::Fail + ), + LaunchdCommand::new( + &[ + "bootstrap", + "gui/501", + "/Users/me/Library/LaunchAgents/com.tracedecay.daemon.plist" + ], + LaunchctlFailureMode::Fail + ), + LaunchdCommand::new( + &["kickstart", "-k", "gui/501/com.tracedecay.daemon"], + LaunchctlFailureMode::Fail + ), + ] + ); + assert_eq!( + super::launchd_uninstall_command_plan("gui/501/com.tracedecay.daemon"), + vec![ + LaunchdCommand::new( + &["bootout", "gui/501/com.tracedecay.daemon"], + LaunchctlFailureMode::TolerateNotLoaded + ), + LaunchdCommand::new( + &["disable", "gui/501/com.tracedecay.daemon"], + LaunchctlFailureMode::Ignore + ), + ] + ); + } + + #[test] + fn launchctl_stderr_not_loaded_matches_known_messages_only() { + assert!(super::launchctl_stderr_is_not_loaded( + "Boot-out failed: 3: No such process" + )); + assert!(super::launchctl_stderr_is_not_loaded( + "Could not find service \"com.tracedecay.daemon\" in domain for user gui: 501" + )); + assert!(super::launchctl_stderr_is_not_loaded( + "service is not loaded" + )); + assert!(!super::launchctl_stderr_is_not_loaded( + "Boot-out failed: 5: Input/output error" + )); + assert!(!super::launchctl_stderr_is_not_loaded("")); + } + + #[cfg(target_os = "linux")] + #[test] + fn refresh_service_rewrites_unit_and_restarts_daemon() { + let _env_lock = ENV_LOCK.lock().expect("env lock"); + let dir = TempDir::new().expect("temp dir"); + let config_home = dir.path().join("config"); + let fake_bin = dir.path().join("bin"); + let home = dir.path().join("home"); + std::fs::create_dir_all(&fake_bin).expect("fake bin dir"); + std::fs::create_dir_all(&home).expect("home dir"); + + let systemctl = fake_bin.join("systemctl"); + let log = dir.path().join("systemctl.log"); + std::fs::write( + &systemctl, + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$TRACEDECAY_SYSTEMCTL_LOG\"\n", + ) + .expect("fake systemctl"); + std::fs::set_permissions(&systemctl, std::fs::Permissions::from_mode(0o755)) + .expect("systemctl permissions"); + + let _config_guard = EnvVarGuard::set("XDG_CONFIG_HOME", &config_home); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _path_guard = EnvVarGuard::set("PATH", &fake_bin); + let _log_guard = EnvVarGuard::set("TRACEDECAY_SYSTEMCTL_LOG", &log); + let spec = DaemonServiceSpec { + tracedecay_bin: PathBuf::from("/opt/tracedecay/bin/tracedecay"), + socket_path: PathBuf::from("/run/user/1000/tracedecay.sock"), + data_dir_override: None, + }; + + let service_path = super::refresh_service(&spec).expect("refresh service"); + + assert_eq!( + service_path, + config_home + .join("systemd/user") + .join(crate::daemon::SERVICE_NAME) + ); + let unit = std::fs::read_to_string(&service_path).expect("service unit"); + assert!(unit.contains( + "ExecStart=/opt/tracedecay/bin/tracedecay daemon run --socket /run/user/1000/tracedecay.sock" + )); + assert_eq!( + std::fs::read_to_string(log).expect("systemctl log"), + "--user daemon-reload\n--user enable tracedecay.service\n--user restart tracedecay.service\n" + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn refresh_installed_service_skips_missing_unit() { + let _env_lock = ENV_LOCK.lock().expect("env lock"); + let dir = TempDir::new().expect("temp dir"); + let config_home = dir.path().join("config"); + let fake_bin = dir.path().join("bin"); + let home = dir.path().join("home"); + std::fs::create_dir_all(&fake_bin).expect("fake bin dir"); + std::fs::create_dir_all(&home).expect("home dir"); + + let _config_guard = EnvVarGuard::set("XDG_CONFIG_HOME", &config_home); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _path_guard = EnvVarGuard::set("PATH", &fake_bin); + let spec = DaemonServiceSpec { + tracedecay_bin: PathBuf::from("/opt/tracedecay/bin/tracedecay"), + socket_path: PathBuf::from("/run/user/1000/tracedecay.sock"), + data_dir_override: None, + }; + + let service_path = config_home + .join("systemd/user") + .join(crate::daemon::SERVICE_NAME); + let outcome = super::refresh_installed_service(&spec).expect("refresh service"); + + assert_eq!(outcome, None); + assert!(!service_path.exists()); + } + + #[cfg(target_os = "linux")] + #[test] + fn refresh_installed_service_preserves_existing_socket_path() { + let _env_lock = ENV_LOCK.lock().expect("env lock"); + let dir = TempDir::new().expect("temp dir"); + let config_home = dir.path().join("config"); + let fake_bin = dir.path().join("bin"); + let home = dir.path().join("home"); + std::fs::create_dir_all(&fake_bin).expect("fake bin dir"); + std::fs::create_dir_all(&home).expect("home dir"); + + let systemctl = fake_bin.join("systemctl"); + let log = dir.path().join("systemctl.log"); + std::fs::write( + &systemctl, + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$TRACEDECAY_SYSTEMCTL_LOG\"\n", + ) + .expect("fake systemctl"); + std::fs::set_permissions(&systemctl, std::fs::Permissions::from_mode(0o755)) + .expect("systemctl permissions"); + + let _config_guard = EnvVarGuard::set("XDG_CONFIG_HOME", &config_home); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _path_guard = EnvVarGuard::set("PATH", &fake_bin); + let _log_guard = EnvVarGuard::set("TRACEDECAY_SYSTEMCTL_LOG", &log); + + let service_path = config_home + .join("systemd/user") + .join(crate::daemon::SERVICE_NAME); + std::fs::create_dir_all(service_path.parent().expect("service parent")) + .expect("service dir"); + std::fs::write( + &service_path, + "[Unit]\n\ + Description=TraceDecay daemon\n\ + \n\ + [Service]\n\ + ExecStart=/old/tracedecay daemon run --socket /custom/tracedecay.sock\n", + ) + .expect("existing service unit"); + + let spec = DaemonServiceSpec { + tracedecay_bin: PathBuf::from("/opt/tracedecay/bin/tracedecay"), + socket_path: PathBuf::from("/run/user/1000/tracedecay.sock"), + data_dir_override: None, + }; + + let outcome = super::refresh_installed_service(&spec).expect("refresh service"); + + assert_eq!(outcome, Some(service_path.clone())); + let unit = std::fs::read_to_string(service_path).expect("service unit"); + assert!(unit.contains( + "ExecStart=/opt/tracedecay/bin/tracedecay daemon run --socket /custom/tracedecay.sock" + )); + assert!(!unit.contains("/run/user/1000/tracedecay.sock")); + assert_eq!( + std::fs::read_to_string(log).expect("systemctl log"), + "--user daemon-reload\n--user enable tracedecay.service\n--user restart tracedecay.service\n" + ); + } + + #[test] + fn default_socket_path_is_profile_scoped_not_project_scoped() { + let _env_lock = ENV_LOCK.lock().expect("env lock"); + let profile = tempfile::TempDir::new().expect("profile temp dir"); + let project_a = tempfile::TempDir::new().expect("project a temp dir"); + let project_b = tempfile::TempDir::new().expect("project b temp dir"); + let override_socket = profile.path().join("override.sock"); + let _socket_guard = EnvVarGuard::unset(crate::daemon::SOCKET_ENV); + let _data_dir_guard = EnvVarGuard::set( + crate::config::USER_DATA_DIR_ENV, + profile.path().join(".tracedecay"), + ); + + { + let _cwd_guard = CurrentDirGuard::set(project_a.path()); + assert_eq!( + super::default_socket_path().expect("default socket path"), + profile.path().join(".tracedecay/daemon.sock") + ); + } + { + let _cwd_guard = CurrentDirGuard::set(project_b.path()); + assert_eq!( + super::default_socket_path().expect("default socket path"), + profile.path().join(".tracedecay/daemon.sock") + ); + } + + let _override_guard = EnvVarGuard::set(crate::daemon::SOCKET_ENV, &override_socket); + assert_eq!( + super::default_socket_path().expect("override socket path"), + override_socket + ); + } +}