This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-installer/src/systemd.rs
T
Miguel PalhasandClaude Opus 5 4fc90aabfb feat(maestro): serve the web UI from the daemon
The browser reached the daemon through maestro-web, a second binary that
served the SPA and reverse-proxied /api/* — two TCP connections and a
WebSocket frame re-encode on every keystroke and every byte of PTY output.
On one machine that hop buys nothing.

The daemon now embeds the bundle and serves it off its own listener, behind
a default-on `web` feature. `--no-default-features` builds without pnpm or
node; MAESTRO_WEB_DIST still takes a prebuilt bundle.

The router's fallback answers an unmatched /api/* with 404 rather than the
SPA shell, so a client expecting JSON gets a status it can read instead of
HTML.

Removes maestro-web and the [web] config section. An existing [web] block
is ignored, not rejected. The installer deletes a leftover
maestro-web.service on upgrade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 18:23:18 +01:00

153 lines
5.6 KiB
Rust

//! systemd detection + user-unit rendering.
//!
//! The wizard only ever writes a *user* unit (`~/.config/systemd/user/`), since
//! `maestro daemon` runs per-user and uses tmux sockets in the user's runtime dir.
use std::path::{Path, PathBuf};
/// Standard probe path. Present on hosts booted under systemd.
pub const SYSTEMD_PROBE_PATH: &str = "/run/systemd/system";
/// Filename written under `~/.config/systemd/user/` for the daemon (NAP-245
/// renamed this from `maestrod.service`).
pub const UNIT_FILE_NAME: &str = "maestro.service";
/// Filename of the retired browser-UI unit. The daemon serves the SPA itself
/// now, so the installer only ever removes this one — never writes it.
pub const LEGACY_WEB_UNIT_FILE_NAME: &str = "maestro-web.service";
/// `true` when the given path exists. Production callers should pass
/// [`SYSTEMD_PROBE_PATH`]; tests inject a temp path so they're hermetic.
pub fn is_systemd_host(probe: &Path) -> bool {
probe.exists()
}
/// `~/.config/systemd/user/<unit>`. `None` if `$HOME` / config dir is
/// unresolvable.
pub fn user_unit_path_for(unit_name: &str) -> Option<PathBuf> {
let base = dirs::config_dir()?;
Some(base.join("systemd").join("user").join(unit_name))
}
/// Convenience: path for `maestro.service`. Kept as a free function so
/// existing callers don't need to thread the unit name around.
pub fn user_unit_path() -> Option<PathBuf> {
user_unit_path_for(UNIT_FILE_NAME)
}
/// Convenience: path for the retired `maestro-web.service`, used only by the
/// installer's cleanup step.
pub fn legacy_web_unit_path() -> Option<PathBuf> {
user_unit_path_for(LEGACY_WEB_UNIT_FILE_NAME)
}
/// Render the daemon unit. `exec_start` is the absolute path to `maestro` —
/// we default to `~/.cargo/bin/maestro` because that's where `cargo install`
/// lands binaries; systemd doesn't inherit the user's PATH, so we can't rely
/// on a bare `maestro`. The unit runs `maestro daemon` (NAP-245).
pub fn render_user_unit(exec_start: &Path) -> String {
format!(
"[Unit]\n\
Description=Maestro per-host daemon\n\
Documentation=https://git.naps.pt/naps62/maestro\n\
After=network-online.target\n\
\n\
[Service]\n\
Type=simple\n\
ExecStart={exec} daemon\n\
# Signal only the daemon on stop/restart, not its whole cgroup —\n\
# otherwise systemd kills the tmux server (and every session) the\n\
# daemon spawned, defeating tmux persistence. recover() re-adopts the\n\
# surviving sessions on the next start (NAP-330).\n\
KillMode=process\n\
Restart=on-failure\n\
RestartSec=5\n\
\n\
[Install]\n\
WantedBy=default.target\n",
exec = exec_start.display()
)
}
/// Default `ExecStart` target: `~/.cargo/bin/maestro`. `None` if `$HOME` is
/// unresolvable.
pub fn default_maestro_path() -> Option<PathBuf> {
Some(dirs::home_dir()?.join(".cargo").join("bin").join("maestro"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_systemd_host_true_when_probe_exists() {
let dir = tempfile::tempdir().unwrap();
let probe = dir.path().join("system");
std::fs::create_dir(&probe).unwrap();
assert!(is_systemd_host(&probe));
}
#[test]
fn is_systemd_host_false_when_probe_missing() {
let dir = tempfile::tempdir().unwrap();
let probe = dir.path().join("not-here");
assert!(!is_systemd_host(&probe));
}
#[test]
fn unit_renders_with_absolute_exec_start() {
let body = render_user_unit(Path::new("/home/alice/.cargo/bin/maestro"));
assert!(body.contains("ExecStart=/home/alice/.cargo/bin/maestro daemon"));
assert!(body.contains("[Unit]"));
assert!(body.contains("[Service]"));
assert!(body.contains("[Install]"));
assert!(body.contains("WantedBy=default.target"));
assert!(body.contains("Restart=on-failure"));
}
#[test]
fn unit_sets_killmode_process_so_restart_keeps_sessions() {
// NAP-330: without KillMode=process, `systemctl restart` SIGKILLs the
// daemon's whole cgroup — including the tmux server and every session.
let body = render_user_unit(Path::new("/x/maestro"));
assert!(
body.contains("\nKillMode=process\n"),
"daemon unit must set KillMode=process; got:\n{body}"
);
}
#[test]
fn unit_has_no_execreload() {
// NAP-339 removed the daemon's SIGHUP handler (it only ever reloaded
// the now-deleted `[daemon.policy]`). Without a handler, SIGHUP's
// default disposition is to terminate, so the unit must NOT wire
// `systemctl reload` to it — that would kill the daemon.
let body = render_user_unit(Path::new("/x/maestro"));
assert!(!body.contains("ExecReload"));
}
#[test]
fn unit_ends_with_newline() {
let body = render_user_unit(Path::new("/x"));
assert!(body.ends_with('\n'), "unit must end with newline");
}
#[test]
fn user_unit_path_points_under_config_dir() {
let p = user_unit_path().expect("config dir resolvable in test env");
assert!(p.ends_with("systemd/user/maestro.service"), "got {p:?}");
}
#[test]
fn legacy_web_unit_path_points_under_config_dir() {
let p = legacy_web_unit_path().expect("config dir resolvable in test env");
assert!(p.ends_with("systemd/user/maestro-web.service"), "got {p:?}");
}
#[test]
fn default_maestro_path_under_cargo_bin() {
let p = default_maestro_path().expect("HOME resolvable in test env");
assert!(p.ends_with(".cargo/bin/maestro"), "got {p:?}");
}
}