git.delta.rocks / remowt / refs/commits / 2499daa8100a

difftreelog

feat enum prompt variant

wnlysuptYaroslav Bolyukin2024-08-05parent: #7c2fb57.patch.diff
in: trunk

11 files changed

modifiedCargo.lockdiffbeforeafterboth
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -351,9 +351,9 @@
 
 [[package]]
 name = "clap"
-version = "4.5.11"
+version = "4.5.13"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "35723e6a11662c2afb578bcf0b88bf6ea8e21282a953428f240574fcc3a2b5b3"
+checksum = "0fbb260a053428790f3de475e304ff84cdbc4face759ea7a3e64c1edd938a7fc"
 dependencies = [
  "clap_builder",
  "clap_derive",
@@ -361,9 +361,9 @@
 
 [[package]]
 name = "clap_builder"
-version = "4.5.11"
+version = "4.5.13"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "49eb96cbfa7cfa35017b7cd548c75b14c3118c98b423041d70562665e07fb0fa"
+checksum = "64b17d7ea74e9f833c7dbf2cbe4fb12ff26783eda4782a8975b72f895c9b4d99"
 dependencies = [
  "anstream",
  "anstyle",
@@ -373,9 +373,9 @@
 
 [[package]]
 name = "clap_derive"
-version = "4.5.11"
+version = "4.5.13"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5d029b67f89d30bbb547c89fd5161293c0aec155fc691d7924b64550662db93e"
+checksum = "501d359d5f3dcaf6ecdeee48833ae73ec6e42723a1e52419c79abf9507eec0a0"
 dependencies = [
  "heck",
  "proc-macro2",
@@ -863,24 +863,6 @@
 ]
 
 [[package]]
-name = "polkit-agent"
-version = "0.1.0"
-dependencies = [
- "anyhow",
- "pam-client",
- "polkit-shared",
- "rand",
- "serde",
- "tokio",
- "tracing",
- "tracing-subscriber",
- "ui-prompt",
- "uuid",
- "zbus",
- "zbus_polkit",
-]
-
-[[package]]
 name = "polkit-backend"
 version = "0.1.0"
 dependencies = [
@@ -1013,6 +995,29 @@
 checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b"
 
 [[package]]
+name = "remowt-agent"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "clap",
+ "pam-client",
+ "polkit-shared",
+ "rand",
+ "serde",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+ "ui-prompt",
+ "uuid",
+ "zbus",
+ "zbus_polkit",
+]
+
+[[package]]
+name = "remowt-ssh"
+version = "0.1.0"
+
+[[package]]
 name = "rpassword"
 version = "6.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
modifiedCargo.tomldiffbeforeafterboth
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,3 +1,7 @@
 [workspace]
 members = ["cmds/*", "crates/*"]
 resolver = "2"
+
+[workspace.packages]
+bifrostlink = { path = "../bifrostlink/crates/bifrostlink" }
+bifrostlink-ports = { path = "../bifrostlink/crates/bifrostlink-ports" }
deletedcmds/polkit-agent/Cargo.tomldiffbeforeafterboth
--- a/cmds/polkit-agent/Cargo.toml
+++ /dev/null
@@ -1,18 +0,0 @@
-[package]
-name = "polkit-agent"
-version = "0.1.0"
-edition = "2021"
-
-[dependencies]
-anyhow = "1.0.86"
-pam-client = "0.5.0"
-polkit-shared = { version = "0.1.0", path = "../../crates/polkit-shared" }
-rand = "0.8.5"
-serde = { version = "1.0.204", features = ["derive"] }
-tokio = { version = "1.39.2", features = ["rt-multi-thread", "fs", "macros"] }
-tracing = "0.1.40"
-tracing-subscriber = "0.3.18"
-ui-prompt = { version = "0.1.0", path = "../../crates/ui-prompt" }
-uuid = { version = "1.10.0", features = ["v4"] }
-zbus = { version = "4.4.0", features = ["tokio"] }
-zbus_polkit = { version = "4.0.0", features = ["tokio"] }
deletedcmds/polkit-agent/src/main.rsdiffbeforeafterboth
--- a/cmds/polkit-agent/src/main.rs
+++ /dev/null
@@ -1,177 +0,0 @@
-use std::collections::HashMap;
-use std::future;
-use std::marker::PhantomData;
-use std::sync::{Mutex, RwLock};
-
-use polkit_shared::{BackendRequest, Identity};
-use tokio::runtime::Handle;
-use tokio::task::{AbortHandle, JoinHandle, LocalSet};
-use tracing::trace;
-use ui_prompt::dbus::DbusPrompterInterface;
-use ui_prompt::rofi::RofiPrompter;
-use ui_prompt::Prompter;
-use zbus::zvariant::{OwnedValue, Str};
-use zbus::ObjectServer;
-use zbus::{interface, proxy, Connection};
-use zbus_polkit::policykit1::Subject;
-
-struct TemporaryPrompterInterface<P: Prompter + Send + Sync + 'static> {
-    connection: Connection,
-    path: String,
-    _marker: PhantomData<P>,
-}
-impl<P: Prompter + Send + Sync + 'static> TemporaryPrompterInterface<P> {
-    async fn new(connection: Connection, prompter: P) -> Self {
-        let path = format!(
-            "/remowt/prompters/{}",
-            uuid::Uuid::new_v4().to_string().replace("-", "_")
-        );
-        let _ = connection
-            .object_server()
-            .at(path.clone(), DbusPrompterInterface(prompter))
-            .await;
-        Self {
-            connection,
-            path,
-            _marker: PhantomData,
-        }
-    }
-}
-impl<P: Prompter + Send + Sync + 'static> Drop for TemporaryPrompterInterface<P> {
-    fn drop(&mut self) {
-        // FIXME: block_in_place prevents to moving to current_thread runtime
-        // There should be a blocking way to remove ObjectServer listener.
-        // As far as I can see, it is only async because of async RwLock, shouldn't it be
-        // just a sync lock?
-        tokio::task::block_in_place(move || {
-            Handle::current().block_on(async {
-                let _ = self
-                    .connection
-                    .object_server()
-                    .remove::<DbusPrompterInterface<P>, String>(self.path.clone())
-                    .await;
-            });
-        });
-    }
-}
-
-struct Agent {
-    helper: PolkitHelperProxy<'static>,
-    tasks: Mutex<HashMap<String, AbortHandle>>,
-    connection: Connection,
-}
-impl Agent {
-    async fn new(connection: Connection) -> anyhow::Result<Self> {
-        Ok(Self {
-            helper: PolkitHelperProxy::new(&connection).await?,
-            tasks: Mutex::new(HashMap::new()),
-            connection,
-        })
-    }
-}
-
-#[interface(name = "org.freedesktop.PolicyKit1.AuthenticationAgent")]
-impl Agent {
-    /// BeginAuthentication method
-    #[allow(clippy::too_many_arguments)]
-    async fn begin_authentication(
-        &mut self,
-        action_id: String,
-        message: String,
-        icon_name: String,
-        details: HashMap<String, String>,
-        cookie: String,
-        identities: Vec<Identity>,
-    ) -> zbus::fdo::Result<()> {
-        trace!("begin auth");
-        let task = {
-            let connection = self.connection.clone();
-            let helper = self.helper.clone();
-            let cookie = cookie.clone();
-            tokio::task::spawn(async move {
-                trace!("conversation task");
-                let prompter = TemporaryPrompterInterface::new(connection, RofiPrompter).await;
-                helper
-                    .init_conversation(
-                        BackendRequest {
-                            cookie: cookie.to_owned(),
-                            environment: HashMap::new(),
-                            prompter_path: prompter.path.clone(),
-                            // TODO: Let user choose
-                            identity: identities.get(0).expect("first always exists").clone(),
-                        }, // cookie.to_owned(), HashMap::new(), prompter.path.clone()
-                    )
-                    .await?;
-                println!("ASKED");
-                dbg!(action_id, message, icon_name, details, cookie, identities);
-
-                Ok(())
-            })
-        };
-
-        self.tasks
-            .lock()
-            .unwrap()
-            .insert(cookie.clone(), task.abort_handle());
-        let result = task.await.expect("join error");
-        // The only way to no reach this line, is to either panic in previous line, or if authorization cancelled,
-        // while cancellation will remove task by itself.
-        // TODO: But still it would be better to have abort guard, which will remove it from HashMap
-        self.tasks.lock().unwrap().remove(&cookie);
-
-        result
-    }
-
-    /// CancelAuthentication method
-    async fn cancel_authentication(&self, cookie: &str) -> zbus::fdo::Result<()> {
-        trace!("cancel auth");
-        if let Some(abort) = self.tasks.lock().unwrap().remove(cookie) {
-            abort.abort();
-        }
-        // debug!("Authentication cancled ! {cookie}");
-        Ok(())
-    }
-}
-
-const OBJ_PATH: &str = "/0lach/polkitAgent";
-#[tokio::main]
-async fn main() -> anyhow::Result<()> {
-    tracing_subscriber::fmt::init();
-
-    trace!("started");
-    let conn = Connection::system().await?;
-
-    let proxy = zbus_polkit::policykit1::AuthorityProxy::new(&conn).await?;
-    conn.object_server()
-        .at(OBJ_PATH, Agent::new(conn.clone()).await?)
-        .await?;
-
-    let session_id = std::env::var("XDG_SESSION_ID")?;
-    let mut details = HashMap::new();
-    let val: OwnedValue = {
-        let wrapped: Str<'_> = session_id.into();
-        wrapped.into()
-    };
-    details.insert("session-id".to_string(), val);
-    proxy
-        .register_authentication_agent(
-            &Subject {
-                subject_kind: "unix-session".to_string(),
-                subject_details: details,
-            },
-            "C",
-            OBJ_PATH,
-        )
-        .await?;
-
-    future::pending().await
-}
-
-#[proxy(
-    interface = "lach.PolkitHelper",
-    default_service = "lach.polkit.helper1",
-    default_path = "/lach/PolkitHelper"
-)]
-trait PolkitHelper {
-    fn init_conversation(&self, request: BackendRequest) -> zbus::Result<()>;
-}
addedcmds/remowt-agent/Cargo.tomldiffbeforeafterboth
--- /dev/null
+++ b/cmds/remowt-agent/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "remowt-agent"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+anyhow = "1.0.86"
+clap = { version = "4.5.13", features = ["derive"] }
+pam-client = "0.5.0"
+polkit-shared = { version = "0.1.0", path = "../../crates/polkit-shared" }
+rand = "0.8.5"
+serde = { version = "1.0.204", features = ["derive"] }
+tokio = { version = "1.39.2", features = ["rt-multi-thread", "fs", "macros"] }
+tracing = "0.1.40"
+tracing-subscriber = "0.3.18"
+ui-prompt = { version = "0.1.0", path = "../../crates/ui-prompt" }
+uuid = { version = "1.10.0", features = ["v4"] }
+zbus = { version = "4.4.0", features = ["tokio"] }
+zbus_polkit = { version = "4.0.0", features = ["tokio"] }
addedcmds/remowt-agent/src/main.rsdiffbeforeafterboth
--- /dev/null
+++ b/cmds/remowt-agent/src/main.rs
@@ -0,0 +1,197 @@
+use std::collections::HashMap;
+use std::io::{stdout, Write};
+use std::marker::PhantomData;
+use std::sync::{Mutex, RwLock};
+use std::{future, process};
+
+use clap::Parser;
+use polkit_shared::{BackendRequest, Identity};
+use tokio::runtime::Handle;
+use tokio::task::{AbortHandle, JoinHandle, LocalSet};
+use tracing::trace;
+use ui_prompt::dbus::DbusPrompterInterface;
+use ui_prompt::rofi::RofiPrompter;
+use ui_prompt::Prompter;
+use zbus::zvariant::{OwnedValue, Str};
+use zbus::ObjectServer;
+use zbus::{interface, proxy, Connection};
+use zbus_polkit::policykit1::Subject;
+
+struct TemporaryPrompterInterface<P: Prompter + Send + Sync + 'static> {
+    connection: Connection,
+    path: String,
+    _marker: PhantomData<P>,
+}
+impl<P: Prompter + Send + Sync + 'static> TemporaryPrompterInterface<P> {
+    async fn new(connection: Connection, prompter: P) -> Self {
+        let path = format!(
+            "/remowt/prompters/{}",
+            uuid::Uuid::new_v4().to_string().replace("-", "_")
+        );
+        let _ = connection
+            .object_server()
+            .at(path.clone(), DbusPrompterInterface(prompter))
+            .await;
+        Self {
+            connection,
+            path,
+            _marker: PhantomData,
+        }
+    }
+}
+impl<P: Prompter + Send + Sync + 'static> Drop for TemporaryPrompterInterface<P> {
+    fn drop(&mut self) {
+        // FIXME: block_in_place prevents to moving to current_thread runtime
+        // There should be a blocking way to remove ObjectServer listener.
+        // As far as I can see, it is only async because of async RwLock, shouldn't it be
+        // just a sync lock?
+        tokio::task::block_in_place(move || {
+            Handle::current().block_on(async {
+                let _ = self
+                    .connection
+                    .object_server()
+                    .remove::<DbusPrompterInterface<P>, String>(self.path.clone())
+                    .await;
+            });
+        });
+    }
+}
+
+struct Agent {
+    helper: PolkitHelperProxy<'static>,
+    tasks: Mutex<HashMap<String, AbortHandle>>,
+    connection: Connection,
+}
+impl Agent {
+    async fn new(connection: Connection) -> anyhow::Result<Self> {
+        Ok(Self {
+            helper: PolkitHelperProxy::new(&connection).await?,
+            tasks: Mutex::new(HashMap::new()),
+            connection,
+        })
+    }
+}
+
+#[interface(name = "org.freedesktop.PolicyKit1.AuthenticationAgent")]
+impl Agent {
+    /// BeginAuthentication method
+    #[allow(clippy::too_many_arguments)]
+    async fn begin_authentication(
+        &mut self,
+        action_id: String,
+        message: String,
+        icon_name: String,
+        details: HashMap<String, String>,
+        cookie: String,
+        identities: Vec<Identity>,
+    ) -> zbus::fdo::Result<()> {
+        trace!("begin auth");
+        let task = {
+            let connection = self.connection.clone();
+            let helper = self.helper.clone();
+            let cookie = cookie.clone();
+            tokio::task::spawn(async move {
+                trace!("conversation task");
+                let prompter = TemporaryPrompterInterface::new(connection, RofiPrompter).await;
+                helper
+                    .init_conversation(
+                        BackendRequest {
+                            cookie: cookie.to_owned(),
+                            environment: HashMap::new(),
+                            prompter_path: prompter.path.clone(),
+                            // TODO: Let user choose
+                            identity: identities.get(0).expect("first always exists").clone(),
+                        }, // cookie.to_owned(), HashMap::new(), prompter.path.clone()
+                    )
+                    .await?;
+                println!("ASKED");
+                dbg!(action_id, message, icon_name, details, cookie, identities);
+
+                Ok(())
+            })
+        };
+
+        self.tasks
+            .lock()
+            .unwrap()
+            .insert(cookie.clone(), task.abort_handle());
+        let result = task.await.expect("join error");
+        // The only way to no reach this line, is to either panic in previous line, or if authorization cancelled,
+        // while cancellation will remove task by itself.
+        // TODO: But still it would be better to have abort guard, which will remove it from HashMap
+        self.tasks.lock().unwrap().remove(&cookie);
+
+        result
+    }
+
+    /// CancelAuthentication method
+    async fn cancel_authentication(&self, cookie: &str) -> zbus::fdo::Result<()> {
+        trace!("cancel auth");
+        if let Some(abort) = self.tasks.lock().unwrap().remove(cookie) {
+            abort.abort();
+        }
+        // debug!("Authentication cancled ! {cookie}");
+        Ok(())
+    }
+}
+
+const OBJ_PATH: &str = "/0lach/polkitAgent";
+
+#[proxy(
+    interface = "lach.PolkitHelper",
+    default_service = "lach.polkit.helper1",
+    default_path = "/lach/PolkitHelper"
+)]
+trait PolkitHelper {
+    fn init_conversation(&self, request: BackendRequest) -> zbus::Result<()>;
+}
+
+#[derive(Parser)]
+enum Opts {
+    Agent,
+    AskPass { description: String },
+}
+
+#[tokio::main]
+async fn main() -> anyhow::Result<()> {
+    tracing_subscriber::fmt::init();
+    let opts = Opts::parse();
+
+    match opts {
+        Opts::Agent => {
+            trace!("started");
+            let conn = Connection::system().await?;
+
+            let proxy = zbus_polkit::policykit1::AuthorityProxy::new(&conn).await?;
+            conn.object_server()
+                .at(OBJ_PATH, Agent::new(conn.clone()).await?)
+                .await?;
+
+            let session_id = std::env::var("XDG_SESSION_ID")?;
+            let mut details = HashMap::new();
+            let val: OwnedValue = {
+                let wrapped: Str<'_> = session_id.into();
+                wrapped.into()
+            };
+            details.insert("session-id".to_string(), val);
+            proxy
+                .register_authentication_agent(
+                    &Subject {
+                        subject_kind: "unix-session".to_string(),
+                        subject_details: details,
+                    },
+                    "C",
+                    OBJ_PATH,
+                )
+                .await?;
+        }
+        Opts::AskPass { description } => {
+            let password = RofiPrompter
+                .prompt_text(false, &description, "SSH password request", &[])
+                .await?;
+            stdout().lock().write_all(password.as_bytes())?;
+        }
+    }
+
+    future::pending().await
+}
addedcmds/remowt-ssh/Cargo.tomldiffbeforeafterboth
--- /dev/null
+++ b/cmds/remowt-ssh/Cargo.toml
@@ -0,0 +1,6 @@
+[package]
+name = "remowt-ssh"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
addedcmds/remowt-ssh/src/main.rsdiffbeforeafterboth
--- /dev/null
+++ b/cmds/remowt-ssh/src/main.rs
@@ -0,0 +1,3 @@
+fn main() {
+    println!("Hello, world!");
+}
modifiedcrates/ui-prompt/src/dbus.rsdiffbeforeafterboth
before · crates/ui-prompt/src/dbus.rs
1use zbus::interface;2use zbus::{fdo, proxy};34use crate::Source;5use crate::{BlockingPrompter, Result};6use crate::{Error, Prompter};78pub struct DbusPrompterInterface<P>(pub P);9#[interface(name = "lach.PolkitInputHandler")]10impl<P: Prompter + Send + Sync + 'static> DbusPrompterInterface<P> {11    async fn prompt_radio(12        &self,13        prompt: &str,14        description: &str,15        source: Vec<Source>,16    ) -> fdo::Result<bool> {17        Ok(self.0.prompt_radio(prompt, description, &source).await?)18    }19    async fn prompt_text(20        &self,21        echo: bool,22        prompt: &str,23        description: &str,24        source: Vec<Source>,25    ) -> fdo::Result<String> {26        Ok(self27            .028            .prompt_text(echo, prompt, description, &source)29            .await?)30    }31    async fn display_text(32        &self,33        error: bool,34        description: &str,35        source: Vec<Source>,36    ) -> fdo::Result<()> {37        Ok(self.0.display_text(error, description, &source).await?)38    }39}4041#[proxy(interface = "lach.PolkitInputHandler")]42trait DbusPrompter {43    async fn prompt_radio(44        &self,45        prompt: &str,46        description: &str,47        source: &[Source],48    ) -> fdo::Result<bool>;49    async fn prompt_text(50        &self,51        echo: bool,52        prompt: &str,53        description: &str,54        source: &[Source],55    ) -> fdo::Result<String>;56    async fn display_text(57        &self,58        error: bool,59        description: &str,60        source: &[Source],61    ) -> fdo::Result<()>;62}6364impl Prompter for DbusPrompterProxy<'_> {65    async fn prompt_radio(66        &self,67        prompt: &str,68        description: &str,69        source: &[Source],70    ) -> Result<bool> {71        Ok(self.prompt_radio(prompt, description, source).await?)72    }7374    async fn prompt_text(75        &self,76        echo: bool,77        prompt: &str,78        description: &str,79        source: &[Source],80    ) -> Result<String> {81        Ok(self.prompt_text(echo, prompt, description, source).await?)82    }8384    async fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> {85        Ok(self.display_text(error, description, source).await?)86    }87}88impl BlockingPrompter for DbusPrompterProxyBlocking<'_> {89    fn prompt_radio(&self, prompt: &str, description: &str, source: &[Source]) -> Result<bool> {90        Ok(self.prompt_radio(prompt, description, source)?)91    }9293    fn prompt_text(94        &self,95        echo: bool,96        prompt: &str,97        description: &str,98        source: &[Source],99    ) -> Result<String> {100        Ok(self.prompt_text(echo, prompt, description, source)?)101    }102103    fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> {104        Ok(self.display_text(error, description, source)?)105    }106}107108impl From<fdo::Error> for Error {109    fn from(value: fdo::Error) -> Self {110        if matches!(value, fdo::Error::NoReply(_)) {111            return Self::Cancel;112        }113        Self::InputError(format!("{value}"))114    }115}116impl From<Error> for fdo::Error {117    fn from(value: Error) -> Self {118        match value {119            Error::Cancel => fdo::Error::NoReply("input was cancelled".to_owned()),120            Error::InputError(e) => fdo::Error::Failed(e),121        }122    }123}
after · crates/ui-prompt/src/dbus.rs
1use zbus::interface;2use zbus::{fdo, proxy};34use crate::Source;5use crate::{BlockingPrompter, Result};6use crate::{Error, Prompter};78pub struct DbusPrompterInterface<P>(pub P);9#[interface(name = "lach.PolkitInputHandler")]10impl<P: Prompter + Send + Sync + 'static> DbusPrompterInterface<P> {11    async fn prompt_radio(12        &self,13        prompt: &str,14        description: &str,15        source: Vec<Source>,16    ) -> fdo::Result<bool> {17        Ok(self.0.prompt_radio(prompt, description, &source).await?)18    }19    async fn prompt_text(20        &self,21        echo: bool,22        prompt: &str,23        description: &str,24        source: Vec<Source>,25    ) -> fdo::Result<String> {26        Ok(self27            .028            .prompt_text(echo, prompt, description, &source)29            .await?)30    }31    async fn display_text(32        &self,33        error: bool,34        description: &str,35        source: Vec<Source>,36    ) -> fdo::Result<()> {37        Ok(self.0.display_text(error, description, &source).await?)38    }39}4041#[proxy(interface = "lach.PolkitInputHandler")]42trait DbusPrompter {43    async fn prompt_enum(44        &self,45        prompt: &str,46        description: &str,47        variants: &[&str],48        source: &[Source],49    ) -> fdo::Result<u32>;50    async fn prompt_text(51        &self,52        echo: bool,53        prompt: &str,54        description: &str,55        source: &[Source],56    ) -> fdo::Result<String>;57    async fn display_text(58        &self,59        error: bool,60        description: &str,61        source: &[Source],62    ) -> fdo::Result<()>;63}6465impl Prompter for DbusPrompterProxy<'_> {66    async fn prompt_enum(67        &self,68        prompt: &str,69        description: &str,70        variants: &[&str],71        source: &[Source],72    ) -> Result<u32> {73        Ok(self74            .prompt_enum(prompt, description, variants, source)75            .await?)76    }7778    async fn prompt_text(79        &self,80        echo: bool,81        prompt: &str,82        description: &str,83        source: &[Source],84    ) -> Result<String> {85        Ok(self.prompt_text(echo, prompt, description, source).await?)86    }8788    async fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> {89        Ok(self.display_text(error, description, source).await?)90    }91}92impl BlockingPrompter for DbusPrompterProxyBlocking<'_> {93    fn prompt_enum(94        &self,95        prompt: &str,96        description: &str,97        variants: &[&str],98        source: &[Source],99    ) -> Result<u32> {100        Ok(self.prompt_enum(prompt, description, variants, source)?)101    }102103    fn prompt_text(104        &self,105        echo: bool,106        prompt: &str,107        description: &str,108        source: &[Source],109    ) -> Result<String> {110        Ok(self.prompt_text(echo, prompt, description, source)?)111    }112113    fn display_text(&self, error: bool, description: &str, source: &[Source]) -> Result<()> {114        Ok(self.display_text(error, description, source)?)115    }116}117118impl From<fdo::Error> for Error {119    fn from(value: fdo::Error) -> Self {120        if matches!(value, fdo::Error::NoReply(_)) {121            return Self::Cancel;122        }123        Self::InputError(format!("{value}"))124    }125}126impl From<Error> for fdo::Error {127    fn from(value: Error) -> Self {128        match value {129            Error::Cancel => fdo::Error::NoReply("input was cancelled".to_owned()),130            Error::InputError(e) => fdo::Error::Failed(e),131        }132    }133}
modifiedcrates/ui-prompt/src/lib.rsdiffbeforeafterboth
--- a/crates/ui-prompt/src/lib.rs
+++ b/crates/ui-prompt/src/lib.rs
@@ -31,7 +31,17 @@
         prompt: &str,
         description: &str,
         source: &[Source],
-    ) -> impl Future<Output = Result<bool>> + Send;
+    ) -> 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,
@@ -47,7 +57,17 @@
     ) -> impl Future<Output = Result<()>> + Send;
 }
 pub trait BlockingPrompter {
-    fn prompt_radio(&self, prompt: &str, description: &str, source: &[Source]) -> Result<bool>;
+    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,
@@ -73,14 +93,15 @@
 where
     P: Prompter + Sync,
 {
-    async fn prompt_radio(
+    async fn prompt_enum(
         &self,
         prompt: &str,
         description: &str,
+        variants: &[&str],
         source: &[Source],
-    ) -> Result<bool> {
+    ) -> Result<u32> {
         self.prompter
-            .prompt_radio(prompt, description, &self.source(source))
+            .prompt_enum(prompt, description, variants, &self.source(source))
             .await
     }
 
modifiedcrates/ui-prompt/src/rofi.rsdiffbeforeafterboth
--- a/crates/ui-prompt/src/rofi.rs
+++ b/crates/ui-prompt/src/rofi.rs
@@ -8,13 +8,19 @@
 
 pub struct RofiPrompter;
 
+fn fixup_prompt(prompt: &str) -> &str {
+    // Rofi always appends such suffix
+    prompt.strip_suffix(": ").unwrap_or(prompt)
+}
+
 impl Prompter for RofiPrompter {
-    async fn prompt_radio(
+    async fn prompt_enum(
         &self,
         prompt: &str,
         description: &str,
+        variants: &[&str],
         source: &[Source],
-    ) -> Result<bool> {
+    ) -> Result<u32> {
         trace!("rofi radio");
         let mut cmd = Command::new("rofi");
         let mesg = if source.is_empty() {
@@ -34,7 +40,9 @@
             "-sync",
             "-only-match",
             "-p",
-            prompt,
+            fixup_prompt(prompt),
+            "-format",
+            "i",
         ]);
         cmd.stdin(Stdio::piped());
         cmd.stdout(Stdio::piped());
@@ -43,13 +51,20 @@
             .spawn()
             .map_err(|e| Error::InputError(format!("failed to spawn rofi: {e}")))?;
 
-        child
-            .stdin
-            .take()
-            .expect("stdin is piped")
-            .write_all(b"Yes\nNo\n")
-            .await
-            .map_err(|e| Error::InputError(format!("failed to write rofi variants: {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()
@@ -61,13 +76,15 @@
             .unwrap_or(&out.stdout)
             .to_owned();
 
-        if &stdout == b"Yes" {
-            Ok(true)
-        } else if &stdout == b"No" {
-            Ok(false)
-        } else {
-            Err(Error::InputError("bad rofi response".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(
@@ -89,7 +106,7 @@
             out.push_str("</b>");
             out
         };
-        cmd.args(["-dmenu", "-mesg", &mesg, "-p", prompt]);
+        cmd.args(["-dmenu", "-mesg", &mesg, "-p", fixup_prompt(prompt)]);
         if !echo {
             cmd.arg("-password");
         }