anvilsign in

collin/browser-terminal-extension

main / daemon / src / install.rs
1//! `termbridge install` — hand the daemon to the service manager so nobody has
2//! to remember to start it.
3//!
4//! On Linux this is socket activation: systemd binds 127.0.0.1:7681 at login
5//! and starts the daemon on the first connection, so the sidebar just works
6//! from a cold boot with no process running at rest. See [`crate::activation`].
7//!
8//! macOS gets a plain login agent instead. launchd can do socket activation
9//! too, but only through `launch_activate_socket(3)`, and shipping untested
10//! FFI would be worse than an idle process.
11
12use std::io::Write;
13use std::path::{Path, PathBuf};
14use std::process::Command;
15
16/// Seconds of no clients before an activated daemon exits. Long enough that
17/// closing the sidebar for a moment doesn't churn the process, short enough
18/// that a forgotten browser tab doesn't pin a pty server all week.
19const DEFAULT_IDLE: u64 = 900;
20
21pub struct Options {
22 pub port: u16,
23 pub idle_secs: u64,
24 pub session: Option<String>,
25}
26
27impl Default for Options {
28 fn default() -> Self {
29 Options {
30 port: crate::DEFAULT_PORT,
31 idle_secs: DEFAULT_IDLE,
32 session: None,
33 }
34 }
35}
36
37pub fn install(opts: &Options) -> std::io::Result<()> {
38 let exe = exe_path()?;
39 if cfg!(target_os = "macos") {
40 launchd_install(&exe, opts)
41 } else {
42 systemd_install(&exe, opts)
43 }
44}
45
46pub fn uninstall() -> std::io::Result<()> {
47 if cfg!(target_os = "macos") {
48 launchd_uninstall()
49 } else {
50 systemd_uninstall()
51 }
52}
53
54/// `termbridge reload` — put the daemon that is running now out, so the next
55/// connection starts the binary that is on disk now.
56///
57/// The update path after a rebuild. `install` already does this as a side
58/// effect, but it also rewrites the unit files from the flags it was given, so
59/// re-running it to pick up a new binary quietly resets an idle timeout or a
60/// pinned session that was set once and forgotten. This touches no
61/// configuration: same units, same options, new process.
62///
63/// What it does *not* restart is tmux. The sessions, their windows and
64/// everything running in them belong to the tmux server, which this daemon only
65/// attaches to — so a reload drops the sidebar's socket for as long as it takes
66/// the browser to reconnect, and loses nothing else.
67pub fn reload() -> std::io::Result<()> {
68 if cfg!(target_os = "macos") {
69 launchd_reload()
70 } else {
71 systemd_reload()
72 }
73}
74
75/// Resolved, absolute, and symlink-free: the unit file outlives this shell, so
76/// a relative `./target/release/termbridge` in it would break the moment the
77/// user cd'd somewhere else.
78fn exe_path() -> std::io::Result<PathBuf> {
79 let exe = std::env::current_exe()?;
80 Ok(std::fs::canonicalize(&exe).unwrap_or(exe))
81}
82
83// ---------------------------------------------------------------------------
84// systemd
85// ---------------------------------------------------------------------------
86
87pub const SOCKET_UNIT: &str = "termbridge.socket";
88pub const SERVICE_UNIT: &str = "termbridge.service";
89
90fn home() -> PathBuf {
91 std::env::var_os("HOME")
92 .map(PathBuf::from)
93 .unwrap_or_default()
94}
95
96fn systemd_unit_dir() -> PathBuf {
97 match std::env::var_os("XDG_CONFIG_HOME") {
98 Some(x) if !x.is_empty() => PathBuf::from(x),
99 _ => home().join(".config"),
100 }
101 .join("systemd/user")
102}
103
104pub fn socket_unit(port: u16) -> String {
105 format!(
106 "# Written by `termbridge install`. Safe to edit; re-running overwrites it.\n\
107 [Unit]\n\
108 Description=termbridge terminal bridge socket\n\
109 Documentation=https://github.com/collin/terminal\n\
110 \n\
111 [Socket]\n\
112 # Loopback only. The daemon hands out shell access and refuses to serve\n\
113 # anything else, so widening this just breaks startup.\n\
114 ListenStream=127.0.0.1:{port}\n\
115 # One daemon for all connections, not one process per connection.\n\
116 Accept=no\n\
117 \n\
118 [Install]\n\
119 WantedBy=sockets.target\n"
120 )
121}
122
123pub fn service_unit(exe: &Path, opts: &Options) -> String {
124 let session = match &opts.session {
125 Some(s) => format!(" --session {s}"),
126 None => String::new(),
127 };
128 format!(
129 "# Written by `termbridge install`. Safe to edit; re-running overwrites it.\n\
130 [Unit]\n\
131 Description=termbridge terminal bridge for the browser sidebar\n\
132 Requires={SOCKET_UNIT}\n\
133 After={SOCKET_UNIT}\n\
134 \n\
135 [Service]\n\
136 ExecStart={exe} serve --systemd-socket --idle-timeout {idle}{session}\n\
137 # The daemon exits on its own once no sidebar has been connected for\n\
138 # --idle-timeout. tmux keeps the sessions, so that loses nothing, and\n\
139 # the socket unit starts us again on the next connection.\n\
140 Restart=no\n\
141 SuccessExitStatus=0\n\
142 # Stopping this unit must stop this daemon and nothing else. On a\n\
143 # machine with no tmux server running, *we* are what starts one, and a\n\
144 # process forked from here keeps this cgroup for life — reparenting to\n\
145 # systemd when tmux daemonises via setsid does not move it out. Under\n\
146 # the default KillMode=control-group, and even under KillMode=mixed —\n\
147 # which still SIGKILLs every remaining cgroup process the moment this\n\
148 # daemon's main process exits, per systemd.kill(5) — `termbridge\n\
149 # reload` would kill the user's tmux server and every pane in it. Only\n\
150 # KillMode=process leaves the rest of the cgroup alone.\n\
151 KillMode=process\n",
152 exe = exe.display(),
153 idle = opts.idle_secs,
154 )
155}
156
157fn systemd_install(exe: &Path, opts: &Options) -> std::io::Result<()> {
158 let dir = systemd_unit_dir();
159 std::fs::create_dir_all(&dir)?;
160 write_file(&dir.join(SOCKET_UNIT), &socket_unit(opts.port))?;
161 write_file(&dir.join(SERVICE_UNIT), &service_unit(exe, opts))?;
162 println!("wrote {}/{{{SOCKET_UNIT},{SERVICE_UNIT}}}", dir.display());
163
164 systemctl(&["daemon-reload"])?;
165 // The service is deliberately not enabled: enabling the *socket* is what
166 // makes the daemon on-demand. Starting the service directly would defeat
167 // the point and, worse, race the socket for the port.
168 systemctl(&["enable", SOCKET_UNIT])?;
169
170 // Re-running install has to work, because that is the update path: rebuild
171 // the binary, run install, done. Two things make that awkward.
172 //
173 // `enable --now` is a no-op on an already-active socket, so it would leave
174 // the old configuration listening while systemd logs "Unit configuration
175 // changed while unit was running ... Unit not functional until restarted"
176 // and quietly stops accepting. Only an explicit restart re-reads it.
177 //
178 // And the restart has to come *after* the daemon is gone, or the new socket
179 // cannot bind the port the old daemon is still holding.
180 let _ = systemctl(&["stop", SERVICE_UNIT]);
181 systemctl(&["restart", SOCKET_UNIT])?;
182
183 // A systemd user service inherits the manager's environment, not a login
184 // shell's. Without this, PATH can be missing whatever the user added in
185 // their profile, and the daemon reports "tmux not found" while `tmux` works
186 // fine in every terminal they have open.
187 let _ = systemctl(&["import-environment", "PATH"]);
188
189 println!(
190 "\nsocket-activated on 127.0.0.1:{port}. Nothing is running yet — the first\n\
191 sidebar connection starts the daemon, and it exits {idle}s after the last\n\
192 one disconnects. tmux keeps your sessions across that.\n",
193 port = opts.port,
194 idle = opts.idle_secs,
195 );
196 println!(" status: systemctl --user status {SERVICE_UNIT}");
197 println!(" logs: journalctl --user -u {SERVICE_UNIT} -f");
198 println!(" remove: termbridge uninstall");
199 Ok(())
200}
201
202fn systemd_uninstall() -> std::io::Result<()> {
203 let _ = systemctl(&["disable", "--now", SOCKET_UNIT]);
204 let _ = systemctl(&["stop", SERVICE_UNIT]);
205 let dir = systemd_unit_dir();
206 for unit in [SOCKET_UNIT, SERVICE_UNIT] {
207 let path = dir.join(unit);
208 match std::fs::remove_file(&path) {
209 Ok(()) => println!("removed {}", path.display()),
210 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
211 Err(e) => return Err(e),
212 }
213 }
214 let _ = systemctl(&["daemon-reload"]);
215 println!("\nthe daemon is no longer started automatically; `termbridge serve` still works.");
216 Ok(())
217}
218
219fn systemd_reload() -> std::io::Result<()> {
220 let dir = systemd_unit_dir();
221 if !dir.join(SOCKET_UNIT).exists() {
222 return Err(std::io::Error::other(format!(
223 "no {SOCKET_UNIT} in {} — run `termbridge install` first, or restart \
224 your own `termbridge serve` by hand",
225 dir.display()
226 )));
227 }
228
229 // A unit edited by hand since login is a unit systemd is still holding the
230 // old text of, and the restart below would put that old text back.
231 systemctl(&["daemon-reload"])?;
232
233 // Stop before restarting the socket, for the reason `install` documents:
234 // the new listener cannot bind a port the old daemon is still holding.
235 // Ignored rather than checked — "it was not running" is the normal case
236 // here, and it is the state we wanted anyway.
237 let _ = systemctl(&["stop", SERVICE_UNIT]);
238 systemctl(&["restart", SOCKET_UNIT])?;
239 let _ = systemctl(&["import-environment", "PATH"]);
240
241 println!(
242 "daemon stopped and the socket is listening again. The next sidebar\n\
243 connection starts the current binary; your tmux sessions are untouched."
244 );
245 Ok(())
246}
247
248fn launchd_reload() -> std::io::Result<()> {
249 let path = launchd_plist_path();
250 if !path.exists() {
251 return Err(std::io::Error::other(format!(
252 "no {} — run `termbridge install` first, or restart your own \
253 `termbridge serve` by hand",
254 path.display()
255 )));
256 }
257 // `kickstart -k` kills the running agent and starts it again from the plist
258 // already loaded, which is what a reload is. No bootout/bootstrap pair: that
259 // would re-read a plist this command has no business rewriting.
260 let target = format!("gui/{}/{LAUNCHD_LABEL}", uid());
261 let status = Command::new("launchctl")
262 .args(["kickstart", "-k", &target])
263 .status()?;
264 if !status.success() {
265 return Err(std::io::Error::other(format!(
266 "launchctl kickstart -k {target} failed ({status})"
267 )));
268 }
269 println!("daemon restarted; your tmux sessions are untouched.");
270 Ok(())
271}
272
273fn systemctl(args: &[&str]) -> std::io::Result<()> {
274 let status = Command::new("systemctl")
275 .arg("--user")
276 .args(args)
277 .status()
278 .map_err(|e| {
279 std::io::Error::new(
280 e.kind(),
281 format!("could not run systemctl --user {}: {e}", args.join(" ")),
282 )
283 })?;
284 if !status.success() {
285 return Err(std::io::Error::other(format!(
286 "systemctl --user {} failed ({status})",
287 args.join(" ")
288 )));
289 }
290 Ok(())
291}
292
293// ---------------------------------------------------------------------------
294// launchd
295// ---------------------------------------------------------------------------
296
297pub const LAUNCHD_LABEL: &str = "com.termbridge.daemon";
298
299fn launchd_plist_path() -> PathBuf {
300 home().join(format!("Library/LaunchAgents/{LAUNCHD_LABEL}.plist"))
301}
302
303pub fn launchd_plist(exe: &Path, opts: &Options) -> String {
304 let mut args = vec![
305 exe.display().to_string(),
306 "serve".into(),
307 "--port".into(),
308 opts.port.to_string(),
309 ];
310 if let Some(s) = &opts.session {
311 args.push("--session".into());
312 args.push(s.clone());
313 }
314 let args: String = args
315 .iter()
316 .map(|a| format!(" <string>{}</string>\n", xml_escape(a)))
317 .collect();
318 let log = crate::paths::config_dir().join("daemon.log");
319 format!(
320 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
321 <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
322 <plist version=\"1.0\"><dict>\n\
323 \x20 <key>Label</key><string>{LAUNCHD_LABEL}</string>\n\
324 \x20 <key>ProgramArguments</key><array>\n{args} </array>\n\
325 \x20 <key>RunAtLoad</key><true/>\n\
326 \x20 <key>KeepAlive</key><true/>\n\
327 \x20 <key>StandardOutPath</key><string>{log}</string>\n\
328 \x20 <key>StandardErrorPath</key><string>{log}</string>\n\
329 </dict></plist>\n",
330 log = xml_escape(&log.display().to_string()),
331 )
332}
333
334fn launchd_install(exe: &Path, opts: &Options) -> std::io::Result<()> {
335 let path = launchd_plist_path();
336 if let Some(parent) = path.parent() {
337 std::fs::create_dir_all(parent)?;
338 }
339 write_file(&path, &launchd_plist(exe, opts))?;
340 println!("wrote {}", path.display());
341
342 let target = format!("gui/{}", uid());
343 let _ = Command::new("launchctl")
344 .args(["bootout", &format!("{target}/{LAUNCHD_LABEL}")])
345 .status();
346 let status = Command::new("launchctl")
347 .args(["bootstrap", &target])
348 .arg(&path)
349 .status()?;
350 if !status.success() {
351 return Err(std::io::Error::other(format!(
352 "launchctl bootstrap failed ({status})"
353 )));
354 }
355 println!(
356 "\nrunning at login on 127.0.0.1:{port}.\n\
357 Note: this is a plain login agent, so the daemon stays resident. On-demand\n\
358 socket activation is Linux-only for now.\n",
359 port = opts.port
360 );
361 println!(
362 " logs: tail -f {}",
363 crate::paths::config_dir().join("daemon.log").display()
364 );
365 println!(" remove: termbridge uninstall");
366 Ok(())
367}
368
369fn launchd_uninstall() -> std::io::Result<()> {
370 let path = launchd_plist_path();
371 let _ = Command::new("launchctl")
372 .args(["bootout", &format!("gui/{}/{LAUNCHD_LABEL}", uid())])
373 .status();
374 match std::fs::remove_file(&path) {
375 Ok(()) => println!("removed {}", path.display()),
376 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
377 Err(e) => return Err(e),
378 }
379 println!("\nthe daemon is no longer started at login; `termbridge serve` still works.");
380 Ok(())
381}
382
383/// Shelling out to `id -u` rather than taking a libc dependency for one number
384/// on one platform. Falls back to 501, macOS's first human user.
385fn uid() -> String {
386 Command::new("id")
387 .arg("-u")
388 .output()
389 .ok()
390 .filter(|o| o.status.success())
391 .and_then(|o| String::from_utf8(o.stdout).ok())
392 .map(|s| s.trim().to_string())
393 .filter(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()))
394 .unwrap_or_else(|| "501".into())
395}
396
397fn xml_escape(s: &str) -> String {
398 s.replace('&', "&amp;")
399 .replace('<', "&lt;")
400 .replace('>', "&gt;")
401}
402
403fn write_file(path: &Path, contents: &str) -> std::io::Result<()> {
404 let mut f = std::fs::File::create(path)?;
405 f.write_all(contents.as_bytes())?;
406 Ok(())
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 #[test]
414 fn socket_unit_is_loopback_only() {
415 let unit = socket_unit(7681);
416 assert!(unit.contains("ListenStream=127.0.0.1:7681"));
417 assert!(unit.contains("WantedBy=sockets.target"));
418 }
419
420 /// The service must ask for the passed socket. Without the flag it would
421 /// try to bind the port systemd already owns and fail on every activation.
422 #[test]
423 fn service_unit_consumes_the_activated_socket() {
424 let unit = service_unit(Path::new("/usr/local/bin/termbridge"), &Options::default());
425 assert!(unit.contains("ExecStart=/usr/local/bin/termbridge serve --systemd-socket"));
426 assert!(unit.contains(&format!("--idle-timeout {DEFAULT_IDLE}")));
427 assert!(unit.contains(&format!("Requires={SOCKET_UNIT}")));
428 assert!(!unit.contains("--port"), "the socket unit owns the port");
429 }
430
431 #[test]
432 fn service_unit_carries_a_pinned_session() {
433 let opts = Options {
434 session: Some("work".into()),
435 ..Options::default()
436 };
437 let unit = service_unit(Path::new("/bin/termbridge"), &opts);
438 assert!(unit.contains("--session work"));
439 }
440
441 #[test]
442 fn plist_escapes_paths() {
443 let opts = Options::default();
444 let plist = launchd_plist(Path::new("/tmp/a&b/termbridge"), &opts);
445 assert!(plist.contains("<string>/tmp/a&amp;b/termbridge</string>"));
446 assert!(plist.contains(LAUNCHD_LABEL));
447 }
448}