From 600e6edc0e862d9718fb4f24774addf68357addf Mon Sep 17 00:00:00 2001 From: Yaroslav Bolyukin Date: Fri, 12 Jun 2026 22:54:08 +0000 Subject: [PATCH] refactor: merge well-known endpoints into a single crate --- --- 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]] --- 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" --- 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; --- 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 --- 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(reader: R, mut writer: W, prompt: P) -> anyhow::Result<()> where --- 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; --- 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 { --- 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 --- 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 { - 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 { 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)?; --- /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 --- /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>, +} + +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, 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, 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 { + 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 FsClient { + pub async fn read_file_text(&self, path: impl Into) -> Result { + 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( + &self, + path: impl Into, + ) -> Result, Error> { + let text = self.read_file_text(path).await?; + Ok(T::from_str(&text)) + } + pub async fn mktemp_dir(&self) -> Result { + self.mktemp_dir_raw().await.map_err(|_| Error::Unknown)? + } + pub async fn read_dir(&self, path: impl Into) -> Result, Error> { + self.read_dir_raw(path.into()) + .await + .map_err(|_| Error::Unknown)? + } +} --- /dev/null +++ b/crates/remowt-endpoints/src/lib.rs @@ -0,0 +1,4 @@ +pub mod fs; +pub mod nix_daemon; +pub mod pty; +pub mod systemd; --- /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(()) + } +} --- /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 for Error { + fn from(e: io::Error) -> Self { + Error::Io(e.to_string()) + } +} + +#[derive(Clone, Default)] +pub struct Pty { + shells: Arc>>, + next_id: Arc, +} + +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 { + 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, +} + +impl AsyncPty { + fn new(fd: OwnedFd) -> io::Result { + 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> { + 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> { + 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> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} --- /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; + fn stop_unit(&self, name: &str, mode: &str) -> zbus::Result; +} + +async fn manager() -> Result, 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(()) + } +} --- 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"] } --- 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>, -} - -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, 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, 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 { - 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 FsClient { - pub async fn read_file_text(&self, path: impl Into) -> Result { - 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( - &self, - path: impl Into, - ) -> Result, Error> { - let text = self.read_file_text(path).await?; - Ok(T::from_str(&text)) - } - pub async fn mktemp_dir(&self) -> Result { - self.mktemp_dir_raw().await.map_err(|_| Error::Unknown)? - } - pub async fn read_dir(&self, path: impl Into) -> Result, Error> { - self.read_dir_raw(path.into()) - .await - .map_err(|_| Error::Unknown)? - } -} --- 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 --- 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}")] --- 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 --- 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(()) - } -} --- 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 { let arg = std::env::args() --- 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 --- 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 for Error { - fn from(e: io::Error) -> Self { - Error::Io(e.to_string()) - } -} - -#[derive(Clone, Default)] -pub struct Pty { - shells: Arc>>, - next_id: Arc, -} - -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 { - 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, -} - -impl AsyncPty { - fn new(fd: OwnedFd) -> io::Result { - 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> { - 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> { - 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> { - Poll::Ready(Ok(())) - } - - fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } -} --- 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"] } --- 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; - fn stop_unit(&self, name: &str, mode: &str) -> zbus::Result; -} - -async fn manager() -> Result, 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(()) - } -} --- /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"] --- /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

(pub P); + +#[endpoints(ns = 2)] +impl

PromptEndpoints

+where + P: Prompter + Send + Sync + 'static, +{ + #[endpoints(id = 1, cancel)] + async fn prompt_enum( + &self, + prompt: String, + description: String, + variants: Vec, + source: Vec, + ) -> Result { + 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, + ) -> Result { + self.0 + .prompt_text(echo, &prompt, &description, &source) + .await + } + + #[endpoints(id = 3, cancel)] + async fn display_text( + &self, + error: bool, + description: String, + source: Vec, + ) -> Result<(), Error> { + self.0.display_text(error, &description, &source).await + } +} + +impl Prompter for PromptEndpointsClient +where + Error: ToString, +{ + async fn prompt_enum( + &self, + prompt: &str, + description: &str, + variants: &[&str], + source: &[Source], + ) -> crate::Result { + 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 { + 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(rpc: &mut Rpc, prompt: P) +where + P: Prompter + Send + Sync + 'static, + C: Config, + C::Error: From, +{ + PromptEndpoints(prompt).register_endpoints(rpc); +} --- /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

(pub P); + +#[interface(name = "lach.PolkitInputHandler")] +impl DbusPrompterInterface

{ + async fn prompt_radio( + &self, + prompt: &str, + description: &str, + source: Vec, + ) -> fdo::Result { + Ok(self.0.prompt_radio(prompt, description, &source).await?) + } + async fn prompt_text( + &self, + echo: bool, + prompt: &str, + description: &str, + source: Vec, + ) -> fdo::Result { + Ok(self + .0 + .prompt_text(echo, prompt, description, &source) + .await?) + } + async fn display_text( + &self, + error: bool, + description: &str, + source: Vec, + ) -> 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; + async fn prompt_text( + &self, + echo: bool, + prompt: &str, + description: &str, + source: &[Source], + ) -> fdo::Result; + 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 { + Ok(self + .prompt_enum(prompt, description, variants, source) + .await?) + } + + async fn prompt_text( + &self, + echo: bool, + prompt: &str, + description: &str, + source: &[Source], + ) -> Result { + 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 { + Ok(self.prompt_enum(prompt, description, variants, source)?) + } + + fn prompt_text( + &self, + echo: bool, + prompt: &str, + description: &str, + source: &[Source], + ) -> Result { + 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 for Error { + fn from(value: fdo::Error) -> Self { + if matches!(value, fdo::Error::NoReply(_)) { + return Self::Cancel; + } + Self::InputError(format!("{value}")) + } +} +impl From 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), + } + } +} --- /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 = result::Result; + +#[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, "{}", self.0) + } +} + +pub trait Prompter: Send + Sync { + fn prompt_radio( + &self, + prompt: &str, + description: &str, + source: &[Source], + ) -> impl Future> + 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> + Send; + fn prompt_text( + &self, + echo: bool, + prompt: &str, + description: &str, + source: &[Source], + ) -> impl Future> + Send; + fn display_text( + &self, + error: bool, + description: &str, + source: &[Source], + ) -> impl Future> + Send; +} +pub trait BlockingPrompter { + fn prompt_radio(&self, prompt: &str, description: &str, source: &[Source]) -> Result { + 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; + fn prompt_text( + &self, + echo: bool, + prompt: &str, + description: &str, + source: &[Source], + ) -> Result; + fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()>; +} +impl

Prompter for &P +where + P: Prompter, +{ + fn prompt_radio( + &self, + prompt: &str, + description: &str, + source: &[Source], + ) -> impl Future> + Send { + (*self).prompt_radio(prompt, description, source) + } + + fn prompt_enum( + &self, + prompt: &str, + description: &str, + variants: &[&str], + source: &[Source], + ) -> impl Future> + Send { + (*self).prompt_enum(prompt, description, variants, source) + } + + fn prompt_text( + &self, + echo: bool, + prompt: &str, + description: &str, + source: &[Source], + ) -> impl Future> + Send { + (*self).prompt_text(echo, prompt, description, source) + } + + fn display_text( + &self, + error: bool, + description: &str, + source: &[Source], + ) -> impl Future> + Send { + (*self).display_text(error, description, source) + } +} + +pub struct PrependSourcePrompter

{ + pub prompter: P, + pub source: Vec, + pub description: String, +} +impl

PrependSourcePrompter

{ + fn source(&self, input: &[Source]) -> Vec { + 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

Prompter for PrependSourcePrompter

+where + P: Prompter + Sync, +{ + async fn prompt_radio( + &self, + prompt: &str, + description: &str, + source: &[Source], + ) -> Result { + 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 { + 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 { + 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 + } +} --- /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 { + trace!("rofi radio"); + let mut cmd = rofi_command(); + let mesg = if source.is_empty() { + description.to_owned() + } else { + let mut out = format!("{description}\n\nRequested on ",); + for (i, s) in source.iter().enumerate() { + if i != 0 { + out.push_str(" -> "); + } + out.push_str(&s.to_string()); + } + out.push_str(""); + 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 { + trace!("rofi text"); + let mut cmd = rofi_command(); + let mesg = if source.is_empty() { + description.to_owned() + } else { + let mut out = format!("{description}\n\nRequested on ",); + for (i, s) in source.iter().enumerate() { + if i != 0 { + out.push_str(" -> "); + } + out.push_str(&s.to_string()); + } + out.push_str(""); + 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\nComing from ",); + for s in source.iter() { + out.push_str(&s.to_string()); + } + out.push_str(""); + out + }; + if error { + mesg.insert_str(0, ""); + mesg.push_str(""); + } + 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"); + } +} --- 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"] --- 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

(pub P); - -#[endpoints(ns = 2)] -impl

PromptEndpoints

-where - P: Prompter + Send + Sync + 'static, -{ - #[endpoints(id = 1, cancel)] - async fn prompt_enum( - &self, - prompt: String, - description: String, - variants: Vec, - source: Vec, - ) -> Result { - 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, - ) -> Result { - self.0 - .prompt_text(echo, &prompt, &description, &source) - .await - } - - #[endpoints(id = 3, cancel)] - async fn display_text( - &self, - error: bool, - description: String, - source: Vec, - ) -> Result<(), Error> { - self.0.display_text(error, &description, &source).await - } -} - -impl Prompter for PromptEndpointsClient -where - Error: ToString, -{ - async fn prompt_enum( - &self, - prompt: &str, - description: &str, - variants: &[&str], - source: &[Source], - ) -> crate::Result { - 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 { - 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(rpc: &mut Rpc, prompt: P) -where - P: Prompter + Send + Sync + 'static, - C: Config, - C::Error: From, -{ - PromptEndpoints(prompt).register_endpoints(rpc); -} --- 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

(pub P); - -#[interface(name = "lach.PolkitInputHandler")] -impl DbusPrompterInterface

{ - async fn prompt_radio( - &self, - prompt: &str, - description: &str, - source: Vec, - ) -> fdo::Result { - Ok(self.0.prompt_radio(prompt, description, &source).await?) - } - async fn prompt_text( - &self, - echo: bool, - prompt: &str, - description: &str, - source: Vec, - ) -> fdo::Result { - Ok(self - .0 - .prompt_text(echo, prompt, description, &source) - .await?) - } - async fn display_text( - &self, - error: bool, - description: &str, - source: Vec, - ) -> 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; - async fn prompt_text( - &self, - echo: bool, - prompt: &str, - description: &str, - source: &[Source], - ) -> fdo::Result; - 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 { - Ok(self - .prompt_enum(prompt, description, variants, source) - .await?) - } - - async fn prompt_text( - &self, - echo: bool, - prompt: &str, - description: &str, - source: &[Source], - ) -> Result { - 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 { - Ok(self.prompt_enum(prompt, description, variants, source)?) - } - - fn prompt_text( - &self, - echo: bool, - prompt: &str, - description: &str, - source: &[Source], - ) -> Result { - 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 for Error { - fn from(value: fdo::Error) -> Self { - if matches!(value, fdo::Error::NoReply(_)) { - return Self::Cancel; - } - Self::InputError(format!("{value}")) - } -} -impl From 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), - } - } -} --- 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 = result::Result; - -#[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, "{}", self.0) - } -} - -pub trait Prompter: Send + Sync { - fn prompt_radio( - &self, - prompt: &str, - description: &str, - source: &[Source], - ) -> impl Future> + 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> + Send; - fn prompt_text( - &self, - echo: bool, - prompt: &str, - description: &str, - source: &[Source], - ) -> impl Future> + Send; - fn display_text( - &self, - error: bool, - description: &str, - source: &[Source], - ) -> impl Future> + Send; -} -pub trait BlockingPrompter { - fn prompt_radio(&self, prompt: &str, description: &str, source: &[Source]) -> Result { - 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; - fn prompt_text( - &self, - echo: bool, - prompt: &str, - description: &str, - source: &[Source], - ) -> Result; - fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()>; -} -impl

Prompter for &P -where - P: Prompter, -{ - fn prompt_radio( - &self, - prompt: &str, - description: &str, - source: &[Source], - ) -> impl Future> + Send { - (*self).prompt_radio(prompt, description, source) - } - - fn prompt_enum( - &self, - prompt: &str, - description: &str, - variants: &[&str], - source: &[Source], - ) -> impl Future> + Send { - (*self).prompt_enum(prompt, description, variants, source) - } - - fn prompt_text( - &self, - echo: bool, - prompt: &str, - description: &str, - source: &[Source], - ) -> impl Future> + Send { - (*self).prompt_text(echo, prompt, description, source) - } - - fn display_text( - &self, - error: bool, - description: &str, - source: &[Source], - ) -> impl Future> + Send { - (*self).display_text(error, description, source) - } -} - -pub struct PrependSourcePrompter

{ - pub prompter: P, - pub source: Vec, - pub description: String, -} -impl

PrependSourcePrompter

{ - fn source(&self, input: &[Source]) -> Vec { - 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

Prompter for PrependSourcePrompter

-where - P: Prompter + Sync, -{ - async fn prompt_radio( - &self, - prompt: &str, - description: &str, - source: &[Source], - ) -> Result { - 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 { - 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 { - 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 - } -} --- 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 { - trace!("rofi radio"); - let mut cmd = rofi_command(); - let mesg = if source.is_empty() { - description.to_owned() - } else { - let mut out = format!("{description}\n\nRequested on ",); - for (i, s) in source.iter().enumerate() { - if i != 0 { - out.push_str(" -> "); - } - out.push_str(&s.to_string()); - } - out.push_str(""); - 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 { - trace!("rofi text"); - let mut cmd = rofi_command(); - let mesg = if source.is_empty() { - description.to_owned() - } else { - let mut out = format!("{description}\n\nRequested on ",); - for (i, s) in source.iter().enumerate() { - if i != 0 { - out.push_str(" -> "); - } - out.push_str(&s.to_string()); - } - out.push_str(""); - 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\nComing from ",); - for s in source.iter() { - out.push_str(&s.to_string()); - } - out.push_str(""); - out - }; - if error { - mesg.insert_str(0, ""); - mesg.push_str(""); - } - 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"); - } -} -- gitstuff