remowt-agent 0.1.0

remowt on-host agent serving fs/pty/systemd endpoints over bifrostlink
use std::env::{current_dir, temp_dir};
use std::path::Path;
use std::time::Duration;
use std::{fs, io};

use anyhow::{bail, Context as _};
use nix::libc;
use remowt_link_shared::editor::EditorEndpointsClient;
use tokio::process::Command;
use zbus::{fdo, interface, proxy, Connection};

use remowt_link_shared::BifConfig;

const BUS_NAME: &str = "lach.RemowtEditor";
const SERVICE_PATH: &str = "/lach/Editor";

pub struct EditorService {
	editor: EditorEndpointsClient<BifConfig>,
}

#[interface(name = "lach.RemowtEditor")]
impl EditorService {
	/// Attach the User's GUI to the nvim server at `socket_path` (on the remote),
	/// blocking until the user is done.
	async fn edit(&self, socket_path: String) -> fdo::Result<()> {
		self.editor
			.open_editor(socket_path)
			.await
			.map_err(|e| fdo::Error::Failed(format!("requesting editor on the User: {e}")))?
			.map_err(|e| fdo::Error::Failed(format!("editor failed: {e}")))?;
		Ok(())
	}
}

pub async fn serve(
	conn: &Connection,
	editor: EditorEndpointsClient<BifConfig>,
) -> anyhow::Result<()> {
	conn.object_server()
		.at(SERVICE_PATH, EditorService { editor })
		.await?;
	conn.request_name(BUS_NAME).await?;
	Ok(())
}

#[proxy(interface = "lach.RemowtEditor")]
trait RemowtEditor {
	async fn edit(&self, socket_path: &str) -> fdo::Result<()>;
}

pub async fn edit(path: String) -> anyhow::Result<()> {
	let path = Path::new(&path);
	let abs = if path.is_absolute() {
		path.to_path_buf()
	} else {
		current_dir()?.join(path)
	};

	let sock = temp_dir().join(format!("remowt-nvim-{}.sock", uuid::Uuid::new_v4()));
	let sock_str = sock
		.to_str()
		.context("temp socket path is not utf-8")?
		.to_owned();

	let mut child = Command::new("nvim");
	child
		.arg("--headless")
		.arg("--listen")
		.arg(&sock)
		.arg("--")
		.arg(&abs)
		.kill_on_drop(true);
	// SAFETY: only an async-signal-safe `prctl` call.
	unsafe {
		child.pre_exec(|| {
			if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL as libc::c_ulong) != 0 {
				return Err(io::Error::last_os_error());
			}
			Ok(())
		});
	}
	let mut child = child.spawn().context("spawning nvim")?;

	wait_for_socket(&sock)
		.await
		.context("nvim did not start its server")?;

	let conn = Connection::session()
		.await
		.context("connecting to the session bus (DBUS_SESSION_BUS_ADDRESS)")?;
	let proxy = RemowtEditorProxy::builder(&conn)
		.destination(BUS_NAME)?
		.path(SERVICE_PATH)?
		.build()
		.await?;
	let result = proxy.edit(&sock_str).await;

	if tokio::time::timeout(Duration::from_secs(2), child.wait())
		.await
		.is_err()
	{
		let _ = child.kill().await;
	}
	let _ = fs::remove_file(&sock);

	result?;
	Ok(())
}

/// Poll for `path` to appear (nvim creating its listen socket), up to ~10s.
async fn wait_for_socket(path: &Path) -> anyhow::Result<()> {
	for _ in 0..200 {
		if tokio::fs::try_exists(path).await.unwrap_or(false) {
			return Ok(());
		}
		tokio::time::sleep(Duration::from_millis(50)).await;
	}
	bail!("timed out waiting for {}", path.display())
}