anvilsign in

collin/browser-terminal-extension

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 does not move it out. So under the\n\
146 # default KillMode=control-group, `termbridge reload` would signal the\n\
147 # user's tmux server and every pane in it, which is the one thing this\n\
148 # daemon promises never to touch.\n\
149 KillMode=mixed\n",
150 exe = exe.display(),
151 idle = opts.idle_secs,
152 )
153}
154
155fn systemd_install(exe: &Path, opts: &Options) -> std::io::Result<()> {
156 let dir = systemd_unit_dir();
157 std::fs::create_dir_all(&dir)?;
158 write_file(&dir.join(SOCKET_UNIT), &socket_unit(opts.port))?;
159 write_file(&dir.join(SERVICE_UNIT), &service_unit(exe, opts))?;
160 println!("wrote {}/{{{SOCKET_UNIT},{SERVICE_UNIT}}}", dir.display());
161
162 systemctl(&["daemon-reload"])?;
163 // The service is deliberately not enabled: enabling the *socket* is what
164 // makes the daemon on-demand. Starting the service directly would defeat
165 // the point and, worse, race the socket for the port.
166 systemctl(&["enable", SOCKET_UNIT])?;
167
168 // Re-running install has to work, because that is the update path: rebuild
169 // the binary, run install, done. Two things make that awkward.
170 //
171 // `enable --now` is a no-op on an already-active socket, so it would leave
172 // the old configuration listening while systemd logs "Unit configuration
173 // changed while unit was running ... Unit not functional until restarted"
174 // and quietly stops accepting. Only an explicit restart re-reads it.
175 //
176 // And the restart has to come *after* the daemon is gone, or the new socket
177 // cannot bind the port the old daemon is still holding.
178 let _ = systemctl(&["stop", SERVICE_UNIT]);
179 systemctl(&["restart", SOCKET_UNIT])?;
180
181 // A systemd user service inherits the manager's environment, not a login
182 // shell's. Without this, PATH can be missing whatever the user added in
183 // their profile, and the daemon reports "tmux not found" while `tmux` works
184 // fine in every terminal they have open.
185 let _ = systemctl(&["import-environment", "PATH"]);
186
187 println!(
188 "\nsocket-activated on 127.0.0.1:{port}. Nothing is running yet — the first\n\
189 sidebar connection starts the daemon, and it exits {idle}s after the last\n\
190 one disconnects. tmux keeps your sessions across that.\n",
191 port = opts.port,
192 idle = opts.idle_secs,
193 );
194 println!(" status: systemctl --user status {SERVICE_UNIT}");
195 println!(" logs: journalctl --user -u {SERVICE_UNIT} -f");
196 println!(" remove: termbridge uninstall");
197 Ok(())
198}
199
200fn systemd_uninstall() -> std::io::Result<()> {
201 let _ = systemctl(&["disable", "--now", SOCKET_UNIT]);
202 let _ = systemctl(&["stop", SERVICE_UNIT]);
203 let dir = systemd_unit_dir();
204 for unit in [SOCKET_UNIT, SERVICE_UNIT] {
205 let path = dir.join(unit);
206 match std::fs::remove_file(&path) {
207 Ok(()) => println!("removed {}", path.display()),
208 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
209 Err(e) => return Err(e),
210 }
211 }
212 let _ = systemctl(&["daemon-reload"]);
213 println!("\nthe daemon is no longer started automatically; `termbridge serve` still works.");
214 Ok(())
215}
216
217fn systemd_reload() -> std::io::Result<()> {
218 let dir = systemd_unit_dir();
219 if !dir.join(SOCKET_UNIT).exists() {
220 return Err(std::io::Error::other(format!(
221 "no {SOCKET_UNIT} in {} — run `termbridge install` first, or restart \
222 your own `termbridge serve` by hand",
223 dir.display()
224 )));
225 }
226
227 // A unit edited by hand since login is a unit systemd is still holding the
228 // old text of, and the restart below would put that old text back.
229 systemctl(&["daemon-reload"])?;
230
231 // Stop before restarting the socket, for the reason `install` documents:
232 // the new listener cannot bind a port the old daemon is still holding.
233 // Ignored rather than checked — "it was not running" is the normal case
234 // here, and it is the state we wanted anyway.
235 let _ = systemctl(&["stop", SERVICE_UNIT]);
236 systemctl(&["restart", SOCKET_UNIT])?;
237 let _ = systemctl(&["import-environment", "PATH"]);
238
239 println!(
240 "daemon stopped and the socket is listening again. The next sidebar\n\
241 connection starts the current binary; your tmux sessions are untouched."
242 );
243 Ok(())
244}
245
246fn launchd_reload() -> std::io::Result<()> {
247 let path = launchd_plist_path();
248 if !path.exists() {
249 return Err(std::io::Error::other(format!(
250 "no {} — run `termbridge install` first, or restart your own \
251 `termbridge serve` by hand",
252 path.display()
253 )));
254 }
255 // `kickstart -k` kills the running agent and starts it again from the plist
256 // already loaded, which is what a reload is. No bootout/bootstrap pair: that
257 // would re-read a plist this command has no business rewriting.
258 let target = format!("gui/{}/{LAUNCHD_LABEL}", uid());
259 let status = Command::new("launchctl")
260 .args(["kickstart", "-k", &target])
261 .status()?;
262 if !status.success() {
263 return Err(std::io::Error::other(format!(
264 "launchctl kickstart -k {target} failed ({status})"
265 )));
266 }
267 println!("daemon restarted; your tmux sessions are untouched.");
268 Ok(())
269}
270
271fn systemctl(args: &[&str]) -> std::io::Result<()> {
272 let status = Command::new("systemctl")
273 .arg("--user")
274 .args(args)
275 .status()
276 .map_err(|e| {
277 std::io::Error::new(
278 e.kind(),
279 format!("could not run systemctl --user {}: {e}", args.join(" ")),
280 )
281 })?;
282 if !status.success() {
283 return Err(std::io::Error::other(format!(
284 "systemctl --user {} failed ({status})",
285 args.join(" ")
286 )));
287 }
288 Ok(())
289}
290
291// ---------------------------------------------------------------------------
292// launchd
293// ---------------------------------------------------------------------------
294
295pub const LAUNCHD_LABEL: &str = "com.termbridge.daemon";
296
297fn launchd_plist_path() -> PathBuf {
298 home().join(format!("Library/LaunchAgents/{LAUNCHD_LABEL}.plist"))
299}
300
301pub fn launchd_plist(exe: &Path, opts: &Options) -> String {
302 let mut args = vec![
303 exe.display().to_string(),
304 "serve".into(),
305 "--port".into(),
306 opts.port.to_string(),
307 ];
308 if let Some(s) = &opts.session {
309 args.push("--session".into());
310 args.push(s.clone());
311 }
312 let args: String = args
313 .iter()
314 .map(|a| format!(" <string>{}</string>\n", xml_escape(a)))
315 .collect();
316 let log = crate::paths::config_dir().join("daemon.log");
317 format!(
318 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
319 <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
320 <plist version=\"1.0\"><dict>\n\
321 \x20 <key>Label</key><string>{LAUNCHD_LABEL}</string>\n\
322 \x20 <key>ProgramArguments</key><array>\n{args} </array>\n\
323 \x20 <key>RunAtLoad</key><true/>\n\
324 \x20 <key>KeepAlive</key><true/>\n\
325 \x20 <key>StandardOutPath</key><string>{log}</string>\n\
326 \x20 <key>StandardErrorPath</key><string>{log}</string>\n\
327 </dict></plist>\n",
328 log = xml_escape(&log.display().to_string()),
329 )
330}
331
332fn launchd_install(exe: &Path, opts: &Options) -> std::io::Result<()> {
333 let path = launchd_plist_path();
334 if let Some(parent) = path.parent() {
335 std::fs::create_dir_all(parent)?;
336 }
337 write_file(&path, &launchd_plist(exe, opts))?;
338 println!("wrote {}", path.display());
339
340 let target = format!("gui/{}", uid());
341 let _ = Command::new("launchctl")
342 .args(["bootout", &format!("{target}/{LAUNCHD_LABEL}")])
343 .status();
344 let status = Command::new("launchctl")
345 .args(["bootstrap", &target])
346 .arg(&path)
347 .status()?;
348 if !status.success() {
349 return Err(std::io::Error::other(format!(
350 "launchctl bootstrap failed ({status})"
351 )));
352 }
353 println!(
354 "\nrunning at login on 127.0.0.1:{port}.\n\
355 Note: this is a plain login agent, so the daemon stays resident. On-demand\n\
356 socket activation is Linux-only for now.\n",
357 port = opts.port
358 );
359 println!(
360 " logs: tail -f {}",
361 crate::paths::config_dir().join("daemon.log").display()
362 );
363 println!(" remove: termbridge uninstall");
364 Ok(())
365}
366
367fn launchd_uninstall() -> std::io::Result<()> {
368 let path = launchd_plist_path();
369 let _ = Command::new("launchctl")
370 .args(["bootout", &format!("gui/{}/{LAUNCHD_LABEL}", uid())])
371 .status();
372 match std::fs::remove_file(&path) {
373 Ok(()) => println!("removed {}", path.display()),
374 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
375 Err(e) => return Err(e),
376 }
377 println!("\nthe daemon is no longer started at login; `termbridge serve` still works.");
378 Ok(())
379}
380
381/// Shelling out to `id -u` rather than taking a libc dependency for one number
382/// on one platform. Falls back to 501, macOS's first human user.
383fn uid() -> String {
384 Command::new("id")
385 .arg("-u")
386 .output()
387 .ok()
388 .filter(|o| o.status.success())
389 .and_then(|o| String::from_utf8(o.stdout).ok())
390 .map(|s| s.trim().to_string())
391 .filter(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()))
392 .unwrap_or_else(|| "501".into())
393}
394
395fn xml_escape(s: &str) -> String {
396 s.replace('&', "&amp;")
397 .replace('<', "&lt;")
398 .replace('>', "&gt;")
399}
400
401fn write_file(path: &Path, contents: &str) -> std::io::Result<()> {
402 let mut f = std::fs::File::create(path)?;
403 f.write_all(contents.as_bytes())?;
404 Ok(())
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 #[test]
412 fn socket_unit_is_loopback_only() {
413 let unit = socket_unit(7681);
414 assert!(unit.contains("ListenStream=127.0.0.1:7681"));
415 assert!(unit.contains("WantedBy=sockets.target"));
416 }
417
418 /// The service must ask for the passed socket. Without the flag it would
419 /// try to bind the port systemd already owns and fail on every activation.
420 #[test]
421 fn service_unit_consumes_the_activated_socket() {
422 let unit = service_unit(Path::new("/usr/local/bin/termbridge"), &Options::default());
423 assert!(unit.contains("ExecStart=/usr/local/bin/termbridge serve --systemd-socket"));
424 assert!(unit.contains(&format!("--idle-timeout {DEFAULT_IDLE}")));
425 assert!(unit.contains(&format!("Requires={SOCKET_UNIT}")));
426 assert!(!unit.contains("--port"), "the socket unit owns the port");
427 }
428
429 #[test]
430 fn service_unit_carries_a_pinned_session() {
431 let opts = Options {
432 session: Some("work".into()),
433 ..Options::default()
434 };
435 let unit = service_unit(Path::new("/bin/termbridge"), &opts);
436 assert!(unit.contains("--session work"));
437 }
438
439 #[test]
440 fn plist_escapes_paths() {
441 let opts = Options::default();
442 let plist = launchd_plist(Path::new("/tmp/a&b/termbridge"), &opts);
443 assert!(plist.contains("<string>/tmp/a&amp;b/termbridge</string>"));
444 assert!(plist.contains(LAUNCHD_LABEL));
445 }
446}