use std::time::Duration;
use freenet_stdlib::prelude::{ClientResponse, UserInputRequest};
pub(crate) const USER_INPUT_TIMEOUT: Duration = Duration::from_secs(60);
pub trait UserInputPrompter: Send + Sync {
fn prompt(
&self,
request: &UserInputRequest<'static>,
) -> impl std::future::Future<Output = Option<(usize, ClientResponse<'static>)>> + Send;
}
pub struct SubprocessPrompter;
impl SubprocessPrompter {
fn parse_message(request: &UserInputRequest<'_>) -> String {
let bytes = request.message.bytes();
if let Ok(json_str) = serde_json::from_slice::<String>(bytes) {
return json_str;
}
String::from_utf8(bytes.to_vec())
.unwrap_or_else(|_| "A delegate is requesting permission.".to_string())
}
fn parse_button_labels(request: &UserInputRequest<'_>) -> Vec<String> {
request
.responses
.iter()
.enumerate()
.map(|(i, r)| {
String::from_utf8((**r).to_vec()).unwrap_or_else(|_| format!("Option {}", i + 1))
})
.collect()
}
}
impl UserInputPrompter for SubprocessPrompter {
async fn prompt(
&self,
request: &UserInputRequest<'static>,
) -> Option<(usize, ClientResponse<'static>)> {
let message = Self::parse_message(request);
let labels = Self::parse_button_labels(request);
if labels.is_empty() {
tracing::warn!("RequestUserInput has no response options");
return None;
}
let labels_json = match serde_json::to_string(&labels) {
Ok(json) => json,
Err(e) => {
tracing::error!(error = %e, "Failed to serialize button labels");
return None;
}
};
let exe = match std::env::current_exe() {
Ok(path) => path,
Err(e) => {
tracing::error!(error = %e, "Failed to determine current executable path");
return None;
}
};
let result = tokio::time::timeout(USER_INPUT_TIMEOUT, async {
tokio::process::Command::new(&exe)
.arg("prompt")
.arg("--message")
.arg(&message)
.arg("--buttons")
.arg(&labels_json)
.arg("--timeout")
.arg(USER_INPUT_TIMEOUT.as_secs().to_string())
.output()
.await
})
.await;
match result {
Ok(Ok(output)) => {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
match stdout.parse::<i32>() {
Ok(idx) if idx >= 0 && (idx as usize) < request.responses.len() => {
let idx = idx as usize;
let response = request.responses[idx].clone().into_owned();
Some((idx, response))
}
Ok(_) => {
tracing::debug!("User dismissed or denied the prompt");
None
}
Err(e) => {
tracing::warn!(
stdout = %stdout,
error = %e,
"Failed to parse prompt subprocess output"
);
None
}
}
}
Ok(Err(e)) => {
tracing::warn!(error = %e, "Failed to spawn prompt subprocess");
None
}
Err(_) => {
tracing::warn!("User input prompt timed out");
None
}
}
}
}
pub struct AutoApprovePrompter;
impl UserInputPrompter for AutoApprovePrompter {
async fn prompt(
&self,
request: &UserInputRequest<'static>,
) -> Option<(usize, ClientResponse<'static>)> {
request
.responses
.first()
.map(|r| (0, r.clone().into_owned()))
}
}
#[allow(dead_code)]
pub struct AutoDenyPrompter;
impl UserInputPrompter for AutoDenyPrompter {
async fn prompt(
&self,
_request: &UserInputRequest<'static>,
) -> Option<(usize, ClientResponse<'static>)> {
None
}
}
#[cfg(test)]
pub(crate) fn make_test_request(message: &str, responses: Vec<&str>) -> UserInputRequest<'static> {
use freenet_stdlib::prelude::NotificationMessage;
let msg = NotificationMessage::try_from(&serde_json::Value::String(message.to_string()))
.expect("valid JSON");
UserInputRequest {
request_id: 1,
message: msg,
responses: responses
.into_iter()
.map(|r| ClientResponse::new(r.as_bytes().to_vec()))
.collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_auto_approve_returns_first_response() {
let req = make_test_request("Allow this?", vec!["Allow", "Deny"]);
let result = AutoApprovePrompter.prompt(&req).await;
assert!(result.is_some());
let (idx, response) = result.unwrap();
assert_eq!(idx, 0);
assert_eq!(&*response, b"Allow");
}
#[tokio::test]
async fn test_auto_approve_empty_responses() {
let req = make_test_request("Allow this?", vec![]);
let result = AutoApprovePrompter.prompt(&req).await;
assert!(result.is_none());
}
#[tokio::test]
async fn test_auto_deny_always_returns_none() {
let req = make_test_request("Allow this?", vec!["Allow", "Deny"]);
let result = AutoDenyPrompter.prompt(&req).await;
assert!(result.is_none());
}
#[test]
fn test_parse_button_labels() {
let req = make_test_request("msg", vec!["Allow Once", "Always Allow", "Deny"]);
let labels = SubprocessPrompter::parse_button_labels(&req);
assert_eq!(labels, vec!["Allow Once", "Always Allow", "Deny"]);
}
#[test]
fn test_parse_button_labels_invalid_utf8() {
use freenet_stdlib::prelude::NotificationMessage;
let req = UserInputRequest {
request_id: 1,
message: NotificationMessage::try_from(&serde_json::Value::String("msg".to_string()))
.unwrap(),
responses: vec![
ClientResponse::new(b"Valid".to_vec()),
ClientResponse::new(vec![0xFF, 0xFE]),
],
};
let labels = SubprocessPrompter::parse_button_labels(&req);
assert_eq!(labels, vec!["Valid", "Option 2"]);
}
#[test]
fn test_parse_message_json_encoded() {
let req = make_test_request("Hello world", vec![]);
let msg = SubprocessPrompter::parse_message(&req);
assert_eq!(msg, "Hello world");
}
#[test]
fn test_parse_message_raw_utf8() {
use freenet_stdlib::prelude::NotificationMessage;
let raw_msg =
NotificationMessage::try_from(&serde_json::Value::String("Raw message".to_string()))
.unwrap();
let req = UserInputRequest {
request_id: 1,
message: raw_msg,
responses: vec![],
};
let msg = SubprocessPrompter::parse_message(&req);
assert_eq!(msg, "Raw message");
}
#[test]
fn test_parse_message_json_with_quotes() {
use freenet_stdlib::prelude::NotificationMessage;
let json_val = serde_json::Value::String("Test with \"quotes\"".to_string());
let msg = NotificationMessage::try_from(&json_val).unwrap();
let req = UserInputRequest {
request_id: 1,
message: msg,
responses: vec![],
};
let parsed = SubprocessPrompter::parse_message(&req);
assert_eq!(parsed, "Test with \"quotes\"");
}
#[tokio::test]
async fn test_auto_approve_with_three_responses() {
let req = make_test_request("Allow?", vec!["Allow Once", "Always Allow", "Deny"]);
let result = AutoApprovePrompter.prompt(&req).await;
let (idx, response) = result.unwrap();
assert_eq!(idx, 0);
assert_eq!(&*response, b"Allow Once");
}
#[tokio::test]
async fn test_auto_deny_with_multiple_responses() {
let req = make_test_request("Allow?", vec!["Allow Once", "Always Allow", "Deny"]);
let result = AutoDenyPrompter.prompt(&req).await;
assert!(result.is_none());
}
}