git.delta.rocks / remowt / refs/commits / 600e6edc0e86

difftreelog

refactor merge well-known endpoints into a single crate

ymvmzxwvYaroslav Bolyukin6 days agoparent: #875f55a.patch.diff
in: trunk

36 files changed

modifiedCargo.lockdiffbeforeafterboth
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2065,10 +2065,10 @@
  "futures-util",
  "nix",
  "rand 0.10.1",
+ "remowt-endpoints",
  "remowt-link-shared",
  "remowt-plugin",
  "remowt-polkit-shared",
- "remowt-pty",
  "remowt-ui-prompt",
  "serde",
  "tempfile",
@@ -2090,6 +2090,7 @@
  "bifrostlink-ports",
  "bytes",
  "camino",
+ "remowt-endpoints",
  "remowt-link-shared",
  "russh",
  "russh-config",
@@ -2101,16 +2102,21 @@
 ]
 
 [[package]]
-name = "remowt-fs"
+name = "remowt-endpoints"
 version = "0.1.1"
 dependencies = [
+ "anyhow",
  "bifrostlink",
  "bifrostlink-macros",
  "camino",
+ "nix",
  "serde",
  "tempfile",
  "thiserror",
  "tokio",
+ "tracing",
+ "uuid",
+ "zbus",
 ]
 
 [[package]]
@@ -2120,30 +2126,11 @@
  "bifrostlink",
  "bytes",
  "camino",
- "remowt-fs",
- "remowt-pty",
- "remowt-systemd",
  "remowt-ui-prompt",
  "serde",
  "serde_json",
- "thiserror",
- "tokio",
-]
-
-[[package]]
-name = "remowt-nix-daemon"
-version = "0.1.1"
-dependencies = [
- "anyhow",
- "bifrostlink",
- "bifrostlink-macros",
- "camino",
- "remowt-client",
- "serde",
  "thiserror",
  "tokio",
- "tracing",
- "uuid",
 ]
 
 [[package]]
@@ -2171,20 +2158,6 @@
 ]
 
 [[package]]
-name = "remowt-pty"
-version = "0.1.1"
-dependencies = [
- "bifrostlink",
- "bifrostlink-macros",
- "camino",
- "nix",
- "serde",
- "thiserror",
- "tokio",
- "tracing",
-]
-
-[[package]]
 name = "remowt-ssh"
 version = "0.1.1"
 dependencies = [
@@ -2210,17 +2183,6 @@
  "tracing",
  "tracing-subscriber",
  "uuid",
-]
-
-[[package]]
-name = "remowt-systemd"
-version = "0.1.1"
-dependencies = [
- "bifrostlink",
- "bifrostlink-macros",
- "serde",
- "thiserror",
- "zbus",
 ]
 
 [[package]]
modifiedCargo.tomldiffbeforeafterboth
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,14 +9,12 @@
 repository = "https://gitlab.delta.directory/iam/remowt"
 
 [workspace.dependencies]
-remowt-fs = { version = "0.1.1", path = "crates/remowt-fs" }
-remowt-pty = { version = "0.1.1", path = "crates/remowt-pty" }
-remowt-systemd = { version = "0.1.1", path = "crates/remowt-systemd" }
 remowt-client = { version = "0.1.1", path = "crates/remowt-client" }
 remowt-polkit-shared = { version = "0.1.1", path = "crates/polkit-shared" }
 remowt-link-shared = { version = "0.1.1", path = "crates/remowt-link-shared" }
 remowt-plugin = { version = "0.1.1", path = "crates/remowt-plugin" }
-remowt-ui-prompt = { version = "0.1.1", path = "crates/ui-prompt" }
+remowt-ui-prompt = { version = "0.1.1", path = "crates/remowt-ui-prompt" }
+remowt-endpoints = { version = "0.1.1", path = "crates/remowt-endpoints" }
 
 bifrostlink = "0.2.0"
 bifrostlink-macros = "0.2.0"
modifiedcmds/polkit-dbus-helper/src/main.rsdiffbeforeafterboth
--- a/cmds/polkit-dbus-helper/src/main.rs
+++ b/cmds/polkit-dbus-helper/src/main.rs
@@ -8,10 +8,10 @@
 use nix::unistd::{setuid, Uid, User};
 use pam_client::{Context, ConversationHandler, ErrorCode, Flag};
 use remowt_polkit_shared::BackendRequest;
+use remowt_ui_prompt::dbus::DbusPrompterProxyBlocking;
+use remowt_ui_prompt::BlockingPrompter;
 use tokio::task::{block_in_place, spawn_blocking};
 use tracing::trace;
-use remowt_ui_prompt::dbus::DbusPrompterProxyBlocking;
-use remowt_ui_prompt::BlockingPrompter;
 use zbus::fdo;
 use zbus::message::Header;
 use zbus::zvariant::OwnedValue;
modifiedcmds/remowt-agent/Cargo.tomldiffbeforeafterboth
--- a/cmds/remowt-agent/Cargo.toml
+++ b/cmds/remowt-agent/Cargo.toml
@@ -17,7 +17,6 @@
 rand.workspace = true
 remowt-link-shared.workspace = true
 remowt-plugin.workspace = true
-remowt-pty.workspace = true
 serde = { workspace = true, features = ["derive"] }
 tempfile.workspace = true
 tokio = { workspace = true, features = [
@@ -36,3 +35,4 @@
 uuid = { workspace = true, features = ["v4"] }
 zbus = { workspace = true, features = ["tokio"] }
 zbus_polkit = { workspace = true, features = ["tokio"] }
+remowt-endpoints.workspace = true
modifiedcmds/remowt-agent/src/helper/protocol.rsdiffbeforeafterboth
--- a/cmds/remowt-agent/src/helper/protocol.rs
+++ b/cmds/remowt-agent/src/helper/protocol.rs
@@ -3,10 +3,10 @@
 use anyhow::bail;
 use futures::stream::Peekable;
 use futures::StreamExt as _;
+use remowt_ui_prompt::Prompter;
 use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt as _};
 use tokio::select;
 use tokio_util::codec::{FramedRead, LinesCodec};
-use remowt_ui_prompt::Prompter;
 
 pub async fn run_conversation<R, W, P>(reader: R, mut writer: W, prompt: P) -> anyhow::Result<()>
 where
modifiedcmds/remowt-agent/src/main.rsdiffbeforeafterboth
--- a/cmds/remowt-agent/src/main.rs
+++ b/cmds/remowt-agent/src/main.rs
@@ -11,8 +11,8 @@
 use bifrostlink_ports::stdio::from_stdio;
 use bifrostlink_ports::unix_socket::from_socket;
 use clap::Parser;
-use remowt_link_shared::editor::EditorEndpointsClient;
-use remowt_link_shared::{Address, BifConfig, Fs, Pty, Systemd};
+use remowt_endpoints::{fs::Fs, pty::Pty, systemd::Systemd};
+use remowt_link_shared::{editor::EditorEndpointsClient, Address, BifConfig};
 use remowt_polkit_shared::{emphasize, BackendRequest, Identity, PidDisplay};
 use remowt_ui_prompt::bifrost::PromptEndpointsClient;
 use remowt_ui_prompt::rofi::RofiPrompter;
modifiedcmds/remowt-ssh/src/main.rsdiffbeforeafterboth
--- a/cmds/remowt-ssh/src/main.rs
+++ b/cmds/remowt-ssh/src/main.rs
@@ -13,13 +13,13 @@
 use remowt_client::editor::SshEditor;
 use remowt_client::{AgentBundle, Remowt};
 use remowt_link_shared::editor::serve_editor;
+use remowt_ui_prompt::bifrost::serve_prompts;
+use remowt_ui_prompt::rofi::RofiPrompter;
+use remowt_ui_prompt::{PrependSourcePrompter, Source};
 use tokio::io::unix::AsyncFd;
 use tokio::io::{AsyncRead, ReadBuf};
 use tokio::signal::unix::{signal, SignalKind};
 use tracing::info;
-use remowt_ui_prompt::bifrost::serve_prompts;
-use remowt_ui_prompt::rofi::RofiPrompter;
-use remowt_ui_prompt::{PrependSourcePrompter, Source};
 
 #[derive(Parser)]
 struct Opts {
modifiedcrates/remowt-client/Cargo.tomldiffbeforeafterboth
--- a/crates/remowt-client/Cargo.toml
+++ b/crates/remowt-client/Cargo.toml
@@ -19,3 +19,4 @@
 tokio = { workspace = true, features = ["net", "io-util", "rt", "sync", "macros", "process"] }
 tracing.workspace = true
 uuid = { workspace = true, features = ["v4"] }
+remowt-endpoints.workspace = true
modifiedcrates/remowt-client/src/lib.rsdiffbeforeafterboth
--- a/crates/remowt-client/src/lib.rs
+++ b/crates/remowt-client/src/lib.rs
@@ -1,7 +1,7 @@
 use std::collections::HashMap;
-use std::io;
 use std::path::PathBuf;
 use std::sync::{Arc, Mutex};
+use std::{env, io};
 
 use anyhow::{anyhow, bail, ensure, Context as _, Result};
 use bifrostlink::declarative::RemoteEndpoints;
@@ -9,11 +9,13 @@
 use bifrostlink_ports::unix_socket::from_socket;
 use bytes::{Bytes, BytesMut};
 use camino::{Utf8Path, Utf8PathBuf};
+use remowt_endpoints::{
+	fs::Fs,
+	pty::{Pty, PtyClient, ShellId},
+	systemd::Systemd,
+};
 use remowt_link_shared::plugin::PluginEndpointsClient;
-use remowt_link_shared::{
-	Address, BifConfig, ElevateEndpoints, ElevateError, Elevator, Fs, Pty, PtyClient, ShellId,
-	Systemd,
-};
+use remowt_link_shared::{Address, BifConfig, ElevateEndpoints, ElevateError, Elevator};
 use russh::client::{connect, Config, Handle, Handler, Msg, Session};
 use russh::keys::agent::client::AgentClient;
 use russh::keys::agent::AgentIdentity;
@@ -220,8 +222,8 @@
 }
 
 fn find_in_path(name: &str) -> Option<std::path::PathBuf> {
-	let path = std::env::var_os("PATH")?;
-	std::env::split_paths(&path)
+	let path = env::var_os("PATH")?;
+	env::split_paths(&path)
 		.map(|dir| dir.join(name))
 		.find(|p| p.is_file())
 }
@@ -383,7 +385,7 @@
 impl Remowt {
 	pub async fn connect(host: &str, bundle: &AgentBundle) -> Result<Self> {
 		let conf = russh_config::parse_home(host)?;
-		let port = conf.host_config.port.unwrap_or(22);
+		let port = conf.host_config.port.or(conf.port).unwrap_or(22);
 		let hostname = conf
 			.host_config
 			.hostname
@@ -392,7 +394,7 @@
 		let user = conf
 			.user
 			.clone()
-			.unwrap_or_else(|| std::env::var("USER").unwrap_or_else(|_| "root".to_owned()));
+			.unwrap_or_else(|| env::var("USER").unwrap_or_else(|_| "root".to_owned()));
 
 		let subs: Subs = Arc::new(Mutex::new(HashMap::new()));
 		let mut sess = connect(
@@ -548,7 +550,7 @@
 						port_from_channel(ch)
 					}
 					Transport::Local { agent_path, .. } => {
-						let sock = std::env::temp_dir()
+						let sock = env::temp_dir()
 							.join(format!("remowt-priv-{}.sock", uuid::Uuid::new_v4()));
 						let _ = std::fs::remove_file(&sock);
 						let listener = UnixListener::bind(&sock)?;
addedcrates/remowt-endpoints/Cargo.tomldiffbeforeafterboth
--- /dev/null
+++ b/crates/remowt-endpoints/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "remowt-endpoints"
+description = "Nix daemon proxy"
+version.workspace = true
+edition = "2021"
+license.workspace = true
+
+[dependencies]
+anyhow.workspace = true
+bifrostlink.workspace = true
+bifrostlink-macros.workspace = true
+camino.workspace = true
+serde = { workspace = true }
+tempfile.workspace = true
+thiserror.workspace = true
+tokio = { workspace = true, features = ["net", "io-util", "rt", "process"] }
+tracing.workspace = true
+uuid.workspace = true
+nix = { workspace = true, features = ["process", "term"] }
+zbus.workspace = true
addedcrates/remowt-endpoints/src/fs.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/remowt-endpoints/src/fs.rs
@@ -0,0 +1,105 @@
+use std::io::ErrorKind;
+use std::str::FromStr;
+use std::sync::Mutex;
+
+use bifrostlink::declarative::endpoints;
+use bifrostlink::Config;
+use camino::Utf8PathBuf;
+use serde::{Deserialize, Serialize};
+use tempfile::TempDir;
+
+#[derive(Default)]
+pub struct Fs {
+	tempdirs: Mutex<Vec<TempDir>>,
+}
+
+impl Fs {
+	pub fn new() -> Self {
+		Self::default()
+	}
+}
+
+#[derive(Serialize, Deserialize, Debug, thiserror::Error)]
+pub enum Error {
+	#[error("file not found")]
+	NotFound,
+	#[error("file name/contents is not utf8")]
+	InvalidUtf8,
+	#[error("unknown fs error")]
+	Unknown,
+}
+
+#[endpoints(ns = 1)]
+impl Fs {
+	#[endpoints(id = 1)]
+	async fn read_file_tiny(&self, path: Utf8PathBuf) -> Result<Vec<u8>, Error> {
+		match tokio::fs::read(path).await {
+			Ok(v) => Ok(v),
+			Err(e) if e.kind() == ErrorKind::NotFound => Err(Error::NotFound),
+			_ => Err(Error::Unknown),
+		}
+	}
+	#[endpoints(id = 2)]
+	async fn file_exists(&self, path: Utf8PathBuf) -> bool {
+		tokio::fs::try_exists(path).await.unwrap_or(false)
+	}
+	#[endpoints(id = 3)]
+	async fn read_dir_raw(&self, path: Utf8PathBuf) -> Result<Vec<Utf8PathBuf>, Error> {
+		let mut dir = match tokio::fs::read_dir(path).await {
+			Ok(dir) => dir,
+			Err(e) if e.kind() == ErrorKind::NotFound => return Err(Error::NotFound),
+			Err(_) => return Err(Error::Unknown),
+		};
+		let mut out = Vec::new();
+		while let Ok(Some(entry)) = dir.next_entry().await {
+			let name = Utf8PathBuf::try_from(entry.file_name()).map_err(|_| Error::InvalidUtf8)?;
+			out.push(name);
+		}
+		Ok(out)
+	}
+	#[endpoints(id = 4)]
+	async fn mktemp_dir_raw(&self) -> Result<Utf8PathBuf, Error> {
+		let dir = tempfile::Builder::new()
+			.prefix("remowt.")
+			.tempdir()
+			.map_err(|_| Error::Unknown)?;
+		let mut tempdirs = self.tempdirs.lock().expect("not poisoned");
+		let path = Utf8PathBuf::try_from(dir.path().to_owned()).map_err(|_| Error::InvalidUtf8);
+		tempdirs.push(dir);
+		path
+	}
+	#[endpoints(id = 5)]
+	async fn rm_file(&self, path: Utf8PathBuf) -> Result<(), Error> {
+		match tokio::fs::remove_file(path).await {
+			Ok(()) => Ok(()),
+			Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
+			Err(_) => Err(Error::Unknown),
+		}
+	}
+}
+
+impl<C: Config> FsClient<C> {
+	pub async fn read_file_text(&self, path: impl Into<Utf8PathBuf>) -> Result<String, Error> {
+		let v = self
+			.read_file_tiny(path.into())
+			.await
+			.map_err(|_| Error::Unknown)?;
+		let v = v?;
+		String::from_utf8(v).map_err(|_| Error::InvalidUtf8)
+	}
+	pub async fn read_file_value<T: FromStr>(
+		&self,
+		path: impl Into<Utf8PathBuf>,
+	) -> Result<Result<T, T::Err>, Error> {
+		let text = self.read_file_text(path).await?;
+		Ok(T::from_str(&text))
+	}
+	pub async fn mktemp_dir(&self) -> Result<Utf8PathBuf, Error> {
+		self.mktemp_dir_raw().await.map_err(|_| Error::Unknown)?
+	}
+	pub async fn read_dir(&self, path: impl Into<Utf8PathBuf>) -> Result<Vec<Utf8PathBuf>, Error> {
+		self.read_dir_raw(path.into())
+			.await
+			.map_err(|_| Error::Unknown)?
+	}
+}
addedcrates/remowt-endpoints/src/lib.rsdiffbeforeafterboth

no changes

addedcrates/remowt-endpoints/src/nix_daemon.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/remowt-endpoints/src/nix_daemon.rs
@@ -0,0 +1,65 @@
+use std::process::Stdio;
+
+use bifrostlink::declarative::endpoints;
+use bifrostlink::Config;
+use serde::{Deserialize, Serialize};
+use std::result::Result;
+use tokio::process::Command;
+
+pub const NIX_DAEMON_SOCKET: &str = "/nix/var/nix/daemon-socket/socket";
+
+pub struct NixDaemon;
+
+#[derive(Serialize, Deserialize, Debug, thiserror::Error)]
+pub enum Error {
+	#[error("nix daemon unavailable: {0}")]
+	DaemonUnavailable(String),
+	#[error("tunnel socket unavailable: {0}")]
+	Tunnel(String),
+}
+
+#[endpoints(ns = 4)]
+impl NixDaemon {
+	#[endpoints(id = 1)]
+	async fn connect_daemon(&self, socket: String) -> Result<(), Error> {
+		let mut daemon = tokio::net::UnixStream::connect(NIX_DAEMON_SOCKET)
+			.await
+			.map_err(|e| Error::DaemonUnavailable(e.to_string()))?;
+		let mut tunnel = tokio::net::UnixStream::connect(&socket)
+			.await
+			.map_err(|e| Error::Tunnel(e.to_string()))?;
+		tokio::spawn(async move {
+			if let Err(e) = tokio::io::copy_bidirectional(&mut daemon, &mut tunnel).await {
+				tracing::debug!("nix daemon tunnel ended: {e}");
+			}
+		});
+		Ok(())
+	}
+
+	#[endpoints(id = 2)]
+	async fn serve_store(&self, store: String, socket: String) -> Result<(), Error> {
+		let mut child = Command::new("nix-daemon")
+			.arg("--stdio")
+			.arg("--store")
+			.arg(&store)
+			.stdin(Stdio::piped())
+			.stdout(Stdio::piped())
+			.spawn()
+			.map_err(|e| Error::DaemonUnavailable(e.to_string()))?;
+		let tunnel = tokio::net::UnixStream::connect(&socket)
+			.await
+			.map_err(|e| Error::Tunnel(e.to_string()))?;
+		let mut stdin = child.stdin.take().expect("piped");
+		let mut stdout = child.stdout.take().expect("piped");
+		tokio::spawn(async move {
+			let mut tunnel = tunnel;
+			let (mut tr, mut tw) = tunnel.split();
+			let _ = tokio::join!(
+				tokio::io::copy(&mut tr, &mut stdin),
+				tokio::io::copy(&mut stdout, &mut tw),
+			);
+			let _ = child.wait().await;
+		});
+		Ok(())
+	}
+}
addedcrates/remowt-endpoints/src/pty.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/remowt-endpoints/src/pty.rs
@@ -0,0 +1,256 @@
+use std::collections::HashMap;
+use std::io;
+use std::os::fd::{AsRawFd, OwnedFd};
+use std::pin::Pin;
+use std::process::Stdio;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::{Arc, Mutex};
+use std::task::{Context, Poll};
+
+use bifrostlink::declarative::endpoints;
+use bifrostlink::Config;
+use camino::Utf8PathBuf;
+use nix::libc;
+use nix::pty::{openpty, OpenptyResult, Winsize};
+use serde::{Deserialize, Serialize};
+use tokio::io::unix::AsyncFd;
+use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
+use tokio::net::UnixStream;
+use tracing::{info, warn};
+
+pub type ShellId = u64;
+
+#[derive(Serialize, Deserialize, Debug, thiserror::Error)]
+pub enum Error {
+	#[error("openpty failed: {0}")]
+	Open(String),
+	#[error("failed to spawn shell: {0}")]
+	Spawn(String),
+	#[error("failed to connect to forwarded socket: {0}")]
+	Connect(String),
+	#[error("no shell with that id")]
+	NoSuchShell,
+	#[error("resize failed: {0}")]
+	Resize(String),
+	#[error("io error: {0}")]
+	Io(String),
+}
+
+impl From<io::Error> for Error {
+	fn from(e: io::Error) -> Self {
+		Error::Io(e.to_string())
+	}
+}
+
+#[derive(Clone, Default)]
+pub struct Pty {
+	shells: Arc<Mutex<HashMap<ShellId, OwnedFd>>>,
+	next_id: Arc<AtomicU64>,
+}
+
+impl Pty {
+	pub fn new() -> Self {
+		Self::default()
+	}
+}
+
+#[endpoints(ns = 7)]
+impl Pty {
+	#[endpoints(id = 1)]
+	async fn open_shell(
+		&self,
+		socket_path: Utf8PathBuf,
+		term: String,
+		cols: u16,
+		rows: u16,
+	) -> Result<ShellId, Error> {
+		let ws = Winsize {
+			ws_row: rows,
+			ws_col: cols,
+			ws_xpixel: 0,
+			ws_ypixel: 0,
+		};
+		let OpenptyResult { master, slave } =
+			openpty(Some(&ws), None).map_err(|e| Error::Open(e.to_string()))?;
+
+		let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_owned());
+
+		let slave_in = slave.try_clone()?;
+		let slave_out = slave.try_clone()?;
+		let slave_err = slave;
+
+		let mut cmd = tokio::process::Command::new(&shell);
+		cmd.env("TERM", &term);
+		if let Ok(home) = std::env::var("HOME") {
+			cmd.current_dir(home);
+		}
+		cmd.stdin(Stdio::from(slave_in));
+		cmd.stdout(Stdio::from(slave_out));
+		cmd.stderr(Stdio::from(slave_err));
+		// SAFETY: only async-signal-safe calls (setsid, ioctl) before exec.
+		unsafe {
+			cmd.pre_exec(|| {
+				nix::unistd::setsid().map_err(|e| io::Error::from_raw_os_error(e as i32))?;
+				if libc::ioctl(0, libc::TIOCSCTTY as _, 0) < 0 {
+					return Err(io::Error::last_os_error());
+				}
+				Ok(())
+			});
+		}
+
+		let mut child = cmd.spawn().map_err(|e| Error::Spawn(e.to_string()))?;
+
+		let resize_fd = master.try_clone()?;
+		let id = self.next_id.fetch_add(1, Ordering::Relaxed);
+		self.shells
+			.lock()
+			.expect("not poisoned")
+			.insert(id, resize_fd);
+
+		let sock = match UnixStream::connect(&socket_path).await {
+			Ok(s) => s,
+			Err(e) => {
+				self.shells.lock().expect("not poisoned").remove(&id);
+				let _ = child.kill().await;
+				return Err(Error::Connect(e.to_string()));
+			}
+		};
+		let pty = AsyncPty::new(master)?;
+
+		info!(id, shell, "shell opened");
+		let shells = self.shells.clone();
+		tokio::spawn(async move {
+			let mut pty = pty;
+			let mut sock = sock;
+			if let Err(e) = tokio::io::copy_bidirectional(&mut pty, &mut sock).await {
+				warn!(id, "shell pump ended: {e}");
+			}
+			let _ = child.kill().await;
+			shells.lock().expect("not poisoned").remove(&id);
+			info!(id, "shell closed");
+		});
+
+		Ok(id)
+	}
+
+	#[endpoints(id = 2)]
+	async fn resize(&self, id: ShellId, cols: u16, rows: u16) -> Result<(), Error> {
+		let ws = libc::winsize {
+			ws_row: rows,
+			ws_col: cols,
+			ws_xpixel: 0,
+			ws_ypixel: 0,
+		};
+		let shells = self.shells.lock().expect("not poisoned");
+		let fd = shells.get(&id).ok_or(Error::NoSuchShell)?;
+		// SAFETY: `fd` is a live PTY master
+		let rc = unsafe { libc::ioctl(fd.as_raw_fd(), libc::TIOCSWINSZ as _, &ws) };
+		if rc < 0 {
+			return Err(Error::Resize(io::Error::last_os_error().to_string()));
+		}
+		Ok(())
+	}
+}
+
+struct AsyncPty {
+	fd: AsyncFd<OwnedFd>,
+}
+
+impl AsyncPty {
+	fn new(fd: OwnedFd) -> io::Result<Self> {
+		let raw = fd.as_raw_fd();
+		// SAFETY: standard F_GETFL/F_SETFL round-trip on a valid fd.
+		unsafe {
+			let flags = libc::fcntl(raw, libc::F_GETFL);
+			if flags < 0 {
+				return Err(io::Error::last_os_error());
+			}
+			if libc::fcntl(raw, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 {
+				return Err(io::Error::last_os_error());
+			}
+		}
+		Ok(Self {
+			fd: AsyncFd::new(fd)?,
+		})
+	}
+}
+
+impl AsyncRead for AsyncPty {
+	fn poll_read(
+		self: Pin<&mut Self>,
+		cx: &mut Context<'_>,
+		buf: &mut ReadBuf<'_>,
+	) -> Poll<io::Result<()>> {
+		let this = self.get_mut();
+		loop {
+			let mut guard = match this.fd.poll_read_ready(cx) {
+				Poll::Ready(Ok(g)) => g,
+				Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
+				Poll::Pending => return Poll::Pending,
+			};
+			let unfilled = buf.initialize_unfilled();
+			let res = guard.try_io(|inner| {
+				let fd = inner.get_ref().as_raw_fd();
+				// SAFETY: writing into `unfilled`'s own backing storage.
+				let n = unsafe { libc::read(fd, unfilled.as_mut_ptr().cast(), unfilled.len()) };
+				if n < 0 {
+					let err = io::Error::last_os_error();
+					if err.raw_os_error() == Some(libc::EIO) {
+						Ok(0)
+					} else {
+						Err(err)
+					}
+				} else {
+					Ok(n as usize)
+				}
+			});
+			match res {
+				Ok(Ok(n)) => {
+					buf.advance(n);
+					return Poll::Ready(Ok(()));
+				}
+				Ok(Err(e)) => return Poll::Ready(Err(e)),
+				Err(_would_block) => continue,
+			}
+		}
+	}
+}
+
+impl AsyncWrite for AsyncPty {
+	fn poll_write(
+		self: Pin<&mut Self>,
+		cx: &mut Context<'_>,
+		buf: &[u8],
+	) -> Poll<io::Result<usize>> {
+		let this = self.get_mut();
+		loop {
+			let mut guard = match this.fd.poll_write_ready(cx) {
+				Poll::Ready(Ok(g)) => g,
+				Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
+				Poll::Pending => return Poll::Pending,
+			};
+			let res = guard.try_io(|inner| {
+				let fd = inner.get_ref().as_raw_fd();
+				// SAFETY: reading from `buf` for `buf.len()` bytes.
+				let n = unsafe { libc::write(fd, buf.as_ptr().cast(), buf.len()) };
+				if n < 0 {
+					Err(io::Error::last_os_error())
+				} else {
+					Ok(n as usize)
+				}
+			});
+			match res {
+				Ok(r) => return Poll::Ready(r),
+				Err(_would_block) => continue,
+			}
+		}
+	}
+
+	fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
+		Poll::Ready(Ok(()))
+	}
+
+	fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
+		Poll::Ready(Ok(()))
+	}
+}
addedcrates/remowt-endpoints/src/systemd.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/remowt-endpoints/src/systemd.rs
@@ -0,0 +1,54 @@
+use bifrostlink::declarative::endpoints;
+use bifrostlink::Config;
+use serde::{Deserialize, Serialize};
+use zbus::proxy;
+use zbus::zvariant::OwnedObjectPath;
+
+pub struct Systemd;
+
+#[derive(Serialize, Deserialize, Debug, thiserror::Error)]
+pub enum Error {
+	#[error("systemd request failed: {0}")]
+	Failed(String),
+}
+
+#[proxy(
+	interface = "org.freedesktop.systemd1.Manager",
+	default_service = "org.freedesktop.systemd1",
+	default_path = "/org/freedesktop/systemd1"
+)]
+trait Manager {
+	fn start_unit(&self, name: &str, mode: &str) -> zbus::Result<OwnedObjectPath>;
+	fn stop_unit(&self, name: &str, mode: &str) -> zbus::Result<OwnedObjectPath>;
+}
+
+async fn manager() -> Result<ManagerProxy<'static>, Error> {
+	let conn = zbus::Connection::system()
+		.await
+		.map_err(|e| Error::Failed(e.to_string()))?;
+	ManagerProxy::new(&conn)
+		.await
+		.map_err(|e| Error::Failed(e.to_string()))
+}
+
+#[endpoints(ns = 5)]
+impl Systemd {
+	#[endpoints(id = 1)]
+	async fn start(&self, unit: String) -> Result<(), Error> {
+		manager()
+			.await?
+			.start_unit(&unit, "replace")
+			.await
+			.map_err(|e| Error::Failed(e.to_string()))?;
+		Ok(())
+	}
+	#[endpoints(id = 2)]
+	async fn stop(&self, unit: String) -> Result<(), Error> {
+		manager()
+			.await?
+			.stop_unit(&unit, "replace")
+			.await
+			.map_err(|e| Error::Failed(e.to_string()))?;
+		Ok(())
+	}
+}
deletedcrates/remowt-fs/Cargo.tomldiffbeforeafterboth
--- a/crates/remowt-fs/Cargo.toml
+++ /dev/null
@@ -1,15 +0,0 @@
-[package]
-name = "remowt-fs"
-description = "Filesystem endpoint for remowt/bifrostlink"
-version.workspace = true
-edition = "2021"
-license.workspace = true
-
-[dependencies]
-bifrostlink.workspace = true
-bifrostlink-macros.workspace = true
-camino = { workspace = true, features = ["serde1"] }
-serde = { workspace = true, features = ["derive"] }
-tempfile.workspace = true
-thiserror.workspace = true
-tokio = { workspace = true, features = ["fs"] }
deletedcrates/remowt-fs/src/lib.rsdiffbeforeafterboth
--- a/crates/remowt-fs/src/lib.rs
+++ /dev/null
@@ -1,105 +0,0 @@
-use std::io::ErrorKind;
-use std::str::FromStr;
-use std::sync::Mutex;
-
-use bifrostlink::declarative::endpoints;
-use bifrostlink::Config;
-use camino::Utf8PathBuf;
-use serde::{Deserialize, Serialize};
-use tempfile::TempDir;
-
-#[derive(Default)]
-pub struct Fs {
-	tempdirs: Mutex<Vec<TempDir>>,
-}
-
-impl Fs {
-	pub fn new() -> Self {
-		Self::default()
-	}
-}
-
-#[derive(Serialize, Deserialize, Debug, thiserror::Error)]
-pub enum Error {
-	#[error("file not found")]
-	NotFound,
-	#[error("file name/contents is not utf8")]
-	InvalidUtf8,
-	#[error("unknown fs error")]
-	Unknown,
-}
-
-#[endpoints(ns = 1)]
-impl Fs {
-	#[endpoints(id = 1)]
-	async fn read_file_tiny(&self, path: Utf8PathBuf) -> Result<Vec<u8>, Error> {
-		match tokio::fs::read(path).await {
-			Ok(v) => Ok(v),
-			Err(e) if e.kind() == ErrorKind::NotFound => Err(Error::NotFound),
-			_ => Err(Error::Unknown),
-		}
-	}
-	#[endpoints(id = 2)]
-	async fn file_exists(&self, path: Utf8PathBuf) -> bool {
-		tokio::fs::try_exists(path).await.unwrap_or(false)
-	}
-	#[endpoints(id = 3)]
-	async fn read_dir_raw(&self, path: Utf8PathBuf) -> Result<Vec<Utf8PathBuf>, Error> {
-		let mut dir = match tokio::fs::read_dir(path).await {
-			Ok(dir) => dir,
-			Err(e) if e.kind() == ErrorKind::NotFound => return Err(Error::NotFound),
-			Err(_) => return Err(Error::Unknown),
-		};
-		let mut out = Vec::new();
-		while let Ok(Some(entry)) = dir.next_entry().await {
-			let name = Utf8PathBuf::try_from(entry.file_name()).map_err(|_| Error::InvalidUtf8)?;
-			out.push(name);
-		}
-		Ok(out)
-	}
-	#[endpoints(id = 4)]
-	async fn mktemp_dir_raw(&self) -> Result<Utf8PathBuf, Error> {
-		let dir = tempfile::Builder::new()
-			.prefix("remowt.")
-			.tempdir()
-			.map_err(|_| Error::Unknown)?;
-		let mut tempdirs = self.tempdirs.lock().expect("not poisoned");
-		let path = Utf8PathBuf::try_from(dir.path().to_owned()).map_err(|_| Error::InvalidUtf8);
-		tempdirs.push(dir);
-		path
-	}
-	#[endpoints(id = 5)]
-	async fn rm_file(&self, path: Utf8PathBuf) -> Result<(), Error> {
-		match tokio::fs::remove_file(path).await {
-			Ok(()) => Ok(()),
-			Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
-			Err(_) => Err(Error::Unknown),
-		}
-	}
-}
-
-impl<C: Config> FsClient<C> {
-	pub async fn read_file_text(&self, path: impl Into<Utf8PathBuf>) -> Result<String, Error> {
-		let v = self
-			.read_file_tiny(path.into())
-			.await
-			.map_err(|_| Error::Unknown)?;
-		let v = v?;
-		String::from_utf8(v).map_err(|_| Error::InvalidUtf8)
-	}
-	pub async fn read_file_value<T: FromStr>(
-		&self,
-		path: impl Into<Utf8PathBuf>,
-	) -> Result<Result<T, T::Err>, Error> {
-		let text = self.read_file_text(path).await?;
-		Ok(T::from_str(&text))
-	}
-	pub async fn mktemp_dir(&self) -> Result<Utf8PathBuf, Error> {
-		self.mktemp_dir_raw().await.map_err(|_| Error::Unknown)?
-	}
-	pub async fn read_dir(&self, path: impl Into<Utf8PathBuf>) -> Result<Vec<Utf8PathBuf>, Error> {
-		self.read_dir_raw(path.into())
-			.await
-			.map_err(|_| Error::Unknown)?
-	}
-}
modifiedcrates/remowt-link-shared/Cargo.tomldiffbeforeafterboth
--- a/crates/remowt-link-shared/Cargo.toml
+++ b/crates/remowt-link-shared/Cargo.toml
@@ -12,8 +12,5 @@
 serde_json.workspace = true
 thiserror.workspace = true
 tokio = { workspace = true, features = ["fs"] }
-remowt-fs.workspace = true
-remowt-systemd.workspace = true
 remowt-ui-prompt.workspace = true
 camino = { workspace = true, features = ["serde1"] }
-remowt-pty.workspace = true
modifiedcrates/remowt-link-shared/src/lib.rsdiffbeforeafterboth
--- a/crates/remowt-link-shared/src/lib.rs
+++ b/crates/remowt-link-shared/src/lib.rs
@@ -21,10 +21,6 @@
 
 pub mod plugin;
 
-pub use remowt_fs::{Error as FsError, Fs, FsClient};
-pub use remowt_pty::{Error as PtyError, Pty, PtyClient, ShellId};
-pub use remowt_systemd::{Error as SystemdError, Systemd, SystemdClient};
-
 #[derive(Serialize, Deserialize, Debug, thiserror::Error)]
 pub enum ElevateError {
 	#[error("elevation failed: {0}")]
deletedcrates/remowt-nix-daemon/Cargo.tomldiffbeforeafterboth
--- a/crates/remowt-nix-daemon/Cargo.toml
+++ /dev/null
@@ -1,18 +0,0 @@
-[package]
-name = "remowt-nix-daemon"
-description = "Nix daemon proxy"
-version.workspace = true
-edition = "2021"
-license.workspace = true
-
-[dependencies]
-anyhow.workspace = true
-bifrostlink.workspace = true
-bifrostlink-macros.workspace = true
-camino.workspace = true
-remowt-client.workspace = true
-serde = { workspace = true }
-thiserror.workspace = true
-tokio = { workspace = true, features = ["net", "io-util", "rt", "process"] }
-tracing.workspace = true
-uuid.workspace = true
deletedcrates/remowt-nix-daemon/src/lib.rsdiffbeforeafterboth
--- a/crates/remowt-nix-daemon/src/lib.rs
+++ /dev/null
@@ -1,65 +0,0 @@
-use std::process::Stdio;
-
-use bifrostlink::declarative::endpoints;
-use bifrostlink::Config;
-use serde::{Deserialize, Serialize};
-use std::result::Result;
-use tokio::process::Command;
-
-pub const NIX_DAEMON_SOCKET: &str = "/nix/var/nix/daemon-socket/socket";
-
-pub struct NixDaemon;
-
-#[derive(Serialize, Deserialize, Debug, thiserror::Error)]
-pub enum Error {
-	#[error("nix daemon unavailable: {0}")]
-	DaemonUnavailable(String),
-	#[error("tunnel socket unavailable: {0}")]
-	Tunnel(String),
-}
-
-#[endpoints(ns = 4)]
-impl NixDaemon {
-	#[endpoints(id = 1)]
-	async fn connect_daemon(&self, socket: String) -> Result<(), Error> {
-		let mut daemon = tokio::net::UnixStream::connect(NIX_DAEMON_SOCKET)
-			.await
-			.map_err(|e| Error::DaemonUnavailable(e.to_string()))?;
-		let mut tunnel = tokio::net::UnixStream::connect(&socket)
-			.await
-			.map_err(|e| Error::Tunnel(e.to_string()))?;
-		tokio::spawn(async move {
-			if let Err(e) = tokio::io::copy_bidirectional(&mut daemon, &mut tunnel).await {
-				tracing::debug!("nix daemon tunnel ended: {e}");
-			}
-		});
-		Ok(())
-	}
-
-	#[endpoints(id = 2)]
-	async fn serve_store(&self, store: String, socket: String) -> Result<(), Error> {
-		let mut child = Command::new("nix-daemon")
-			.arg("--stdio")
-			.arg("--store")
-			.arg(&store)
-			.stdin(Stdio::piped())
-			.stdout(Stdio::piped())
-			.spawn()
-			.map_err(|e| Error::DaemonUnavailable(e.to_string()))?;
-		let tunnel = tokio::net::UnixStream::connect(&socket)
-			.await
-			.map_err(|e| Error::Tunnel(e.to_string()))?;
-		let mut stdin = child.stdin.take().expect("piped");
-		let mut stdout = child.stdout.take().expect("piped");
-		tokio::spawn(async move {
-			let mut tunnel = tunnel;
-			let (mut tr, mut tw) = tunnel.split();
-			let _ = tokio::join!(
-				tokio::io::copy(&mut tr, &mut stdin),
-				tokio::io::copy(&mut stdout, &mut tw),
-			);
-			let _ = child.wait().await;
-		});
-		Ok(())
-	}
-}
modifiedcrates/remowt-plugin/src/lib.rsdiffbeforeafterboth
--- a/crates/remowt-plugin/src/lib.rs
+++ b/crates/remowt-plugin/src/lib.rs
@@ -8,7 +8,7 @@
 pub mod host;
 
 pub use bifrostlink;
-pub use remowt_link_shared::{self, Address, BifConfig, Fs, Pty, Systemd};
+pub use remowt_link_shared::{self, Address, BifConfig};
 
 pub fn plugin_index() -> Result<u16> {
 	let arg = std::env::args()
deletedcrates/remowt-pty/Cargo.tomldiffbeforeafterboth
--- a/crates/remowt-pty/Cargo.toml
+++ /dev/null
@@ -1,23 +0,0 @@
-[package]
-name = "remowt-pty"
-description = "PTY/shell endpoint for remowt"
-version.workspace = true
-edition = "2021"
-license.workspace = true
-
-[dependencies]
-bifrostlink.workspace = true
-bifrostlink-macros.workspace = true
-camino = { workspace = true, features = ["serde1"] }
-nix = { workspace = true, features = ["process", "term"] }
-serde = { workspace = true, features = ["derive"] }
-thiserror.workspace = true
-tokio = { workspace = true, features = [
-	"net",
-	"io-util",
-	"rt",
-	"macros",
-	"process",
-	"sync",
-] }
-tracing.workspace = true
deletedcrates/remowt-pty/src/lib.rsdiffbeforeafterboth
--- a/crates/remowt-pty/src/lib.rs
+++ /dev/null
@@ -1,256 +0,0 @@
-use std::collections::HashMap;
-use std::io;
-use std::os::fd::{AsRawFd, OwnedFd};
-use std::pin::Pin;
-use std::process::Stdio;
-use std::sync::atomic::{AtomicU64, Ordering};
-use std::sync::{Arc, Mutex};
-use std::task::{Context, Poll};
-
-use bifrostlink::declarative::endpoints;
-use bifrostlink::Config;
-use camino::Utf8PathBuf;
-use nix::libc;
-use nix::pty::{openpty, OpenptyResult, Winsize};
-use serde::{Deserialize, Serialize};
-use tokio::io::unix::AsyncFd;
-use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
-use tokio::net::UnixStream;
-use tracing::{info, warn};
-
-pub type ShellId = u64;
-
-#[derive(Serialize, Deserialize, Debug, thiserror::Error)]
-pub enum Error {
-	#[error("openpty failed: {0}")]
-	Open(String),
-	#[error("failed to spawn shell: {0}")]
-	Spawn(String),
-	#[error("failed to connect to forwarded socket: {0}")]
-	Connect(String),
-	#[error("no shell with that id")]
-	NoSuchShell,
-	#[error("resize failed: {0}")]
-	Resize(String),
-	#[error("io error: {0}")]
-	Io(String),
-}
-
-impl From<io::Error> for Error {
-	fn from(e: io::Error) -> Self {
-		Error::Io(e.to_string())
-	}
-}
-
-#[derive(Clone, Default)]
-pub struct Pty {
-	shells: Arc<Mutex<HashMap<ShellId, OwnedFd>>>,
-	next_id: Arc<AtomicU64>,
-}
-
-impl Pty {
-	pub fn new() -> Self {
-		Self::default()
-	}
-}
-
-#[endpoints(ns = 7)]
-impl Pty {
-	#[endpoints(id = 1)]
-	async fn open_shell(
-		&self,
-		socket_path: Utf8PathBuf,
-		term: String,
-		cols: u16,
-		rows: u16,
-	) -> Result<ShellId, Error> {
-		let ws = Winsize {
-			ws_row: rows,
-			ws_col: cols,
-			ws_xpixel: 0,
-			ws_ypixel: 0,
-		};
-		let OpenptyResult { master, slave } =
-			openpty(Some(&ws), None).map_err(|e| Error::Open(e.to_string()))?;
-
-		let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_owned());
-
-		let slave_in = slave.try_clone()?;
-		let slave_out = slave.try_clone()?;
-		let slave_err = slave;
-
-		let mut cmd = tokio::process::Command::new(&shell);
-		cmd.env("TERM", &term);
-		if let Ok(home) = std::env::var("HOME") {
-			cmd.current_dir(home);
-		}
-		cmd.stdin(Stdio::from(slave_in));
-		cmd.stdout(Stdio::from(slave_out));
-		cmd.stderr(Stdio::from(slave_err));
-		// SAFETY: only async-signal-safe calls (setsid, ioctl) before exec.
-		unsafe {
-			cmd.pre_exec(|| {
-				nix::unistd::setsid().map_err(|e| io::Error::from_raw_os_error(e as i32))?;
-				if libc::ioctl(0, libc::TIOCSCTTY as _, 0) < 0 {
-					return Err(io::Error::last_os_error());
-				}
-				Ok(())
-			});
-		}
-
-		let mut child = cmd.spawn().map_err(|e| Error::Spawn(e.to_string()))?;
-
-		let resize_fd = master.try_clone()?;
-		let id = self.next_id.fetch_add(1, Ordering::Relaxed);
-		self.shells
-			.lock()
-			.expect("not poisoned")
-			.insert(id, resize_fd);
-
-		let sock = match UnixStream::connect(&socket_path).await {
-			Ok(s) => s,
-			Err(e) => {
-				self.shells.lock().expect("not poisoned").remove(&id);
-				let _ = child.kill().await;
-				return Err(Error::Connect(e.to_string()));
-			}
-		};
-		let pty = AsyncPty::new(master)?;
-
-		info!(id, shell, "shell opened");
-		let shells = self.shells.clone();
-		tokio::spawn(async move {
-			let mut pty = pty;
-			let mut sock = sock;
-			if let Err(e) = tokio::io::copy_bidirectional(&mut pty, &mut sock).await {
-				warn!(id, "shell pump ended: {e}");
-			}
-			let _ = child.kill().await;
-			shells.lock().expect("not poisoned").remove(&id);
-			info!(id, "shell closed");
-		});
-
-		Ok(id)
-	}
-
-	#[endpoints(id = 2)]
-	async fn resize(&self, id: ShellId, cols: u16, rows: u16) -> Result<(), Error> {
-		let ws = libc::winsize {
-			ws_row: rows,
-			ws_col: cols,
-			ws_xpixel: 0,
-			ws_ypixel: 0,
-		};
-		let shells = self.shells.lock().expect("not poisoned");
-		let fd = shells.get(&id).ok_or(Error::NoSuchShell)?;
-		// SAFETY: `fd` is a live PTY master
-		let rc = unsafe { libc::ioctl(fd.as_raw_fd(), libc::TIOCSWINSZ as _, &ws) };
-		if rc < 0 {
-			return Err(Error::Resize(io::Error::last_os_error().to_string()));
-		}
-		Ok(())
-	}
-}
-
-struct AsyncPty {
-	fd: AsyncFd<OwnedFd>,
-}
-
-impl AsyncPty {
-	fn new(fd: OwnedFd) -> io::Result<Self> {
-		let raw = fd.as_raw_fd();
-		// SAFETY: standard F_GETFL/F_SETFL round-trip on a valid fd.
-		unsafe {
-			let flags = libc::fcntl(raw, libc::F_GETFL);
-			if flags < 0 {
-				return Err(io::Error::last_os_error());
-			}
-			if libc::fcntl(raw, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 {
-				return Err(io::Error::last_os_error());
-			}
-		}
-		Ok(Self {
-			fd: AsyncFd::new(fd)?,
-		})
-	}
-}
-
-impl AsyncRead for AsyncPty {
-	fn poll_read(
-		self: Pin<&mut Self>,
-		cx: &mut Context<'_>,
-		buf: &mut ReadBuf<'_>,
-	) -> Poll<io::Result<()>> {
-		let this = self.get_mut();
-		loop {
-			let mut guard = match this.fd.poll_read_ready(cx) {
-				Poll::Ready(Ok(g)) => g,
-				Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
-				Poll::Pending => return Poll::Pending,
-			};
-			let unfilled = buf.initialize_unfilled();
-			let res = guard.try_io(|inner| {
-				let fd = inner.get_ref().as_raw_fd();
-				// SAFETY: writing into `unfilled`'s own backing storage.
-				let n = unsafe { libc::read(fd, unfilled.as_mut_ptr().cast(), unfilled.len()) };
-				if n < 0 {
-					let err = io::Error::last_os_error();
-					if err.raw_os_error() == Some(libc::EIO) {
-						Ok(0)
-					} else {
-						Err(err)
-					}
-				} else {
-					Ok(n as usize)
-				}
-			});
-			match res {
-				Ok(Ok(n)) => {
-					buf.advance(n);
-					return Poll::Ready(Ok(()));
-				}
-				Ok(Err(e)) => return Poll::Ready(Err(e)),
-				Err(_would_block) => continue,
-			}
-		}
-	}
-}
-
-impl AsyncWrite for AsyncPty {
-	fn poll_write(
-		self: Pin<&mut Self>,
-		cx: &mut Context<'_>,
-		buf: &[u8],
-	) -> Poll<io::Result<usize>> {
-		let this = self.get_mut();
-		loop {
-			let mut guard = match this.fd.poll_write_ready(cx) {
-				Poll::Ready(Ok(g)) => g,
-				Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
-				Poll::Pending => return Poll::Pending,
-			};
-			let res = guard.try_io(|inner| {
-				let fd = inner.get_ref().as_raw_fd();
-				// SAFETY: reading from `buf` for `buf.len()` bytes.
-				let n = unsafe { libc::write(fd, buf.as_ptr().cast(), buf.len()) };
-				if n < 0 {
-					Err(io::Error::last_os_error())
-				} else {
-					Ok(n as usize)
-				}
-			});
-			match res {
-				Ok(r) => return Poll::Ready(r),
-				Err(_would_block) => continue,
-			}
-		}
-	}
-
-	fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
-		Poll::Ready(Ok(()))
-	}
-
-	fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
-		Poll::Ready(Ok(()))
-	}
-}
deletedcrates/remowt-systemd/Cargo.tomldiffbeforeafterboth
--- a/crates/remowt-systemd/Cargo.toml
+++ /dev/null
@@ -1,13 +0,0 @@
-[package]
-name = "remowt-systemd"
-description = "systemd control endpoint for remowt/bifrostlink (over D-Bus)"
-version.workspace = true
-edition = "2021"
-license.workspace = true
-
-[dependencies]
-bifrostlink.workspace = true
-bifrostlink-macros.workspace = true
-serde = { workspace = true, features = ["derive"] }
-thiserror.workspace = true
-zbus = { workspace = true, features = ["tokio"] }
deletedcrates/remowt-systemd/src/lib.rsdiffbeforeafterboth
--- a/crates/remowt-systemd/src/lib.rs
+++ /dev/null
@@ -1,54 +0,0 @@
-use bifrostlink::declarative::endpoints;
-use bifrostlink::Config;
-use serde::{Deserialize, Serialize};
-use zbus::proxy;
-use zbus::zvariant::OwnedObjectPath;
-
-pub struct Systemd;
-
-#[derive(Serialize, Deserialize, Debug, thiserror::Error)]
-pub enum Error {
-	#[error("systemd request failed: {0}")]
-	Failed(String),
-}
-
-#[proxy(
-	interface = "org.freedesktop.systemd1.Manager",
-	default_service = "org.freedesktop.systemd1",
-	default_path = "/org/freedesktop/systemd1"
-)]
-trait Manager {
-	fn start_unit(&self, name: &str, mode: &str) -> zbus::Result<OwnedObjectPath>;
-	fn stop_unit(&self, name: &str, mode: &str) -> zbus::Result<OwnedObjectPath>;
-}
-
-async fn manager() -> Result<ManagerProxy<'static>, Error> {
-	let conn = zbus::Connection::system()
-		.await
-		.map_err(|e| Error::Failed(e.to_string()))?;
-	ManagerProxy::new(&conn)
-		.await
-		.map_err(|e| Error::Failed(e.to_string()))
-}
-
-#[endpoints(ns = 5)]
-impl Systemd {
-	#[endpoints(id = 1)]
-	async fn start(&self, unit: String) -> Result<(), Error> {
-		manager()
-			.await?
-			.start_unit(&unit, "replace")
-			.await
-			.map_err(|e| Error::Failed(e.to_string()))?;
-		Ok(())
-	}
-	#[endpoints(id = 2)]
-	async fn stop(&self, unit: String) -> Result<(), Error> {
-		manager()
-			.await?
-			.stop_unit(&unit, "replace")
-			.await
-			.map_err(|e| Error::Failed(e.to_string()))?;
-		Ok(())
-	}
-}
addedcrates/remowt-ui-prompt/Cargo.tomldiffbeforeafterboth
--- /dev/null
+++ b/crates/remowt-ui-prompt/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "remowt-ui-prompt"
+description = "Interactive UI prompt endpoint for remowt (D-Bus)"
+version.workspace = true
+edition = "2021"
+license.workspace = true
+
+[dependencies]
+bifrostlink.workspace = true
+bifrostlink-macros.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+thiserror.workspace = true
+tokio = { workspace = true, features = ["io-util", "macros", "process", "rt"] }
+tracing.workspace = true
+zbus = { workspace = true, optional = true }
+
+[features]
+default = ["dbus"]
+dbus = ["dep:zbus"]
addedcrates/remowt-ui-prompt/src/bifrost.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/remowt-ui-prompt/src/bifrost.rs
@@ -0,0 +1,109 @@
+use bifrostlink::{Config, Rpc};
+use bifrostlink_macros::endpoints;
+use serde::{Deserialize, Serialize};
+
+use crate::{Error, Prompter, Source};
+
+pub struct PromptEndpoints<P>(pub P);
+
+#[endpoints(ns = 2)]
+impl<P> PromptEndpoints<P>
+where
+	P: Prompter + Send + Sync + 'static,
+{
+	#[endpoints(id = 1, cancel)]
+	async fn prompt_enum(
+		&self,
+		prompt: String,
+		description: String,
+		variants: Vec<String>,
+		source: Vec<Source>,
+	) -> Result<u32, Error> {
+		let variants: Vec<&str> = variants.iter().map(|v| v.as_str()).collect();
+		self.0
+			.prompt_enum(&prompt, &description, &variants, &source)
+			.await
+	}
+
+	#[endpoints(id = 2, cancel)]
+	async fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: String,
+		description: String,
+		source: Vec<Source>,
+	) -> Result<String, Error> {
+		self.0
+			.prompt_text(echo, &prompt, &description, &source)
+			.await
+	}
+
+	#[endpoints(id = 3, cancel)]
+	async fn display_text(
+		&self,
+		error: bool,
+		description: String,
+		source: Vec<Source>,
+	) -> Result<(), Error> {
+		self.0.display_text(error, &description, &source).await
+	}
+}
+
+impl<C: Config> Prompter for PromptEndpointsClient<C>
+where
+	Error: ToString,
+{
+	async fn prompt_enum(
+		&self,
+		prompt: &str,
+		description: &str,
+		variants: &[&str],
+		source: &[Source],
+	) -> crate::Result<u32> {
+		self.prompt_enum(
+			prompt.to_owned(),
+			description.to_owned(),
+			variants.iter().map(|v| (*v).to_owned()).collect(),
+			source.to_vec(),
+		)
+		.await
+		.map_err(|e| Error::Remote(e.to_string()))?
+	}
+
+	async fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> crate::Result<String> {
+		self.prompt_text(
+			echo,
+			prompt.to_owned(),
+			description.to_owned(),
+			source.to_vec(),
+		)
+		.await
+		.map_err(|e| Error::Remote(e.to_string()))?
+	}
+
+	async fn display_text(
+		&self,
+		error: bool,
+		description: &str,
+		source: &[Source],
+	) -> crate::Result<()> {
+		self.display_text(error, description.to_owned(), source.to_vec())
+			.await
+			.map_err(|e| Error::Remote(e.to_string()))?
+	}
+}
+
+pub fn serve_prompts<P, C>(rpc: &mut Rpc<C>, prompt: P)
+where
+	P: Prompter + Send + Sync + 'static,
+	C: Config,
+	C::Error: From<Error>,
+{
+	PromptEndpoints(prompt).register_endpoints(rpc);
+}
addedcrates/remowt-ui-prompt/src/dbus.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/remowt-ui-prompt/src/dbus.rs
@@ -0,0 +1,135 @@
+use zbus::interface;
+use zbus::{fdo, proxy};
+
+use crate::Source;
+use crate::{BlockingPrompter, Result};
+use crate::{Error, Prompter};
+
+pub struct DbusPrompterInterface<P>(pub P);
+
+#[interface(name = "lach.PolkitInputHandler")]
+impl<P: Prompter + Send + Sync + 'static> DbusPrompterInterface<P> {
+	async fn prompt_radio(
+		&self,
+		prompt: &str,
+		description: &str,
+		source: Vec<Source>,
+	) -> fdo::Result<bool> {
+		Ok(self.0.prompt_radio(prompt, description, &source).await?)
+	}
+	async fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: Vec<Source>,
+	) -> fdo::Result<String> {
+		Ok(self
+			.0
+			.prompt_text(echo, prompt, description, &source)
+			.await?)
+	}
+	async fn display_text(
+		&self,
+		error: bool,
+		description: &str,
+		source: Vec<Source>,
+	) -> fdo::Result<()> {
+		Ok(self.0.display_text(error, description, &source).await?)
+	}
+}
+
+#[proxy(interface = "lach.PolkitInputHandler")]
+pub trait DbusPrompter {
+	async fn prompt_enum(
+		&self,
+		prompt: &str,
+		description: &str,
+		variants: &[&str],
+		source: &[Source],
+	) -> fdo::Result<u32>;
+	async fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> fdo::Result<String>;
+	async fn display_text(
+		&self,
+		error: bool,
+		description: &str,
+		source: &[Source],
+	) -> fdo::Result<()>;
+}
+
+impl Prompter for DbusPrompterProxy<'_> {
+	async fn prompt_enum(
+		&self,
+		prompt: &str,
+		description: &str,
+		variants: &[&str],
+		source: &[Source],
+	) -> Result<u32> {
+		Ok(self
+			.prompt_enum(prompt, description, variants, source)
+			.await?)
+	}
+
+	async fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> Result<String> {
+		Ok(self.prompt_text(echo, prompt, description, source).await?)
+	}
+
+	async fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> {
+		Ok(self.display_text(error, description, source).await?)
+	}
+}
+impl BlockingPrompter for DbusPrompterProxyBlocking<'_> {
+	fn prompt_enum(
+		&self,
+		prompt: &str,
+		description: &str,
+		variants: &[&str],
+		source: &[Source],
+	) -> Result<u32> {
+		Ok(self.prompt_enum(prompt, description, variants, source)?)
+	}
+
+	fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> Result<String> {
+		Ok(self.prompt_text(echo, prompt, description, source)?)
+	}
+
+	fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> {
+		Ok(self.display_text(error, description, source)?)
+	}
+}
+
+impl From<fdo::Error> for Error {
+	fn from(value: fdo::Error) -> Self {
+		if matches!(value, fdo::Error::NoReply(_)) {
+			return Self::Cancel;
+		}
+		Self::InputError(format!("{value}"))
+	}
+}
+impl From<Error> for fdo::Error {
+	fn from(value: Error) -> Self {
+		match value {
+			Error::Cancel => fdo::Error::NoReply("input was cancelled".to_owned()),
+			Error::Remote(e) => fdo::Error::NoReply(format!("remote error occured: {e}")),
+			Error::InputError(e) => fdo::Error::Failed(e),
+		}
+	}
+}
addedcrates/remowt-ui-prompt/src/lib.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/remowt-ui-prompt/src/lib.rs
@@ -0,0 +1,201 @@
+use core::fmt;
+use std::borrow::Cow;
+use std::future::Future;
+use std::result;
+
+pub mod bifrost;
+pub mod dbus;
+pub mod rofi;
+
+#[derive(thiserror::Error, Debug, serde::Serialize, serde::Deserialize)]
+pub enum Error {
+	#[error("user has cancelled input")]
+	Cancel,
+	#[error("input error: {0}")]
+	InputError(String),
+	#[error("unknown remote error: {0}")]
+	Remote(String),
+}
+
+pub type Result<T, E = Error> = result::Result<T, E>;
+
+#[cfg_attr(feature = "dbus", derive(zbus::zvariant::Type))]
+#[derive(serde::Serialize, serde::Deserialize, Clone)]
+pub struct Source(pub Cow<'static, str>);
+impl fmt::Display for Source {
+	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+		write!(f, "<u>{}</u>", self.0)
+	}
+}
+
+pub trait Prompter: Send + Sync {
+	fn prompt_radio(
+		&self,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> impl Future<Output = Result<bool>> + Send {
+		let fut = self.prompt_enum(prompt, description, &["No", "Yes"], source);
+		async { fut.await.map(|v| v == 1) }
+	}
+	fn prompt_enum(
+		&self,
+		prompt: &str,
+		description: &str,
+		variants: &[&str],
+		source: &[Source],
+	) -> impl Future<Output = Result<u32>> + Send;
+	fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> impl Future<Output = Result<String>> + Send;
+	fn display_text(
+		&self,
+		error: bool,
+		description: &str,
+		source: &[Source],
+	) -> impl Future<Output = Result<()>> + Send;
+}
+pub trait BlockingPrompter {
+	fn prompt_radio(&self, prompt: &str, description: &str, source: &[Source]) -> Result<bool> {
+		self.prompt_enum(prompt, description, &["No", "Yes"], source)
+			.map(|v| v == 1)
+	}
+	fn prompt_enum(
+		&self,
+		prompt: &str,
+		description: &str,
+		variants: &[&str],
+		source: &[Source],
+	) -> Result<u32>;
+	fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> Result<String>;
+	fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()>;
+}
+impl<P> Prompter for &P
+where
+	P: Prompter,
+{
+	fn prompt_radio(
+		&self,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> impl Future<Output = Result<bool>> + Send {
+		(*self).prompt_radio(prompt, description, source)
+	}
+
+	fn prompt_enum(
+		&self,
+		prompt: &str,
+		description: &str,
+		variants: &[&str],
+		source: &[Source],
+	) -> impl Future<Output = Result<u32>> + Send {
+		(*self).prompt_enum(prompt, description, variants, source)
+	}
+
+	fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> impl Future<Output = Result<String>> + Send {
+		(*self).prompt_text(echo, prompt, description, source)
+	}
+
+	fn display_text(
+		&self,
+		error: bool,
+		description: &str,
+		source: &[Source],
+	) -> impl Future<Output = Result<()>> + Send {
+		(*self).display_text(error, description, source)
+	}
+}
+
+pub struct PrependSourcePrompter<P> {
+	pub prompter: P,
+	pub source: Vec<Source>,
+	pub description: String,
+}
+impl<P> PrependSourcePrompter<P> {
+	fn source(&self, input: &[Source]) -> Vec<Source> {
+		let mut out = self.source.clone();
+		out.extend(input.iter().cloned());
+		out
+	}
+	fn description(&self, input: &str) -> String {
+		if self.description.is_empty() {
+			input.to_owned()
+		} else if input.is_empty() {
+			self.description.to_owned()
+		} else {
+			format!("{input}\n\n{}", self.description)
+		}
+	}
+}
+impl<P> Prompter for PrependSourcePrompter<P>
+where
+	P: Prompter + Sync,
+{
+	async fn prompt_radio(
+		&self,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> Result<bool> {
+		self.prompter
+			.prompt_radio(prompt, &self.description(description), &self.source(source))
+			.await
+	}
+
+	async fn prompt_enum(
+		&self,
+		prompt: &str,
+		description: &str,
+		variants: &[&str],
+		source: &[Source],
+	) -> Result<u32> {
+		self.prompter
+			.prompt_enum(
+				prompt,
+				&self.description(description),
+				variants,
+				&self.source(source),
+			)
+			.await
+	}
+
+	async fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> Result<String> {
+		self.prompter
+			.prompt_text(
+				echo,
+				prompt,
+				&self.description(description),
+				&self.source(source),
+			)
+			.await
+	}
+
+	async fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> {
+		self.prompter
+			.display_text(error, &self.description(description), &self.source(source))
+			.await
+	}
+}
addedcrates/remowt-ui-prompt/src/rofi.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/remowt-ui-prompt/src/rofi.rs
@@ -0,0 +1,208 @@
+use std::process::Stdio;
+
+use tokio::io::AsyncWriteExt;
+use tokio::process::Command;
+use tracing::trace;
+
+use crate::{Error, Prompter, Result, Source};
+
+#[derive(Clone)]
+pub struct RofiPrompter;
+
+fn fixup_prompt(prompt: &str) -> &str {
+	// Rofi always appends such suffix
+	prompt.strip_suffix(": ").unwrap_or(prompt)
+}
+
+fn rofi_command() -> Command {
+	Command::new(option_env!("ROFI").unwrap_or("rofi"))
+}
+
+impl Prompter for RofiPrompter {
+	async fn prompt_enum(
+		&self,
+		prompt: &str,
+		description: &str,
+		variants: &[&str],
+		source: &[Source],
+	) -> Result<u32> {
+		trace!("rofi radio");
+		let mut cmd = rofi_command();
+		let mesg = if source.is_empty() {
+			description.to_owned()
+		} else {
+			let mut out = format!("{description}\n\n<b>Requested on ",);
+			for (i, s) in source.iter().enumerate() {
+				if i != 0 {
+					out.push_str(" -> ");
+				}
+				out.push_str(&s.to_string());
+			}
+			out.push_str("</b>");
+			out
+		};
+		cmd.args([
+			"-dmenu",
+			"-mesg",
+			&mesg,
+			"-sync",
+			"-only-match",
+			"-p",
+			fixup_prompt(prompt),
+			"-format",
+			"i",
+			"-markup-rows",
+		]);
+		cmd.stdin(Stdio::piped());
+		cmd.stdout(Stdio::piped());
+		cmd.kill_on_drop(true);
+		let mut child = cmd
+			.spawn()
+			.map_err(|e| Error::InputError(format!("failed to spawn rofi: {e}")))?;
+
+		let mut stdin = child.stdin.take().expect("stdin is piped");
+		for var in variants {
+			stdin
+				.write_all(var.replace('\n', " ").as_bytes())
+				.await
+				.map_err(|e| Error::InputError(format!("failed to write rofi variants: {e}")))?;
+			stdin
+				.write_all(b"\n")
+				.await
+				.map_err(|e| Error::InputError(format!("failed to write rofi variants: {e}")))?;
+		}
+		// write_all already flushes, just to be sure.
+		let _ = stdin.flush().await;
+		drop(stdin);
+
+		let out = child
+			.wait_with_output()
+			.await
+			.map_err(|e| Error::InputError(format!("failed to wait for rofi: {e}")))?;
+		let stdout = out
+			.stdout
+			.strip_suffix(b"\n")
+			.unwrap_or(&out.stdout)
+			.to_owned();
+
+		let id: u32 = String::from_utf8(stdout)
+			.map_err(|e| Error::InputError(format!("rofi produced invalid output: {e}")))?
+			.parse()
+			.map_err(|e| Error::InputError(format!("rofi produced invalid output: {e}")))?;
+		if id as usize >= variants.len() {
+			return Err(Error::InputError("invalid rofi response".to_owned()));
+		}
+
+		Ok(id)
+	}
+
+	async fn prompt_text(
+		&self,
+		echo: bool,
+		prompt: &str,
+		description: &str,
+		source: &[Source],
+	) -> Result<String> {
+		trace!("rofi text");
+		let mut cmd = rofi_command();
+		let mesg = if source.is_empty() {
+			description.to_owned()
+		} else {
+			let mut out = format!("{description}\n\n<b>Requested on ",);
+			for (i, s) in source.iter().enumerate() {
+				if i != 0 {
+					out.push_str(" -> ");
+				}
+				out.push_str(&s.to_string());
+			}
+			out.push_str("</b>");
+			out
+		};
+		cmd.args(["-dmenu", "-mesg", &mesg, "-p", fixup_prompt(prompt)]);
+		if !echo {
+			cmd.arg("-password");
+		}
+		cmd.stdin(Stdio::null());
+		cmd.stdout(Stdio::piped());
+		cmd.kill_on_drop(true);
+		let child = cmd
+			.spawn()
+			.map_err(|e| Error::InputError(format!("failed to spawn rofi: {e}")))?;
+
+		let out = child
+			.wait_with_output()
+			.await
+			.map_err(|e| Error::InputError(format!("failed to wait for rofi: {e}")))?;
+		let stdout = out
+			.stdout
+			.strip_suffix(b"\n")
+			.unwrap_or(&out.stdout)
+			.to_owned();
+
+		Ok(String::from_utf8_lossy(&stdout).to_string())
+	}
+
+	async fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> {
+		trace!("rofi display");
+		let mut cmd = rofi_command();
+		let mut mesg = if source.is_empty() {
+			description.to_owned()
+		} else {
+			let mut out = format!("{description}\n\n<b>Coming from ",);
+			for s in source.iter() {
+				out.push_str(&s.to_string());
+			}
+			out.push_str("</b>");
+			out
+		};
+		if error {
+			mesg.insert_str(0, "<span color=\"red\">");
+			mesg.push_str("</span>");
+		}
+		cmd.args(["-e", &mesg, "-markup"]);
+		cmd.stdin(Stdio::null());
+		cmd.stdout(Stdio::null());
+		cmd.kill_on_drop(true);
+		let mut child = cmd
+			.spawn()
+			.map_err(|e| Error::InputError(format!("failed to spawn rofi: {e}")))?;
+
+		child
+			.wait()
+			.await
+			.map_err(|e| Error::InputError(format!("failed to wait for rofi: {e}")))?;
+
+		Ok(())
+	}
+}
+
+#[cfg(test)]
+mod tests {
+	use std::borrow::Cow;
+
+	use crate::rofi::RofiPrompter;
+	use crate::{PrependSourcePrompter, Prompter as _, Source};
+
+	// #[tokio::test]
+	#[tokio::test]
+	#[ignore = "interactive"]
+	async fn test() {
+		let prompter = PrependSourcePrompter {
+			prompter: RofiPrompter,
+			description: "test".to_owned(),
+			source: vec![Source(Cow::Borrowed("ssh"))],
+		};
+		prompter
+			.prompt_radio("Enable", "Polkit needs access", &[])
+			.await
+			.expect("rofi");
+		prompter
+			.prompt_text(false, "Password", "Polkit needs access", &[])
+			.await
+			.expect("rofi");
+		prompter
+			.display_text(true, "Polkit needs access", &[])
+			.await
+			.expect("rofi");
+	}
+}
deletedcrates/ui-prompt/Cargo.tomldiffbeforeafterboth
--- a/crates/ui-prompt/Cargo.toml
+++ /dev/null
@@ -1,20 +0,0 @@
-[package]
-name = "remowt-ui-prompt"
-description = "Interactive UI prompt endpoint for remowt (D-Bus)"
-version.workspace = true
-edition = "2021"
-license.workspace = true
-
-[dependencies]
-bifrostlink.workspace = true
-bifrostlink-macros.workspace = true
-serde.workspace = true
-serde_json.workspace = true
-thiserror.workspace = true
-tokio = { workspace = true, features = ["io-util", "macros", "process", "rt"] }
-tracing.workspace = true
-zbus = { workspace = true, optional = true }
-
-[features]
-default = ["dbus"]
-dbus = ["dep:zbus"]
deletedcrates/ui-prompt/src/bifrost.rsdiffbeforeafterboth
--- a/crates/ui-prompt/src/bifrost.rs
+++ /dev/null
@@ -1,109 +0,0 @@
-use bifrostlink::{Config, Rpc};
-use bifrostlink_macros::endpoints;
-use serde::{Deserialize, Serialize};
-
-use crate::{Error, Prompter, Source};
-
-pub struct PromptEndpoints<P>(pub P);
-
-#[endpoints(ns = 2)]
-impl<P> PromptEndpoints<P>
-where
-	P: Prompter + Send + Sync + 'static,
-{
-	#[endpoints(id = 1, cancel)]
-	async fn prompt_enum(
-		&self,
-		prompt: String,
-		description: String,
-		variants: Vec<String>,
-		source: Vec<Source>,
-	) -> Result<u32, Error> {
-		let variants: Vec<&str> = variants.iter().map(|v| v.as_str()).collect();
-		self.0
-			.prompt_enum(&prompt, &description, &variants, &source)
-			.await
-	}
-
-	#[endpoints(id = 2, cancel)]
-	async fn prompt_text(
-		&self,
-		echo: bool,
-		prompt: String,
-		description: String,
-		source: Vec<Source>,
-	) -> Result<String, Error> {
-		self.0
-			.prompt_text(echo, &prompt, &description, &source)
-			.await
-	}
-
-	#[endpoints(id = 3, cancel)]
-	async fn display_text(
-		&self,
-		error: bool,
-		description: String,
-		source: Vec<Source>,
-	) -> Result<(), Error> {
-		self.0.display_text(error, &description, &source).await
-	}
-}
-
-impl<C: Config> Prompter for PromptEndpointsClient<C>
-where
-	Error: ToString,
-{
-	async fn prompt_enum(
-		&self,
-		prompt: &str,
-		description: &str,
-		variants: &[&str],
-		source: &[Source],
-	) -> crate::Result<u32> {
-		self.prompt_enum(
-			prompt.to_owned(),
-			description.to_owned(),
-			variants.iter().map(|v| (*v).to_owned()).collect(),
-			source.to_vec(),
-		)
-		.await
-		.map_err(|e| Error::Remote(e.to_string()))?
-	}
-
-	async fn prompt_text(
-		&self,
-		echo: bool,
-		prompt: &str,
-		description: &str,
-		source: &[Source],
-	) -> crate::Result<String> {
-		self.prompt_text(
-			echo,
-			prompt.to_owned(),
-			description.to_owned(),
-			source.to_vec(),
-		)
-		.await
-		.map_err(|e| Error::Remote(e.to_string()))?
-	}
-
-	async fn display_text(
-		&self,
-		error: bool,
-		description: &str,
-		source: &[Source],
-	) -> crate::Result<()> {
-		self.display_text(error, description.to_owned(), source.to_vec())
-			.await
-			.map_err(|e| Error::Remote(e.to_string()))?
-	}
-}
-
-pub fn serve_prompts<P, C>(rpc: &mut Rpc<C>, prompt: P)
-where
-	P: Prompter + Send + Sync + 'static,
-	C: Config,
-	C::Error: From<Error>,
-{
-	PromptEndpoints(prompt).register_endpoints(rpc);
-}
deletedcrates/ui-prompt/src/dbus.rsdiffbeforeafterboth
--- a/crates/ui-prompt/src/dbus.rs
+++ /dev/null
@@ -1,135 +0,0 @@
-use zbus::interface;
-use zbus::{fdo, proxy};
-
-use crate::Source;
-use crate::{BlockingPrompter, Result};
-use crate::{Error, Prompter};
-
-pub struct DbusPrompterInterface<P>(pub P);
-
-#[interface(name = "lach.PolkitInputHandler")]
-impl<P: Prompter + Send + Sync + 'static> DbusPrompterInterface<P> {
-	async fn prompt_radio(
-		&self,
-		prompt: &str,
-		description: &str,
-		source: Vec<Source>,
-	) -> fdo::Result<bool> {
-		Ok(self.0.prompt_radio(prompt, description, &source).await?)
-	}
-	async fn prompt_text(
-		&self,
-		echo: bool,
-		prompt: &str,
-		description: &str,
-		source: Vec<Source>,
-	) -> fdo::Result<String> {
-		Ok(self
-			.0
-			.prompt_text(echo, prompt, description, &source)
-			.await?)
-	}
-	async fn display_text(
-		&self,
-		error: bool,
-		description: &str,
-		source: Vec<Source>,
-	) -> fdo::Result<()> {
-		Ok(self.0.display_text(error, description, &source).await?)
-	}
-}
-
-#[proxy(interface = "lach.PolkitInputHandler")]
-pub trait DbusPrompter {
-	async fn prompt_enum(
-		&self,
-		prompt: &str,
-		description: &str,
-		variants: &[&str],
-		source: &[Source],
-	) -> fdo::Result<u32>;
-	async fn prompt_text(
-		&self,
-		echo: bool,
-		prompt: &str,
-		description: &str,
-		source: &[Source],
-	) -> fdo::Result<String>;
-	async fn display_text(
-		&self,
-		error: bool,
-		description: &str,
-		source: &[Source],
-	) -> fdo::Result<()>;
-}
-
-impl Prompter for DbusPrompterProxy<'_> {
-	async fn prompt_enum(
-		&self,
-		prompt: &str,
-		description: &str,
-		variants: &[&str],
-		source: &[Source],
-	) -> Result<u32> {
-		Ok(self
-			.prompt_enum(prompt, description, variants, source)
-			.await?)
-	}
-
-	async fn prompt_text(
-		&self,
-		echo: bool,
-		prompt: &str,
-		description: &str,
-		source: &[Source],
-	) -> Result<String> {
-		Ok(self.prompt_text(echo, prompt, description, source).await?)
-	}
-
-	async fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> {
-		Ok(self.display_text(error, description, source).await?)
-	}
-}
-impl BlockingPrompter for DbusPrompterProxyBlocking<'_> {
-	fn prompt_enum(
-		&self,
-		prompt: &str,
-		description: &str,
-		variants: &[&str],
-		source: &[Source],
-	) -> Result<u32> {
-		Ok(self.prompt_enum(prompt, description, variants, source)?)
-	}
-
-	fn prompt_text(
-		&self,
-		echo: bool,
-		prompt: &str,
-		description: &str,
-		source: &[Source],
-	) -> Result<String> {
-		Ok(self.prompt_text(echo, prompt, description, source)?)
-	}
-
-	fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> {
-		Ok(self.display_text(error, description, source)?)
-	}
-}
-
-impl From<fdo::Error> for Error {
-	fn from(value: fdo::Error) -> Self {
-		if matches!(value, fdo::Error::NoReply(_)) {
-			return Self::Cancel;
-		}
-		Self::InputError(format!("{value}"))
-	}
-}
-impl From<Error> for fdo::Error {
-	fn from(value: Error) -> Self {
-		match value {
-			Error::Cancel => fdo::Error::NoReply("input was cancelled".to_owned()),
-			Error::Remote(e) => fdo::Error::NoReply(format!("remote error occured: {e}")),
-			Error::InputError(e) => fdo::Error::Failed(e),
-		}
-	}
-}
deletedcrates/ui-prompt/src/lib.rsdiffbeforeafterboth
--- a/crates/ui-prompt/src/lib.rs
+++ /dev/null
@@ -1,201 +0,0 @@
-use core::fmt;
-use std::borrow::Cow;
-use std::future::Future;
-use std::result;
-
-pub mod bifrost;
-pub mod dbus;
-pub mod rofi;
-
-#[derive(thiserror::Error, Debug, serde::Serialize, serde::Deserialize)]
-pub enum Error {
-	#[error("user has cancelled input")]
-	Cancel,
-	#[error("input error: {0}")]
-	InputError(String),
-	#[error("unknown remote error: {0}")]
-	Remote(String),
-}
-
-pub type Result<T, E = Error> = result::Result<T, E>;
-
-#[cfg_attr(feature = "dbus", derive(zbus::zvariant::Type))]
-#[derive(serde::Serialize, serde::Deserialize, Clone)]
-pub struct Source(pub Cow<'static, str>);
-impl fmt::Display for Source {
-	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-		write!(f, "<u>{}</u>", self.0)
-	}
-}
-
-pub trait Prompter: Send + Sync {
-	fn prompt_radio(
-		&self,
-		prompt: &str,
-		description: &str,
-		source: &[Source],
-	) -> impl Future<Output = Result<bool>> + Send {
-		let fut = self.prompt_enum(prompt, description, &["No", "Yes"], source);
-		async { fut.await.map(|v| v == 1) }
-	}
-	fn prompt_enum(
-		&self,
-		prompt: &str,
-		description: &str,
-		variants: &[&str],
-		source: &[Source],
-	) -> impl Future<Output = Result<u32>> + Send;
-	fn prompt_text(
-		&self,
-		echo: bool,
-		prompt: &str,
-		description: &str,
-		source: &[Source],
-	) -> impl Future<Output = Result<String>> + Send;
-	fn display_text(
-		&self,
-		error: bool,
-		description: &str,
-		source: &[Source],
-	) -> impl Future<Output = Result<()>> + Send;
-}
-pub trait BlockingPrompter {
-	fn prompt_radio(&self, prompt: &str, description: &str, source: &[Source]) -> Result<bool> {
-		self.prompt_enum(prompt, description, &["No", "Yes"], source)
-			.map(|v| v == 1)
-	}
-	fn prompt_enum(
-		&self,
-		prompt: &str,
-		description: &str,
-		variants: &[&str],
-		source: &[Source],
-	) -> Result<u32>;
-	fn prompt_text(
-		&self,
-		echo: bool,
-		prompt: &str,
-		description: &str,
-		source: &[Source],
-	) -> Result<String>;
-	fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()>;
-}
-impl<P> Prompter for &P
-where
-	P: Prompter,
-{
-	fn prompt_radio(
-		&self,
-		prompt: &str,
-		description: &str,
-		source: &[Source],
-	) -> impl Future<Output = Result<bool>> + Send {
-		(*self).prompt_radio(prompt, description, source)
-	}
-
-	fn prompt_enum(
-		&self,
-		prompt: &str,
-		description: &str,
-		variants: &[&str],
-		source: &[Source],
-	) -> impl Future<Output = Result<u32>> + Send {
-		(*self).prompt_enum(prompt, description, variants, source)
-	}
-
-	fn prompt_text(
-		&self,
-		echo: bool,
-		prompt: &str,
-		description: &str,
-		source: &[Source],
-	) -> impl Future<Output = Result<String>> + Send {
-		(*self).prompt_text(echo, prompt, description, source)
-	}
-
-	fn display_text(
-		&self,
-		error: bool,
-		description: &str,
-		source: &[Source],
-	) -> impl Future<Output = Result<()>> + Send {
-		(*self).display_text(error, description, source)
-	}
-}
-
-pub struct PrependSourcePrompter<P> {
-	pub prompter: P,
-	pub source: Vec<Source>,
-	pub description: String,
-}
-impl<P> PrependSourcePrompter<P> {
-	fn source(&self, input: &[Source]) -> Vec<Source> {
-		let mut out = self.source.clone();
-		out.extend(input.iter().cloned());
-		out
-	}
-	fn description(&self, input: &str) -> String {
-		if self.description.is_empty() {
-			input.to_owned()
-		} else if input.is_empty() {
-			self.description.to_owned()
-		} else {
-			format!("{input}\n\n{}", self.description)
-		}
-	}
-}
-impl<P> Prompter for PrependSourcePrompter<P>
-where
-	P: Prompter + Sync,
-{
-	async fn prompt_radio(
-		&self,
-		prompt: &str,
-		description: &str,
-		source: &[Source],
-	) -> Result<bool> {
-		self.prompter
-			.prompt_radio(prompt, &self.description(description), &self.source(source))
-			.await
-	}
-
-	async fn prompt_enum(
-		&self,
-		prompt: &str,
-		description: &str,
-		variants: &[&str],
-		source: &[Source],
-	) -> Result<u32> {
-		self.prompter
-			.prompt_enum(
-				prompt,
-				&self.description(description),
-				variants,
-				&self.source(source),
-			)
-			.await
-	}
-
-	async fn prompt_text(
-		&self,
-		echo: bool,
-		prompt: &str,
-		description: &str,
-		source: &[Source],
-	) -> Result<String> {
-		self.prompter
-			.prompt_text(
-				echo,
-				prompt,
-				&self.description(description),
-				&self.source(source),
-			)
-			.await
-	}
-
-	async fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> {
-		self.prompter
-			.display_text(error, &self.description(description), &self.source(source))
-			.await
-	}
-}
deletedcrates/ui-prompt/src/rofi.rsdiffbeforeafterboth
--- a/crates/ui-prompt/src/rofi.rs
+++ /dev/null
@@ -1,208 +0,0 @@
-use std::process::Stdio;
-
-use tokio::io::AsyncWriteExt;
-use tokio::process::Command;
-use tracing::trace;
-
-use crate::{Error, Prompter, Result, Source};
-
-#[derive(Clone)]
-pub struct RofiPrompter;
-
-fn fixup_prompt(prompt: &str) -> &str {
-	// Rofi always appends such suffix
-	prompt.strip_suffix(": ").unwrap_or(prompt)
-}
-
-fn rofi_command() -> Command {
-	Command::new(option_env!("ROFI").unwrap_or("rofi"))
-}
-
-impl Prompter for RofiPrompter {
-	async fn prompt_enum(
-		&self,
-		prompt: &str,
-		description: &str,
-		variants: &[&str],
-		source: &[Source],
-	) -> Result<u32> {
-		trace!("rofi radio");
-		let mut cmd = rofi_command();
-		let mesg = if source.is_empty() {
-			description.to_owned()
-		} else {
-			let mut out = format!("{description}\n\n<b>Requested on ",);
-			for (i, s) in source.iter().enumerate() {
-				if i != 0 {
-					out.push_str(" -> ");
-				}
-				out.push_str(&s.to_string());
-			}
-			out.push_str("</b>");
-			out
-		};
-		cmd.args([
-			"-dmenu",
-			"-mesg",
-			&mesg,
-			"-sync",
-			"-only-match",
-			"-p",
-			fixup_prompt(prompt),
-			"-format",
-			"i",
-			"-markup-rows",
-		]);
-		cmd.stdin(Stdio::piped());
-		cmd.stdout(Stdio::piped());
-		cmd.kill_on_drop(true);
-		let mut child = cmd
-			.spawn()
-			.map_err(|e| Error::InputError(format!("failed to spawn rofi: {e}")))?;
-
-		let mut stdin = child.stdin.take().expect("stdin is piped");
-		for var in variants {
-			stdin
-				.write_all(var.replace('\n', " ").as_bytes())
-				.await
-				.map_err(|e| Error::InputError(format!("failed to write rofi variants: {e}")))?;
-			stdin
-				.write_all(b"\n")
-				.await
-				.map_err(|e| Error::InputError(format!("failed to write rofi variants: {e}")))?;
-		}
-		// write_all already flushes, just to be sure.
-		let _ = stdin.flush().await;
-		drop(stdin);
-
-		let out = child
-			.wait_with_output()
-			.await
-			.map_err(|e| Error::InputError(format!("failed to wait for rofi: {e}")))?;
-		let stdout = out
-			.stdout
-			.strip_suffix(b"\n")
-			.unwrap_or(&out.stdout)
-			.to_owned();
-
-		let id: u32 = String::from_utf8(stdout)
-			.map_err(|e| Error::InputError(format!("rofi produced invalid output: {e}")))?
-			.parse()
-			.map_err(|e| Error::InputError(format!("rofi produced invalid output: {e}")))?;
-		if id as usize >= variants.len() {
-			return Err(Error::InputError("invalid rofi response".to_owned()));
-		}
-
-		Ok(id)
-	}
-
-	async fn prompt_text(
-		&self,
-		echo: bool,
-		prompt: &str,
-		description: &str,
-		source: &[Source],
-	) -> Result<String> {
-		trace!("rofi text");
-		let mut cmd = rofi_command();
-		let mesg = if source.is_empty() {
-			description.to_owned()
-		} else {
-			let mut out = format!("{description}\n\n<b>Requested on ",);
-			for (i, s) in source.iter().enumerate() {
-				if i != 0 {
-					out.push_str(" -> ");
-				}
-				out.push_str(&s.to_string());
-			}
-			out.push_str("</b>");
-			out
-		};
-		cmd.args(["-dmenu", "-mesg", &mesg, "-p", fixup_prompt(prompt)]);
-		if !echo {
-			cmd.arg("-password");
-		}
-		cmd.stdin(Stdio::null());
-		cmd.stdout(Stdio::piped());
-		cmd.kill_on_drop(true);
-		let child = cmd
-			.spawn()
-			.map_err(|e| Error::InputError(format!("failed to spawn rofi: {e}")))?;
-
-		let out = child
-			.wait_with_output()
-			.await
-			.map_err(|e| Error::InputError(format!("failed to wait for rofi: {e}")))?;
-		let stdout = out
-			.stdout
-			.strip_suffix(b"\n")
-			.unwrap_or(&out.stdout)
-			.to_owned();
-
-		Ok(String::from_utf8_lossy(&stdout).to_string())
-	}
-
-	async fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> {
-		trace!("rofi display");
-		let mut cmd = rofi_command();
-		let mut mesg = if source.is_empty() {
-			description.to_owned()
-		} else {
-			let mut out = format!("{description}\n\n<b>Coming from ",);
-			for s in source.iter() {
-				out.push_str(&s.to_string());
-			}
-			out.push_str("</b>");
-			out
-		};
-		if error {
-			mesg.insert_str(0, "<span color=\"red\">");
-			mesg.push_str("</span>");
-		}
-		cmd.args(["-e", &mesg, "-markup"]);
-		cmd.stdin(Stdio::null());
-		cmd.stdout(Stdio::null());
-		cmd.kill_on_drop(true);
-		let mut child = cmd
-			.spawn()
-			.map_err(|e| Error::InputError(format!("failed to spawn rofi: {e}")))?;
-
-		child
-			.wait()
-			.await
-			.map_err(|e| Error::InputError(format!("failed to wait for rofi: {e}")))?;
-
-		Ok(())
-	}
-}
-
-#[cfg(test)]
-mod tests {
-	use std::borrow::Cow;
-
-	use crate::rofi::RofiPrompter;
-	use crate::{PrependSourcePrompter, Prompter as _, Source};
-
-	// #[tokio::test]
-	#[tokio::test]
-	#[ignore = "interactive"]
-	async fn test() {
-		let prompter = PrependSourcePrompter {
-			prompter: RofiPrompter,
-			description: "test".to_owned(),
-			source: vec![Source(Cow::Borrowed("ssh"))],
-		};
-		prompter
-			.prompt_radio("Enable", "Polkit needs access", &[])
-			.await
-			.expect("rofi");
-		prompter
-			.prompt_text(false, "Password", "Polkit needs access", &[])
-			.await
-			.expect("rofi");
-		prompter
-			.display_text(true, "Polkit needs access", &[])
-			.await
-			.expect("rofi");
-	}
-}