From 5496149ea6b8afd2e952183e6757e4f969137160 Mon Sep 17 00:00:00 2001 From: spa5k <79936503+spa5k@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:03:42 +0530 Subject: [PATCH 1/4] docs: finalize macos launchd daemon plan --- ...2026-07-01-macos-launchd-daemon-support.md | 535 ++++++++++++++++++ 1 file changed, 535 insertions(+) create mode 100644 docs/plans/2026-07-01-macos-launchd-daemon-support.md 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..4876572c --- /dev/null +++ b/docs/plans/2026-07-01-macos-launchd-daemon-support.md @@ -0,0 +1,535 @@ +# 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; `bootstrap gui/ `; `enable gui//com.tracedecay.daemon`; `kickstart -k gui//com.tracedecay.daemon` | +| install with `--no-start` | write unit only | write plist only | +| refresh | write unit; `daemon-reload`; `enable`; `restart` | write plist; if loaded, `bootout gui//com.tracedecay.daemon`; `bootstrap gui/ `; `enable ...`; `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 `enable` or `kickstart` for `--no-start`. + +## 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 `bootstrap`, `enable`, `kickstart`; +- install with `--no-start` writes plist only; +- refresh preserves existing socket path and plans `bootout`, `bootstrap`, + `enable`, `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` | From 5e2c33a721ade2c4eee6d5eae8b6ab36bda53476 Mon Sep 17 00:00:00 2001 From: spa5k <79936503+spa5k@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:20:12 +0530 Subject: [PATCH 2/4] feat: add macos launchd daemon service --- README.md | 10 +- SECURITY.md | 4 +- docs/USER-GUIDE.md | 15 + src/daemon.rs | 743 ++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 731 insertions(+), 41 deletions(-) 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/src/daemon.rs b/src/daemon.rs index d64f0b70..c7d67e21 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -30,6 +30,9 @@ use crate::mcp::{ErrorCode, JsonRpcRequest, JsonRpcResponse, McpTransport, Stdio pub const SERVICE_NAME: &str = "tracedecay.service"; pub const SOCKET_ENV: &str = "TRACEDECAY_DAEMON_SOCKET"; pub const HOOK_EVENT_METHOD: &str = "tracedecay/hookEvent"; +const LAUNCHD_LABEL: &str = "com.tracedecay.daemon"; +#[cfg(target_os = "macos")] +const LAUNCHD_PLIST_NAME: &str = "com.tracedecay.daemon.plist"; #[cfg(unix)] const HOOK_EVENT_NOTIFY_TIMEOUT: Duration = Duration::from_millis(750); @@ -173,6 +176,105 @@ impl DaemonServiceSpec { 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 = crate::config::user_data_dir().ok_or_else(|| TraceDecayError::Config { + message: "could not determine TraceDecay user data directory".to_string(), + })?; + 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) = + std::env::var_os(crate::config::USER_DATA_DIR_ENV).filter(|value| !value.is_empty()) + { + env_entries.push(( + crate::config::USER_DATA_DIR_ENV.to_string(), + PathBuf::from(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()? { + #[cfg(target_os = "linux")] + ServiceRunner::Systemd => Ok(self.render_systemd_user_unit()), + #[cfg(target_os = "macos")] + ServiceRunner::Launchd => self.render_launchd_plist(), + } + } } fn daemon_service_path_env(tracedecay_bin: &Path) -> String { @@ -234,6 +336,41 @@ fn systemd_escape_env_value(value: &str) -> String { .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 +} + +#[cfg(any(test, target_os = "macos"))] +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(), + }) +} + 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)); @@ -453,26 +590,22 @@ fn read_hook_marker_secs(path: &Path) -> Option { } pub fn install_service(spec: &DaemonServiceSpec, start: bool) -> Result { + let runner = ServiceRunner::current()?; let service_path = write_service_unit(spec)?; - - if start { - run_systemctl(&["daemon-reload"])?; - run_systemctl(&["enable", "--now", SERVICE_NAME])?; - } + 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)?; - run_systemctl(&["daemon-reload"])?; - run_systemctl(&["enable", SERVICE_NAME])?; - run_systemctl(&["restart", SERVICE_NAME])?; + runner.refresh(&service_path, &spec.socket_path)?; Ok(service_path) } pub fn refresh_installed_service(spec: &DaemonServiceSpec) -> Result> { - let service_path = systemd_user_service_path()?; + let service_path = service_unit_path()?; if !service_path.exists() { return Ok(None); } @@ -484,7 +617,7 @@ pub fn refresh_installed_service(spec: &DaemonServiceSpec) -> Result Result { - let service_path = systemd_user_service_path()?; + let service_path = service_unit_path()?; let parent = service_path .parent() .ok_or_else(|| TraceDecayError::Config { @@ -496,17 +629,24 @@ fn write_service_unit(spec: &DaemonServiceSpec) -> Result { 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()), - } + 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 = systemd_user_service_path()?; + let service_path = service_unit_path()?; if !service_path.exists() { return Ok(None); } @@ -517,9 +657,10 @@ fn service_socket_path_from_unit_file(service_path: &Path) -> Result Option { unit.lines() .filter_map(|line| line.trim().strip_prefix("ExecStart=")) @@ -537,11 +678,57 @@ fn socket_path_from_service_unit(unit: &str) -> Option { }) } -pub fn uninstall_service(stop: bool) -> Result { - let service_path = systemd_user_service_path()?; - if stop { - let _ = run_systemctl(&["disable", "--now", SERVICE_NAME]); +#[cfg(any(test, target_os = "macos"))] +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); + + let mut args = strings.iter(); + 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 +} + +#[cfg(any(test, target_os = "macos"))] +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()? { + #[cfg(target_os = "linux")] + ServiceRunner::Systemd => socket_path_from_service_unit(unit), + #[cfg(target_os = "macos")] + 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(&service_path, stop)?; match std::fs::remove_file(&service_path) { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} @@ -551,23 +738,30 @@ pub fn uninstall_service(stop: bool) -> Result { }); } } - if stop { - let _ = run_systemctl(&["daemon-reload"]); - } + 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 detail = ServiceRunner::current() + .ok() + .and_then(|runner| runner.service_detail_hint()) + .map(|hint| format!("service-detail: {hint}\n")) + .unwrap_or_default(); + let logs = ServiceRunner::current() + .map_or_else(|e| format!("unavailable: {e}"), |runner| runner.log_hint()); 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() - ), + "service: {}\nsocket: {} ({})\n{}logs: {}\n", + service, socket_path.display(), socket_state, - SERVICE_NAME + detail, + logs, ) } @@ -1712,13 +1906,123 @@ 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(), - }); +enum ServiceRunner { + #[cfg(target_os = "linux")] + Systemd, + #[cfg(target_os = "macos")] + Launchd, +} + +impl ServiceRunner { + fn current() -> Result { + #[cfg(target_os = "linux")] + { + return Ok(Self::Systemd); + } + #[cfg(target_os = "macos")] + { + return Ok(Self::Launchd); + } + #[allow(unreachable_code)] + Err(unsupported_service_platform()) + } + + fn install(&self, service_path: &Path, start: bool, socket_path: &Path) -> Result<()> { + match self { + #[cfg(target_os = "linux")] + Self::Systemd => { + if start { + run_systemctl(&["daemon-reload"])?; + run_systemctl(&["enable", "--now", SERVICE_NAME])?; + } + Ok(()) + } + #[cfg(target_os = "macos")] + Self::Launchd => launchd_install(service_path, start, socket_path), + } } + + fn refresh(&self, service_path: &Path, socket_path: &Path) -> Result<()> { + match self { + #[cfg(target_os = "linux")] + Self::Systemd => { + run_systemctl(&["daemon-reload"])?; + run_systemctl(&["enable", SERVICE_NAME])?; + run_systemctl(&["restart", SERVICE_NAME])?; + Ok(()) + } + #[cfg(target_os = "macos")] + Self::Launchd => launchd_refresh(service_path, socket_path), + } + } + + fn before_uninstall(&self, service_path: &Path, stop: bool) -> Result<()> { + match self { + #[cfg(target_os = "linux")] + Self::Systemd => { + if stop { + let _ = run_systemctl(&["disable", "--now", SERVICE_NAME]); + } + Ok(()) + } + #[cfg(target_os = "macos")] + Self::Launchd => launchd_before_uninstall(service_path, stop), + } + } + + fn after_uninstall(&self, _stop: bool) -> Result<()> { + match self { + #[cfg(target_os = "linux")] + Self::Systemd => { + if _stop { + let _ = run_systemctl(&["daemon-reload"]); + } + Ok(()) + } + #[cfg(target_os = "macos")] + Self::Launchd => Ok(()), + } + } + + fn log_hint(&self) -> String { + match self { + #[cfg(target_os = "linux")] + Self::Systemd => format!("journalctl --user -u {SERVICE_NAME} -f"), + #[cfg(target_os = "macos")] + 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 { + #[cfg(target_os = "linux")] + Self::Systemd => None, + #[cfg(target_os = "macos")] + Self::Launchd => launchd_service_target() + .ok() + .map(|target| format!("launchctl print {target}")), + } + } +} + +fn service_unit_path() -> Result { + #[cfg(target_os = "linux")] + { + return systemd_user_service_path(); + } + #[cfg(target_os = "macos")] + { + return launchd_user_service_path(); + } + #[allow(unreachable_code)] + Err(unsupported_service_platform()) +} + +#[cfg(target_os = "linux")] +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"))) @@ -1728,6 +2032,13 @@ fn systemd_user_service_path() -> Result { Ok(config_home.join("systemd/user").join(SERVICE_NAME)) } +#[cfg(target_os = "macos")] +fn launchd_user_service_path() -> Result { + let home = home_for_service_env()?; + Ok(home.join("Library/LaunchAgents").join(LAUNCHD_PLIST_NAME)) +} + +#[cfg(target_os = "linux")] fn run_systemctl(args: &[&str]) -> Result<()> { let output = Command::new("systemctl") .arg("--user") @@ -1749,6 +2060,203 @@ fn run_systemctl(args: &[&str]) -> Result<()> { }) } +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(), + } +} + +#[cfg(any(test, target_os = "macos"))] +fn launchd_install_command_args( + domain: &str, + target: &str, + service_path: &Path, +) -> Vec> { + vec![ + vec![ + "bootstrap".to_string(), + domain.to_string(), + service_path.display().to_string(), + ], + vec!["enable".to_string(), target.to_string()], + vec![ + "kickstart".to_string(), + "-k".to_string(), + target.to_string(), + ], + ] +} + +#[cfg(any(test, target_os = "macos"))] +fn launchd_refresh_command_args( + domain: &str, + target: &str, + service_path: &Path, +) -> Vec> { + let mut commands = vec![vec!["bootout".to_string(), target.to_string()]]; + commands.extend(launchd_install_command_args(domain, target, service_path)); + commands +} + +#[cfg(any(test, target_os = "macos"))] +fn launchd_uninstall_command_args(target: &str) -> Vec> { + vec![ + vec!["bootout".to_string(), target.to_string()], + vec!["disable".to_string(), target.to_string()], + ] +} + +#[cfg(target_os = "macos")] +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}")) +} + +#[cfg(target_os = "macos")] +fn launchd_service_target() -> Result { + Ok(format!("{}/{}", launchd_domain()?, LAUNCHD_LABEL)) +} + +#[cfg(target_os = "macos")] +fn ensure_launchd_runtime_dirs() -> Result<()> { + let data_dir = crate::config::user_data_dir().ok_or_else(|| TraceDecayError::Config { + message: "could not determine TraceDecay user data directory".to_string(), + })?; + std::fs::create_dir_all(&data_dir).map_err(|e| TraceDecayError::Config { + message: format!( + "failed to create daemon data directory '{}': {e}", + data_dir.display() + ), + }) +} + +#[cfg(target_os = "macos")] +fn launchd_install(service_path: &Path, start: bool, socket_path: &Path) -> Result<()> { + if !start { + return Ok(()); + } + ensure_launchd_runtime_dirs()?; + let domain = launchd_domain()?; + let target = launchd_service_target()?; + for command in launchd_install_command_args(&domain, &target, service_path) { + run_launchctl_owned(&command)?; + } + verify_launchd_started(&target, socket_path) +} + +#[cfg(target_os = "macos")] +fn launchd_refresh(service_path: &Path, socket_path: &Path) -> Result<()> { + ensure_launchd_runtime_dirs()?; + let domain = launchd_domain()?; + let target = launchd_service_target()?; + let commands = launchd_refresh_command_args(&domain, &target, service_path); + for (index, command) in commands.iter().enumerate() { + if index == 0 { + run_launchctl_owned_allow_not_loaded(command)?; + } else { + run_launchctl_owned(command)?; + } + } + verify_launchd_started(&target, socket_path) +} + +#[cfg(target_os = "macos")] +fn launchd_before_uninstall(_service_path: &Path, stop: bool) -> Result<()> { + if !stop { + return Ok(()); + } + let target = launchd_service_target()?; + let commands = launchd_uninstall_command_args(&target); + for (index, command) in commands.iter().enumerate() { + if index == 0 { + run_launchctl_owned_allow_not_loaded(command)?; + } else { + let _ = run_launchctl_owned(command); + } + } + Ok(()) +} + +#[cfg(target_os = "macos")] +fn verify_launchd_started(target: &str, socket_path: &Path) -> Result<()> { + if daemon_socket_state(socket_path) == "connectable" { + return Ok(()); + } + run_launchctl(&["print", target]).map(|_| ()) +} + +#[cfg(target_os = "macos")] +fn run_launchctl_owned(args: &[String]) -> Result { + let args = args.iter().map(String::as_str).collect::>(); + run_launchctl(&args) +} + +#[cfg(target_os = "macos")] +fn run_launchctl_owned_allow_not_loaded(args: &[String]) -> Result<()> { + match run_launchctl_owned(args) { + Ok(_) => Ok(()), + Err(error) if launchctl_error_is_not_loaded(&error.to_string()) => Ok(()), + Err(error) => Err(error), + } +} + +#[cfg(target_os = "macos")] +fn run_launchctl(args: &[&str]) -> Result { + let output = + Command::new("launchctl") + .args(args) + .output() + .map_err(|e| TraceDecayError::Config { + message: format!("failed to run launchctl {}: {e}", args.join(" ")), + })?; + if output.status.success() { + return Ok(output); + } + Err(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) + ), + }) +} + +#[cfg(target_os = "macos")] +fn launchctl_error_is_not_loaded(message: &str) -> bool { + [ + "No such process", + "No such file or directory", + "not found", + "Could not find service", + "service is not loaded", + ] + .iter() + .any(|needle| message.contains(needle)) +} + #[cfg(not(unix))] fn unsupported_platform() -> TraceDecayError { TraceDecayError::Config { @@ -1893,6 +2401,7 @@ mod tests { ); } + #[cfg(target_os = "linux")] #[test] fn service_status_includes_journalctl_debug_command() { let status = super::service_status(&PathBuf::from("/tmp/tracedecay.sock")); @@ -1900,6 +2409,23 @@ mod tests { 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() { @@ -2028,6 +2554,153 @@ mod tests { assert!(unit.contains("Restart=on-failure")); } + #[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 _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"), + }; + + 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")); + } + + #[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(), + }; + + 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")) + ); + } + + #[test] + fn launchd_command_args_match_install_refresh_and_uninstall_mapping() { + let service_path = + PathBuf::from("/Users/me/Library/LaunchAgents/com.tracedecay.daemon.plist"); + + assert_eq!( + super::launchd_install_command_args( + "gui/501", + "gui/501/com.tracedecay.daemon", + &service_path + ), + vec![ + vec![ + "bootstrap".to_string(), + "gui/501".to_string(), + service_path.display().to_string() + ], + vec![ + "enable".to_string(), + "gui/501/com.tracedecay.daemon".to_string() + ], + vec![ + "kickstart".to_string(), + "-k".to_string(), + "gui/501/com.tracedecay.daemon".to_string() + ], + ] + ); + assert_eq!( + super::launchd_refresh_command_args( + "gui/501", + "gui/501/com.tracedecay.daemon", + &service_path + ) + .first(), + Some(&vec![ + "bootout".to_string(), + "gui/501/com.tracedecay.daemon".to_string() + ]) + ); + assert_eq!( + super::launchd_uninstall_command_args("gui/501/com.tracedecay.daemon"), + vec![ + vec![ + "bootout".to_string(), + "gui/501/com.tracedecay.daemon".to_string() + ], + vec![ + "disable".to_string(), + "gui/501/com.tracedecay.daemon".to_string() + ], + ] + ); + } + #[cfg(target_os = "linux")] #[test] fn refresh_service_rewrites_unit_and_restarts_daemon() { From 50512de71c64826f3fffd32b9059c53831163b25 Mon Sep 17 00:00:00 2001 From: spa5k <79936503+spa5k@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:33:55 +0530 Subject: [PATCH 3/4] refactor: split daemon service module --- src/daemon.rs | 823 +----------------------------------------- src/daemon/service.rs | 810 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 824 insertions(+), 809 deletions(-) create mode 100644 src/daemon/service.rs diff --git a/src/daemon.rs b/src/daemon.rs index c7d67e21..0d4d0a06 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; @@ -30,12 +27,16 @@ use crate::mcp::{ErrorCode, JsonRpcRequest, JsonRpcResponse, McpTransport, Stdio pub const SERVICE_NAME: &str = "tracedecay.service"; pub const SOCKET_ENV: &str = "TRACEDECAY_DAEMON_SOCKET"; pub const HOOK_EVENT_METHOD: &str = "tracedecay/hookEvent"; -const LAUNCHD_LABEL: &str = "com.tracedecay.daemon"; -#[cfg(target_os = "macos")] -const LAUNCHD_PLIST_NAME: &str = "com.tracedecay.daemon.plist"; #[cfg(unix)] const HOOK_EVENT_NOTIFY_TIMEOUT: Duration = Duration::from_millis(750); +mod service; +pub use service::{ + 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, @@ -102,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 @@ -154,247 +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() - ) - } - - 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 = crate::config::user_data_dir().ok_or_else(|| TraceDecayError::Config { - message: "could not determine TraceDecay user data directory".to_string(), - })?; - 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) = - std::env::var_os(crate::config::USER_DATA_DIR_ENV).filter(|value| !value.is_empty()) - { - env_entries.push(( - crate::config::USER_DATA_DIR_ENV.to_string(), - PathBuf::from(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()? { - #[cfg(target_os = "linux")] - ServiceRunner::Systemd => Ok(self.render_systemd_user_unit()), - #[cfg(target_os = "macos")] - 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 -} - -#[cfg(any(test, target_os = "macos"))] -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(), - }) -} - -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( @@ -589,204 +343,6 @@ fn read_hook_marker_secs(path: &Path) -> Option { .ok() } -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 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 = 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); - } - 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_unit_text(&unit)) -} - -#[cfg(target_os = "linux")] -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 - }) -} - -#[cfg(any(test, target_os = "macos"))] -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); - - let mut args = strings.iter(); - 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 -} - -#[cfg(any(test, target_os = "macos"))] -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()? { - #[cfg(target_os = "linux")] - ServiceRunner::Systemd => socket_path_from_service_unit(unit), - #[cfg(target_os = "macos")] - 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(&service_path, 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 detail = ServiceRunner::current() - .ok() - .and_then(|runner| runner.service_detail_hint()) - .map(|hint| format!("service-detail: {hint}\n")) - .unwrap_or_default(); - let logs = ServiceRunner::current() - .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, - ) -} - -#[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 { @@ -1906,357 +1462,6 @@ impl crate::mcp::McpTransport for UnixStreamTransport { } } -enum ServiceRunner { - #[cfg(target_os = "linux")] - Systemd, - #[cfg(target_os = "macos")] - Launchd, -} - -impl ServiceRunner { - fn current() -> Result { - #[cfg(target_os = "linux")] - { - return Ok(Self::Systemd); - } - #[cfg(target_os = "macos")] - { - return Ok(Self::Launchd); - } - #[allow(unreachable_code)] - Err(unsupported_service_platform()) - } - - fn install(&self, service_path: &Path, start: bool, socket_path: &Path) -> Result<()> { - match self { - #[cfg(target_os = "linux")] - Self::Systemd => { - if start { - run_systemctl(&["daemon-reload"])?; - run_systemctl(&["enable", "--now", SERVICE_NAME])?; - } - Ok(()) - } - #[cfg(target_os = "macos")] - Self::Launchd => launchd_install(service_path, start, socket_path), - } - } - - fn refresh(&self, service_path: &Path, socket_path: &Path) -> Result<()> { - match self { - #[cfg(target_os = "linux")] - Self::Systemd => { - run_systemctl(&["daemon-reload"])?; - run_systemctl(&["enable", SERVICE_NAME])?; - run_systemctl(&["restart", SERVICE_NAME])?; - Ok(()) - } - #[cfg(target_os = "macos")] - Self::Launchd => launchd_refresh(service_path, socket_path), - } - } - - fn before_uninstall(&self, service_path: &Path, stop: bool) -> Result<()> { - match self { - #[cfg(target_os = "linux")] - Self::Systemd => { - if stop { - let _ = run_systemctl(&["disable", "--now", SERVICE_NAME]); - } - Ok(()) - } - #[cfg(target_os = "macos")] - Self::Launchd => launchd_before_uninstall(service_path, stop), - } - } - - fn after_uninstall(&self, _stop: bool) -> Result<()> { - match self { - #[cfg(target_os = "linux")] - Self::Systemd => { - if _stop { - let _ = run_systemctl(&["daemon-reload"]); - } - Ok(()) - } - #[cfg(target_os = "macos")] - Self::Launchd => Ok(()), - } - } - - fn log_hint(&self) -> String { - match self { - #[cfg(target_os = "linux")] - Self::Systemd => format!("journalctl --user -u {SERVICE_NAME} -f"), - #[cfg(target_os = "macos")] - 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 { - #[cfg(target_os = "linux")] - Self::Systemd => None, - #[cfg(target_os = "macos")] - Self::Launchd => launchd_service_target() - .ok() - .map(|target| format!("launchctl print {target}")), - } - } -} - -fn service_unit_path() -> Result { - #[cfg(target_os = "linux")] - { - return systemd_user_service_path(); - } - #[cfg(target_os = "macos")] - { - return launchd_user_service_path(); - } - #[allow(unreachable_code)] - Err(unsupported_service_platform()) -} - -#[cfg(target_os = "linux")] -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(SERVICE_NAME)) -} - -#[cfg(target_os = "macos")] -fn launchd_user_service_path() -> Result { - let home = home_for_service_env()?; - Ok(home.join("Library/LaunchAgents").join(LAUNCHD_PLIST_NAME)) -} - -#[cfg(target_os = "linux")] -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(), - } -} - -#[cfg(any(test, target_os = "macos"))] -fn launchd_install_command_args( - domain: &str, - target: &str, - service_path: &Path, -) -> Vec> { - vec![ - vec![ - "bootstrap".to_string(), - domain.to_string(), - service_path.display().to_string(), - ], - vec!["enable".to_string(), target.to_string()], - vec![ - "kickstart".to_string(), - "-k".to_string(), - target.to_string(), - ], - ] -} - -#[cfg(any(test, target_os = "macos"))] -fn launchd_refresh_command_args( - domain: &str, - target: &str, - service_path: &Path, -) -> Vec> { - let mut commands = vec![vec!["bootout".to_string(), target.to_string()]]; - commands.extend(launchd_install_command_args(domain, target, service_path)); - commands -} - -#[cfg(any(test, target_os = "macos"))] -fn launchd_uninstall_command_args(target: &str) -> Vec> { - vec![ - vec!["bootout".to_string(), target.to_string()], - vec!["disable".to_string(), target.to_string()], - ] -} - -#[cfg(target_os = "macos")] -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}")) -} - -#[cfg(target_os = "macos")] -fn launchd_service_target() -> Result { - Ok(format!("{}/{}", launchd_domain()?, LAUNCHD_LABEL)) -} - -#[cfg(target_os = "macos")] -fn ensure_launchd_runtime_dirs() -> Result<()> { - let data_dir = crate::config::user_data_dir().ok_or_else(|| TraceDecayError::Config { - message: "could not determine TraceDecay user data directory".to_string(), - })?; - std::fs::create_dir_all(&data_dir).map_err(|e| TraceDecayError::Config { - message: format!( - "failed to create daemon data directory '{}': {e}", - data_dir.display() - ), - }) -} - -#[cfg(target_os = "macos")] -fn launchd_install(service_path: &Path, start: bool, socket_path: &Path) -> Result<()> { - if !start { - return Ok(()); - } - ensure_launchd_runtime_dirs()?; - let domain = launchd_domain()?; - let target = launchd_service_target()?; - for command in launchd_install_command_args(&domain, &target, service_path) { - run_launchctl_owned(&command)?; - } - verify_launchd_started(&target, socket_path) -} - -#[cfg(target_os = "macos")] -fn launchd_refresh(service_path: &Path, socket_path: &Path) -> Result<()> { - ensure_launchd_runtime_dirs()?; - let domain = launchd_domain()?; - let target = launchd_service_target()?; - let commands = launchd_refresh_command_args(&domain, &target, service_path); - for (index, command) in commands.iter().enumerate() { - if index == 0 { - run_launchctl_owned_allow_not_loaded(command)?; - } else { - run_launchctl_owned(command)?; - } - } - verify_launchd_started(&target, socket_path) -} - -#[cfg(target_os = "macos")] -fn launchd_before_uninstall(_service_path: &Path, stop: bool) -> Result<()> { - if !stop { - return Ok(()); - } - let target = launchd_service_target()?; - let commands = launchd_uninstall_command_args(&target); - for (index, command) in commands.iter().enumerate() { - if index == 0 { - run_launchctl_owned_allow_not_loaded(command)?; - } else { - let _ = run_launchctl_owned(command); - } - } - Ok(()) -} - -#[cfg(target_os = "macos")] -fn verify_launchd_started(target: &str, socket_path: &Path) -> Result<()> { - if daemon_socket_state(socket_path) == "connectable" { - return Ok(()); - } - run_launchctl(&["print", target]).map(|_| ()) -} - -#[cfg(target_os = "macos")] -fn run_launchctl_owned(args: &[String]) -> Result { - let args = args.iter().map(String::as_str).collect::>(); - run_launchctl(&args) -} - -#[cfg(target_os = "macos")] -fn run_launchctl_owned_allow_not_loaded(args: &[String]) -> Result<()> { - match run_launchctl_owned(args) { - Ok(_) => Ok(()), - Err(error) if launchctl_error_is_not_loaded(&error.to_string()) => Ok(()), - Err(error) => Err(error), - } -} - -#[cfg(target_os = "macos")] -fn run_launchctl(args: &[&str]) -> Result { - let output = - Command::new("launchctl") - .args(args) - .output() - .map_err(|e| TraceDecayError::Config { - message: format!("failed to run launchctl {}: {e}", args.join(" ")), - })?; - if output.status.success() { - return Ok(output); - } - Err(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) - ), - }) -} - -#[cfg(target_os = "macos")] -fn launchctl_error_is_not_loaded(message: &str) -> bool { - [ - "No such process", - "No such file or directory", - "not found", - "Could not find service", - "service is not loaded", - ] - .iter() - .any(|needle| message.contains(needle)) -} - #[cfg(not(unix))] fn unsupported_platform() -> TraceDecayError { TraceDecayError::Config { @@ -2610,7 +1815,7 @@ mod tests { 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), + super::service::socket_path_from_launchd_plist(&plist), Some(socket_path) ); } @@ -2618,11 +1823,11 @@ mod tests { #[test] fn socket_path_from_launchd_plist_returns_none_for_malformed_input() { assert_eq!( - super::socket_path_from_launchd_plist(""), + super::service::socket_path_from_launchd_plist(""), None ); assert_eq!( - super::socket_path_from_launchd_plist( + super::service::socket_path_from_launchd_plist( "ProgramArgumentstracedecay" ), None @@ -2641,7 +1846,7 @@ mod tests { "; assert_eq!( - super::socket_path_from_launchd_plist(plist), + super::service::socket_path_from_launchd_plist(plist), Some(PathBuf::from("/tmp/tracedecay.sock")) ); } @@ -2652,7 +1857,7 @@ mod tests { PathBuf::from("/Users/me/Library/LaunchAgents/com.tracedecay.daemon.plist"); assert_eq!( - super::launchd_install_command_args( + super::service::launchd_install_command_args( "gui/501", "gui/501/com.tracedecay.daemon", &service_path @@ -2675,7 +1880,7 @@ mod tests { ] ); assert_eq!( - super::launchd_refresh_command_args( + super::service::launchd_refresh_command_args( "gui/501", "gui/501/com.tracedecay.daemon", &service_path @@ -2687,7 +1892,7 @@ mod tests { ]) ); assert_eq!( - super::launchd_uninstall_command_args("gui/501/com.tracedecay.daemon"), + super::service::launchd_uninstall_command_args("gui/501/com.tracedecay.daemon"), vec![ vec![ "bootout".to_string(), diff --git a/src/daemon/service.rs b/src/daemon/service.rs new file mode 100644 index 00000000..810e7183 --- /dev/null +++ b/src/daemon/service.rs @@ -0,0 +1,810 @@ +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"; +#[cfg(target_os = "macos")] +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, +} + +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 = crate::config::user_data_dir().ok_or_else(|| TraceDecayError::Config { + message: "could not determine TraceDecay user data directory".to_string(), + })?; + 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) = + std::env::var_os(crate::config::USER_DATA_DIR_ENV).filter(|value| !value.is_empty()) + { + env_entries.push(( + crate::config::USER_DATA_DIR_ENV.to_string(), + PathBuf::from(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()? { + #[cfg(target_os = "linux")] + ServiceRunner::Systemd => Ok(self.render_systemd_user_unit()), + #[cfg(target_os = "macos")] + 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 +} + +#[cfg(any(test, target_os = "macos"))] +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(), + }) +} + +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)?, + }) +} + +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 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 = 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); + } + 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_unit_text(&unit)) +} + +#[cfg(target_os = "linux")] +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 + }) +} + +#[cfg(any(test, target_os = "macos"))] +pub(super) 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); + + let mut args = strings.iter(); + 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 +} + +#[cfg(any(test, target_os = "macos"))] +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()? { + #[cfg(target_os = "linux")] + ServiceRunner::Systemd => socket_path_from_service_unit(unit), + #[cfg(target_os = "macos")] + 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(&service_path, 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 detail = ServiceRunner::current() + .ok() + .and_then(|runner| runner.service_detail_hint()) + .map(|hint| format!("service-detail: {hint}\n")) + .unwrap_or_default(); + let logs = ServiceRunner::current() + .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, + ) +} + +#[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(_) => "present but unreachable", + } +} + +#[cfg(not(unix))] +fn daemon_socket_state(socket_path: &Path) -> &'static str { + if socket_path.exists() { + "present but unsupported on this platform" + } else { + "missing" + } +} + +enum ServiceRunner { + #[cfg(target_os = "linux")] + Systemd, + #[cfg(target_os = "macos")] + Launchd, +} + +impl ServiceRunner { + fn current() -> Result { + #[cfg(target_os = "linux")] + { + return Ok(Self::Systemd); + } + #[cfg(target_os = "macos")] + { + return Ok(Self::Launchd); + } + #[allow(unreachable_code)] + Err(unsupported_service_platform()) + } + + fn install(&self, service_path: &Path, start: bool, socket_path: &Path) -> Result<()> { + match self { + #[cfg(target_os = "linux")] + Self::Systemd => { + if start { + run_systemctl(&["daemon-reload"])?; + run_systemctl(&["enable", "--now", super::SERVICE_NAME])?; + } + Ok(()) + } + #[cfg(target_os = "macos")] + Self::Launchd => launchd_install(service_path, start, socket_path), + } + } + + fn refresh(&self, service_path: &Path, socket_path: &Path) -> Result<()> { + match self { + #[cfg(target_os = "linux")] + Self::Systemd => { + run_systemctl(&["daemon-reload"])?; + run_systemctl(&["enable", super::SERVICE_NAME])?; + run_systemctl(&["restart", super::SERVICE_NAME])?; + Ok(()) + } + #[cfg(target_os = "macos")] + Self::Launchd => launchd_refresh(service_path, socket_path), + } + } + + fn before_uninstall(&self, service_path: &Path, stop: bool) -> Result<()> { + match self { + #[cfg(target_os = "linux")] + Self::Systemd => { + if stop { + let _ = run_systemctl(&["disable", "--now", super::SERVICE_NAME]); + } + Ok(()) + } + #[cfg(target_os = "macos")] + Self::Launchd => launchd_before_uninstall(service_path, stop), + } + } + + fn after_uninstall(&self, _stop: bool) -> Result<()> { + match self { + #[cfg(target_os = "linux")] + Self::Systemd => { + if _stop { + let _ = run_systemctl(&["daemon-reload"]); + } + Ok(()) + } + #[cfg(target_os = "macos")] + Self::Launchd => Ok(()), + } + } + + fn log_hint(&self) -> String { + match self { + #[cfg(target_os = "linux")] + Self::Systemd => format!("journalctl --user -u {} -f", super::SERVICE_NAME), + #[cfg(target_os = "macos")] + 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 { + #[cfg(target_os = "linux")] + Self::Systemd => None, + #[cfg(target_os = "macos")] + Self::Launchd => launchd_service_target() + .ok() + .map(|target| format!("launchctl print {target}")), + } + } +} + +fn service_unit_path() -> Result { + #[cfg(target_os = "linux")] + { + return systemd_user_service_path(); + } + #[cfg(target_os = "macos")] + { + return launchd_user_service_path(); + } + #[allow(unreachable_code)] + Err(unsupported_service_platform()) +} + +#[cfg(target_os = "linux")] +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)) +} + +#[cfg(target_os = "macos")] +fn launchd_user_service_path() -> Result { + let home = home_for_service_env()?; + Ok(home.join("Library/LaunchAgents").join(LAUNCHD_PLIST_NAME)) +} + +#[cfg(target_os = "linux")] +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(), + } +} + +#[cfg(any(test, target_os = "macos"))] +pub(super) fn launchd_install_command_args( + domain: &str, + target: &str, + service_path: &Path, +) -> Vec> { + vec![ + vec![ + "bootstrap".to_string(), + domain.to_string(), + service_path.display().to_string(), + ], + vec!["enable".to_string(), target.to_string()], + vec![ + "kickstart".to_string(), + "-k".to_string(), + target.to_string(), + ], + ] +} + +#[cfg(any(test, target_os = "macos"))] +pub(super) fn launchd_refresh_command_args( + domain: &str, + target: &str, + service_path: &Path, +) -> Vec> { + let mut commands = vec![vec!["bootout".to_string(), target.to_string()]]; + commands.extend(launchd_install_command_args(domain, target, service_path)); + commands +} + +#[cfg(any(test, target_os = "macos"))] +pub(super) fn launchd_uninstall_command_args(target: &str) -> Vec> { + vec![ + vec!["bootout".to_string(), target.to_string()], + vec!["disable".to_string(), target.to_string()], + ] +} + +#[cfg(target_os = "macos")] +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}")) +} + +#[cfg(target_os = "macos")] +fn launchd_service_target() -> Result { + Ok(format!("{}/{}", launchd_domain()?, LAUNCHD_LABEL)) +} + +#[cfg(target_os = "macos")] +fn ensure_launchd_runtime_dirs() -> Result<()> { + let data_dir = crate::config::user_data_dir().ok_or_else(|| TraceDecayError::Config { + message: "could not determine TraceDecay user data directory".to_string(), + })?; + std::fs::create_dir_all(&data_dir).map_err(|e| TraceDecayError::Config { + message: format!( + "failed to create daemon data directory '{}': {e}", + data_dir.display() + ), + }) +} + +#[cfg(target_os = "macos")] +fn launchd_install(service_path: &Path, start: bool, socket_path: &Path) -> Result<()> { + if !start { + return Ok(()); + } + ensure_launchd_runtime_dirs()?; + let domain = launchd_domain()?; + let target = launchd_service_target()?; + for command in launchd_install_command_args(&domain, &target, service_path) { + run_launchctl_owned(&command)?; + } + verify_launchd_started(&target, socket_path) +} + +#[cfg(target_os = "macos")] +fn launchd_refresh(service_path: &Path, socket_path: &Path) -> Result<()> { + ensure_launchd_runtime_dirs()?; + let domain = launchd_domain()?; + let target = launchd_service_target()?; + let commands = launchd_refresh_command_args(&domain, &target, service_path); + for (index, command) in commands.iter().enumerate() { + if index == 0 { + run_launchctl_owned_allow_not_loaded(command)?; + } else { + run_launchctl_owned(command)?; + } + } + verify_launchd_started(&target, socket_path) +} + +#[cfg(target_os = "macos")] +fn launchd_before_uninstall(_service_path: &Path, stop: bool) -> Result<()> { + if !stop { + return Ok(()); + } + let target = launchd_service_target()?; + let commands = launchd_uninstall_command_args(&target); + for (index, command) in commands.iter().enumerate() { + if index == 0 { + run_launchctl_owned_allow_not_loaded(command)?; + } else { + let _ = run_launchctl_owned(command); + } + } + Ok(()) +} + +#[cfg(target_os = "macos")] +fn verify_launchd_started(target: &str, socket_path: &Path) -> Result<()> { + if daemon_socket_state(socket_path) == "connectable" { + return Ok(()); + } + run_launchctl(&["print", target]).map(|_| ()) +} + +#[cfg(target_os = "macos")] +fn run_launchctl_owned(args: &[String]) -> Result { + let args = args.iter().map(String::as_str).collect::>(); + run_launchctl(&args) +} + +#[cfg(target_os = "macos")] +fn run_launchctl_owned_allow_not_loaded(args: &[String]) -> Result<()> { + match run_launchctl_owned(args) { + Ok(_) => Ok(()), + Err(error) if launchctl_error_is_not_loaded(&error.to_string()) => Ok(()), + Err(error) => Err(error), + } +} + +#[cfg(target_os = "macos")] +fn run_launchctl(args: &[&str]) -> Result { + let output = + Command::new("launchctl") + .args(args) + .output() + .map_err(|e| TraceDecayError::Config { + message: format!("failed to run launchctl {}: {e}", args.join(" ")), + })?; + if output.status.success() { + return Ok(output); + } + Err(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) + ), + }) +} + +#[cfg(target_os = "macos")] +fn launchctl_error_is_not_loaded(message: &str) -> bool { + [ + "No such process", + "No such file or directory", + "not found", + "Could not find service", + "service is not loaded", + ] + .iter() + .any(|needle| message.contains(needle)) +} From 248e9a58bd6b516de42ed1759e3cdc62a558576e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 1 Jul 2026 22:37:43 +0000 Subject: [PATCH 4/4] fix: address launchd service review findings - Make ServiceRunner variants unconditional so match dispatch compiles on every platform (fixes E0004 zero-arm matches on Windows); platform selection now lives only in ServiceRunner::current() - Fix Linux clippy failures (unused parameters, unnecessary Result wraps, underscore-prefixed binding read in the systemd arm) - Restore the PermissionDenied socket-state arm dropped in the module split and type socket state as an enum with a Display impl instead of comparing display strings - Make macOS install idempotent: bootout (tolerating not-loaded) before enable/bootstrap/kickstart, shared with the refresh path - Replace the positional Vec-of-Vec launchctl command plan with LaunchdCommand entries carrying an explicit failure mode; match not-loaded errors against launchctl stderr instead of formatted error prose - Disable the launchd agent on install --no-start so the plist stays inert at the next login, and create runtime dirs on --no-start too - Preserve the TRACEDECAY_DATA_DIR override from the installed plist when refreshing so refresh from a clean shell keeps the data dir - Move service tests into src/daemon/service.rs, removing the pub(super) test-only leaks and the test-gating asymmetry --- ...2026-07-01-macos-launchd-daemon-support.md | 18 +- src/daemon.rs | 457 +------- src/daemon/service.rs | 979 ++++++++++++++---- 3 files changed, 789 insertions(+), 665 deletions(-) diff --git a/docs/plans/2026-07-01-macos-launchd-daemon-support.md b/docs/plans/2026-07-01-macos-launchd-daemon-support.md index 4876572c..992a46f1 100644 --- a/docs/plans/2026-07-01-macos-launchd-daemon-support.md +++ b/docs/plans/2026-07-01-macos-launchd-daemon-support.md @@ -165,9 +165,9 @@ separate option. | Operation | Linux systemd | macOS launchd | |---|---|---| -| install with start | `daemon-reload`; `enable --now tracedecay.service` | write plist; `bootstrap gui/ `; `enable gui//com.tracedecay.daemon`; `kickstart -k gui//com.tracedecay.daemon` | -| install with `--no-start` | write unit only | write plist only | -| refresh | write unit; `daemon-reload`; `enable`; `restart` | write plist; if loaded, `bootout gui//com.tracedecay.daemon`; `bootstrap gui/ `; `enable ...`; `kickstart -k ...` | +| 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 | @@ -179,7 +179,8 @@ Implementation details: - 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 `enable` or `kickstart` for `--no-start`. +- 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 @@ -403,10 +404,11 @@ tests mechanically without changing expected Linux output. Add tests around command planning/fake command runner, not real `launchctl`: -- install with start plans `bootstrap`, `enable`, `kickstart`; -- install with `--no-start` writes plist only; -- refresh preserves existing socket path and plans `bootout`, `bootstrap`, - `enable`, `kickstart`; +- 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. diff --git a/src/daemon.rs b/src/daemon.rs index 0d4d0a06..87ba01bf 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1472,70 +1472,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")) @@ -1606,79 +1554,6 @@ mod tests { ); } - #[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}" - ); - } - #[cfg(unix)] #[test] fn scheduler_task_start_log_uses_task_key_and_project() { @@ -1743,334 +1618,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")); - } - - #[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 _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"), - }; - - 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")); - } - - #[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(), - }; - - 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::service::socket_path_from_launchd_plist(&plist), - Some(socket_path) - ); - } - - #[test] - fn socket_path_from_launchd_plist_returns_none_for_malformed_input() { - assert_eq!( - super::service::socket_path_from_launchd_plist(""), - None - ); - assert_eq!( - super::service::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::service::socket_path_from_launchd_plist(plist), - Some(PathBuf::from("/tmp/tracedecay.sock")) - ); - } - - #[test] - fn launchd_command_args_match_install_refresh_and_uninstall_mapping() { - let service_path = - PathBuf::from("/Users/me/Library/LaunchAgents/com.tracedecay.daemon.plist"); - - assert_eq!( - super::service::launchd_install_command_args( - "gui/501", - "gui/501/com.tracedecay.daemon", - &service_path - ), - vec![ - vec![ - "bootstrap".to_string(), - "gui/501".to_string(), - service_path.display().to_string() - ], - vec![ - "enable".to_string(), - "gui/501/com.tracedecay.daemon".to_string() - ], - vec![ - "kickstart".to_string(), - "-k".to_string(), - "gui/501/com.tracedecay.daemon".to_string() - ], - ] - ); - assert_eq!( - super::service::launchd_refresh_command_args( - "gui/501", - "gui/501/com.tracedecay.daemon", - &service_path - ) - .first(), - Some(&vec![ - "bootout".to_string(), - "gui/501/com.tracedecay.daemon".to_string() - ]) - ); - assert_eq!( - super::service::launchd_uninstall_command_args("gui/501/com.tracedecay.daemon"), - vec![ - vec![ - "bootout".to_string(), - "gui/501/com.tracedecay.daemon".to_string() - ], - vec![ - "disable".to_string(), - "gui/501/com.tracedecay.daemon".to_string() - ], - ] - ); - } - - #[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 index 810e7183..be1ebaf6 100644 --- a/src/daemon/service.rs +++ b/src/daemon/service.rs @@ -11,13 +11,13 @@ use crate::errors::{Result, TraceDecayError}; use super::SOCKET_ENV; const LAUNCHD_LABEL: &str = "com.tracedecay.daemon"; -#[cfg(target_os = "macos")] 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 { @@ -54,9 +54,10 @@ impl DaemonServiceSpec { } let home = home_for_service_env()?; - let data_dir = crate::config::user_data_dir().ok_or_else(|| TraceDecayError::Config { - message: "could not determine TraceDecay user data directory".to_string(), - })?; + let data_dir = match &self.data_dir_override { + Some(dir) => dir.clone(), + None => tracedecay_data_dir()?, + }; let mut env_entries = vec![ ( "PATH".to_string(), @@ -64,12 +65,10 @@ impl DaemonServiceSpec { ), ("HOME".to_string(), home.display().to_string()), ]; - if let Some(data_dir_override) = - std::env::var_os(crate::config::USER_DATA_DIR_ENV).filter(|value| !value.is_empty()) - { + if let Some(data_dir_override) = &self.data_dir_override { env_entries.push(( crate::config::USER_DATA_DIR_ENV.to_string(), - PathBuf::from(data_dir_override).display().to_string(), + data_dir_override.display().to_string(), )); } @@ -135,9 +134,7 @@ impl DaemonServiceSpec { fn render_unit(&self) -> Result { match ServiceRunner::current()? { - #[cfg(target_os = "linux")] ServiceRunner::Systemd => Ok(self.render_systemd_user_unit()), - #[cfg(target_os = "macos")] ServiceRunner::Launchd => self.render_launchd_plist(), } } @@ -217,7 +214,6 @@ fn plist_xml_escape(value: &str) -> String { escaped } -#[cfg(any(test, target_os = "macos"))] fn plist_xml_unescape(value: &str) -> String { value .replace(""", "\"") @@ -237,14 +233,17 @@ fn home_for_service_env() -> Result { }) } +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)); } - 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")) + Ok(tracedecay_data_dir()?.join("daemon.sock")) } pub fn socket_path_or_default(socket: Option) -> Result { @@ -258,6 +257,9 @@ pub fn service_spec( 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), }) } @@ -281,10 +283,17 @@ pub fn refresh_installed_service(spec: &DaemonServiceSpec) -> Result Result> { if !service_path.exists() { return Ok(None); } - service_socket_path_from_unit_file(&service_path) + Ok(socket_path_from_unit_text(&read_service_unit( + &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 { +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()), - })?; - Ok(socket_path_from_unit_text(&unit)) + }) +} + +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 } -#[cfg(target_os = "linux")] 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 - }) + .find_map(|exec_start| socket_path_from_args(exec_start.split_whitespace())) } -#[cfg(any(test, target_os = "macos"))] -pub(super) fn socket_path_from_launchd_plist(plist: &str) -> Option { +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(); @@ -360,19 +369,24 @@ pub(super) fn socket_path_from_launchd_plist(plist: &str) -> Option { let array_text = &after_array_start[..array_end]; let strings = plist_string_values(array_text); - let mut args = strings.iter(); - 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 + 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() } -#[cfg(any(test, target_os = "macos"))] fn plist_string_values(text: &str) -> Vec { let mut values = Vec::new(); let mut remaining = text; @@ -390,9 +404,7 @@ fn plist_string_values(text: &str) -> Vec { fn socket_path_from_unit_text(unit: &str) -> Option { match ServiceRunner::current().ok()? { - #[cfg(target_os = "linux")] ServiceRunner::Systemd => socket_path_from_service_unit(unit), - #[cfg(target_os = "macos")] ServiceRunner::Launchd => socket_path_from_launchd_plist(unit), } } @@ -400,7 +412,7 @@ fn socket_path_from_unit_text(unit: &str) -> Option { pub fn uninstall_service(stop: bool) -> Result { let runner = ServiceRunner::current()?; let service_path = service_unit_path()?; - runner.before_uninstall(&service_path, stop)?; + runner.before_uninstall(stop)?; match std::fs::remove_file(&service_path) { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} @@ -410,7 +422,7 @@ pub fn uninstall_service(stop: bool) -> Result { }); } } - runner.after_uninstall(stop)?; + runner.after_uninstall(stop); Ok(service_path) } @@ -420,13 +432,14 @@ pub fn service_status(socket_path: &Path) -> String { |e| format!("unavailable: {e}"), |path| path.display().to_string(), ); - let detail = ServiceRunner::current() + let runner = ServiceRunner::current(); + let detail = runner + .as_ref() .ok() - .and_then(|runner| runner.service_detail_hint()) + .and_then(ServiceRunner::service_detail_hint) .map(|hint| format!("service-detail: {hint}\n")) .unwrap_or_default(); - let logs = ServiceRunner::current() - .map_or_else(|e| format!("unavailable: {e}"), |runner| runner.log_hint()); + let logs = runner.map_or_else(|e| format!("unavailable: {e}"), |runner| runner.log_hint()); format!( "service: {}\nsocket: {} ({})\n{}logs: {}\n", service, @@ -437,51 +450,83 @@ pub fn service_status(socket_path: &Path) -> String { ) } +#[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) -> &'static str { +fn daemon_socket_state(socket_path: &Path) -> DaemonSocketState { if !socket_path.exists() { - return "missing"; + return DaemonSocketState::Missing; } match StdUnixStream::connect(socket_path) { - Ok(_) => "connectable", - Err(e) if e.kind() == std::io::ErrorKind::ConnectionRefused => "stale", - Err(_) => "present but unreachable", + 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) -> &'static str { +fn daemon_socket_state(socket_path: &Path) -> DaemonSocketState { if socket_path.exists() { - "present but unsupported on this platform" + DaemonSocketState::Present } else { - "missing" + 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 { - #[cfg(target_os = "linux")] Systemd, - #[cfg(target_os = "macos")] Launchd, } impl ServiceRunner { fn current() -> Result { - #[cfg(target_os = "linux")] - { - return Ok(Self::Systemd); - } - #[cfg(target_os = "macos")] - { - return Ok(Self::Launchd); + if cfg!(target_os = "linux") { + Ok(Self::Systemd) + } else if cfg!(target_os = "macos") { + Ok(Self::Launchd) + } else { + Err(unsupported_service_platform()) } - #[allow(unreachable_code)] - Err(unsupported_service_platform()) } fn install(&self, service_path: &Path, start: bool, socket_path: &Path) -> Result<()> { match self { - #[cfg(target_os = "linux")] Self::Systemd => { if start { run_systemctl(&["daemon-reload"])?; @@ -489,58 +534,48 @@ impl ServiceRunner { } Ok(()) } - #[cfg(target_os = "macos")] Self::Launchd => launchd_install(service_path, start, socket_path), } } fn refresh(&self, service_path: &Path, socket_path: &Path) -> Result<()> { match self { - #[cfg(target_os = "linux")] Self::Systemd => { run_systemctl(&["daemon-reload"])?; run_systemctl(&["enable", super::SERVICE_NAME])?; run_systemctl(&["restart", super::SERVICE_NAME])?; Ok(()) } - #[cfg(target_os = "macos")] Self::Launchd => launchd_refresh(service_path, socket_path), } } - fn before_uninstall(&self, service_path: &Path, stop: bool) -> Result<()> { + fn before_uninstall(&self, stop: bool) -> Result<()> { match self { - #[cfg(target_os = "linux")] Self::Systemd => { if stop { let _ = run_systemctl(&["disable", "--now", super::SERVICE_NAME]); } Ok(()) } - #[cfg(target_os = "macos")] - Self::Launchd => launchd_before_uninstall(service_path, stop), + Self::Launchd => launchd_before_uninstall(stop), } } - fn after_uninstall(&self, _stop: bool) -> Result<()> { + fn after_uninstall(&self, stop: bool) { match self { - #[cfg(target_os = "linux")] Self::Systemd => { - if _stop { + if stop { let _ = run_systemctl(&["daemon-reload"]); } - Ok(()) } - #[cfg(target_os = "macos")] - Self::Launchd => Ok(()), + Self::Launchd => {} } } fn log_hint(&self) -> String { match self { - #[cfg(target_os = "linux")] Self::Systemd => format!("journalctl --user -u {} -f", super::SERVICE_NAME), - #[cfg(target_os = "macos")] 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()), @@ -550,9 +585,7 @@ impl ServiceRunner { fn service_detail_hint(&self) -> Option { match self { - #[cfg(target_os = "linux")] Self::Systemd => None, - #[cfg(target_os = "macos")] Self::Launchd => launchd_service_target() .ok() .map(|target| format!("launchctl print {target}")), @@ -561,19 +594,12 @@ impl ServiceRunner { } fn service_unit_path() -> Result { - #[cfg(target_os = "linux")] - { - return systemd_user_service_path(); + match ServiceRunner::current()? { + ServiceRunner::Systemd => systemd_user_service_path(), + ServiceRunner::Launchd => launchd_user_service_path(), } - #[cfg(target_os = "macos")] - { - return launchd_user_service_path(); - } - #[allow(unreachable_code)] - Err(unsupported_service_platform()) } -#[cfg(target_os = "linux")] fn systemd_user_service_path() -> Result { let config_home = std::env::var_os("XDG_CONFIG_HOME") .map(PathBuf::from) @@ -584,13 +610,11 @@ fn systemd_user_service_path() -> Result { Ok(config_home.join("systemd/user").join(super::SERVICE_NAME)) } -#[cfg(target_os = "macos")] fn launchd_user_service_path() -> Result { let home = home_for_service_env()?; Ok(home.join("Library/LaunchAgents").join(LAUNCHD_PLIST_NAME)) } -#[cfg(target_os = "linux")] fn run_systemctl(args: &[&str]) -> Result<()> { let output = Command::new("systemctl") .arg("--user") @@ -619,47 +643,83 @@ fn unsupported_service_platform() -> TraceDecayError { } } -#[cfg(any(test, target_os = "macos"))] -pub(super) fn launchd_install_command_args( - domain: &str, - target: &str, - service_path: &Path, -) -> Vec> { - vec![ - vec![ - "bootstrap".to_string(), - domain.to_string(), - service_path.display().to_string(), - ], - vec!["enable".to_string(), target.to_string()], - vec![ - "kickstart".to_string(), - "-k".to_string(), - target.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, } -#[cfg(any(test, target_os = "macos"))] -pub(super) fn launchd_refresh_command_args( +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> { - let mut commands = vec![vec!["bootout".to_string(), target.to_string()]]; - commands.extend(launchd_install_command_args(domain, target, service_path)); - commands +) -> 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), + ] } -#[cfg(any(test, target_os = "macos"))] -pub(super) fn launchd_uninstall_command_args(target: &str) -> Vec> { +fn launchd_uninstall_command_plan(target: &str) -> Vec { vec![ - vec!["bootout".to_string(), target.to_string()], - vec!["disable".to_string(), target.to_string()], + 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), ] } -#[cfg(target_os = "macos")] +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") @@ -685,16 +745,12 @@ fn launchd_domain() -> Result { Ok(format!("gui/{uid}")) } -#[cfg(target_os = "macos")] fn launchd_service_target() -> Result { Ok(format!("{}/{}", launchd_domain()?, LAUNCHD_LABEL)) } -#[cfg(target_os = "macos")] fn ensure_launchd_runtime_dirs() -> Result<()> { - let data_dir = crate::config::user_data_dir().ok_or_else(|| TraceDecayError::Config { - message: "could not determine TraceDecay user data directory".to_string(), - })?; + 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}", @@ -703,89 +759,56 @@ fn ensure_launchd_runtime_dirs() -> Result<()> { }) } -#[cfg(target_os = "macos")] fn launchd_install(service_path: &Path, start: bool, socket_path: &Path) -> Result<()> { - if !start { - return Ok(()); - } ensure_launchd_runtime_dirs()?; - let domain = launchd_domain()?; let target = launchd_service_target()?; - for command in launchd_install_command_args(&domain, &target, service_path) { - run_launchctl_owned(&command)?; + 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(()); } - verify_launchd_started(&target, socket_path) + launchd_start(&target, service_path, socket_path) } -#[cfg(target_os = "macos")] fn launchd_refresh(service_path: &Path, socket_path: &Path) -> Result<()> { ensure_launchd_runtime_dirs()?; - let domain = launchd_domain()?; let target = launchd_service_target()?; - let commands = launchd_refresh_command_args(&domain, &target, service_path); - for (index, command) in commands.iter().enumerate() { - if index == 0 { - run_launchctl_owned_allow_not_loaded(command)?; - } else { - run_launchctl_owned(command)?; - } - } - verify_launchd_started(&target, socket_path) + launchd_start(&target, service_path, socket_path) } -#[cfg(target_os = "macos")] -fn launchd_before_uninstall(_service_path: &Path, stop: bool) -> Result<()> { +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()?; - let commands = launchd_uninstall_command_args(&target); - for (index, command) in commands.iter().enumerate() { - if index == 0 { - run_launchctl_owned_allow_not_loaded(command)?; - } else { - let _ = run_launchctl_owned(command); - } - } - Ok(()) + run_launchd_commands(&launchd_uninstall_command_plan(&target)) } -#[cfg(target_os = "macos")] fn verify_launchd_started(target: &str, socket_path: &Path) -> Result<()> { - if daemon_socket_state(socket_path) == "connectable" { + if daemon_socket_state(socket_path) == DaemonSocketState::Connectable { return Ok(()); } run_launchctl(&["print", target]).map(|_| ()) } -#[cfg(target_os = "macos")] -fn run_launchctl_owned(args: &[String]) -> Result { - let args = args.iter().map(String::as_str).collect::>(); - run_launchctl(&args) -} - -#[cfg(target_os = "macos")] -fn run_launchctl_owned_allow_not_loaded(args: &[String]) -> Result<()> { - match run_launchctl_owned(args) { - Ok(_) => Ok(()), - Err(error) if launchctl_error_is_not_loaded(&error.to_string()) => Ok(()), - Err(error) => Err(error), - } +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(" ")), + }) } -#[cfg(target_os = "macos")] -fn run_launchctl(args: &[&str]) -> Result { - let output = - Command::new("launchctl") - .args(args) - .output() - .map_err(|e| TraceDecayError::Config { - message: format!("failed to run launchctl {}: {e}", args.join(" ")), - })?; - if output.status.success() { - return Ok(output); - } - Err(TraceDecayError::Config { +fn launchctl_failure(args: &[&str], output: &std::process::Output) -> TraceDecayError { + TraceDecayError::Config { message: format!( "launchctl {} failed with status {}\nstdout:\n{}\nstderr:\n{}", args.join(" "), @@ -793,18 +816,570 @@ fn run_launchctl(args: &[&str]) -> Result { String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ), - }) + } } -#[cfg(target_os = "macos")] -fn launchctl_error_is_not_loaded(message: &str) -> bool { +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", - "not found", "Could not find service", + "Could not find specified service", "service is not loaded", ] .iter() - .any(|needle| message.contains(needle)) + .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 + ); + } }