mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-20 06:28:19 +02:00
554 lines
17 KiB
Rust
554 lines
17 KiB
Rust
//! Feedback to the Donut team, from the person (the feedback dialog) or from an
|
|
//! agent (the `send_feedback` MCP tool). The cloud stores it; anyone may send
|
|
//! it, signed in or not. A signed-in sender is named by their access token, so
|
|
//! a reply can reach them.
|
|
|
|
use crate::cloud_errors;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::VecDeque;
|
|
use std::path::PathBuf;
|
|
use std::sync::{Mutex, OnceLock};
|
|
use std::time::{Duration, Instant};
|
|
|
|
pub const MAX_MESSAGE_CHARS: usize = 5000;
|
|
/// The cloud refuses bodies over 1 MiB; this leaves room for JSON escaping.
|
|
pub const MAX_LOG_BYTES: usize = 512 * 1024;
|
|
const MAX_EMAIL_CHARS: usize = 254;
|
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
|
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
|
/// An agent caught in a loop must not flood the team's inbox.
|
|
const AGENT_SENDS_PER_HOUR: usize = 10;
|
|
const TRANSPORT_PREFIX: &str = "reach backend: ";
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum FeedbackKind {
|
|
Bug,
|
|
Idea,
|
|
Praise,
|
|
Other,
|
|
}
|
|
|
|
impl FeedbackKind {
|
|
pub fn parse(value: &str) -> Option<Self> {
|
|
match value {
|
|
"bug" => Some(Self::Bug),
|
|
"idea" => Some(Self::Idea),
|
|
"praise" => Some(Self::Praise),
|
|
"other" => Some(Self::Other),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Bug => "bug",
|
|
Self::Idea => "idea",
|
|
Self::Praise => "praise",
|
|
Self::Other => "other",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum FeedbackSource {
|
|
Person,
|
|
Agent,
|
|
}
|
|
|
|
impl FeedbackSource {
|
|
fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Person => "person",
|
|
Self::Agent => "agent",
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct Feedback {
|
|
pub kind: FeedbackKind,
|
|
pub source: FeedbackSource,
|
|
pub message: String,
|
|
/// Where to reply, for a sender who is not signed in.
|
|
pub email: Option<String>,
|
|
pub locale: Option<String>,
|
|
pub logs: Option<String>,
|
|
pub agent_client: Option<String>,
|
|
pub context: Option<serde_json::Value>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct Payload<'a> {
|
|
kind: FeedbackKind,
|
|
source: FeedbackSource,
|
|
message: &'a str,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
email: Option<&'a str>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
logs: Option<&'a str>,
|
|
app_version: String,
|
|
os: String,
|
|
arch: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
locale: Option<&'a str>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
agent_client: Option<&'a str>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
context: Option<&'a serde_json::Value>,
|
|
}
|
|
|
|
impl<'a> Payload<'a> {
|
|
fn of(feedback: &'a Feedback) -> Self {
|
|
let info = crate::settings_manager::get_system_info();
|
|
Self {
|
|
kind: feedback.kind,
|
|
source: feedback.source,
|
|
message: &feedback.message,
|
|
email: feedback.email.as_deref(),
|
|
logs: feedback.logs.as_deref().filter(|logs| !logs.is_empty()),
|
|
app_version: info.app_version,
|
|
os: info.os,
|
|
arch: info.arch,
|
|
locale: feedback.locale.as_deref(),
|
|
agent_client: feedback.agent_client.as_deref(),
|
|
context: feedback.context.as_ref(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct FeedbackReceipt {
|
|
pub id: String,
|
|
}
|
|
|
|
/// The message as it is sent: trimmed, not empty, not too long.
|
|
pub fn validate_message(message: &str) -> Result<String, String> {
|
|
let message = message.trim();
|
|
if message.is_empty() {
|
|
return Err(crate::backend_error("FEEDBACK_MESSAGE_EMPTY"));
|
|
}
|
|
if message.chars().count() > MAX_MESSAGE_CHARS {
|
|
return Err(
|
|
serde_json::json!({
|
|
"code": "FEEDBACK_MESSAGE_TOO_LONG",
|
|
"params": { "max": MAX_MESSAGE_CHARS.to_string() }
|
|
})
|
|
.to_string(),
|
|
);
|
|
}
|
|
Ok(message.to_string())
|
|
}
|
|
|
|
/// A reply address: blank is none, anything else must look like an address.
|
|
pub fn validate_email(email: Option<&str>) -> Result<Option<String>, String> {
|
|
let Some(email) = email.map(str::trim).filter(|email| !email.is_empty()) else {
|
|
return Ok(None);
|
|
};
|
|
let plausible = email.len() <= MAX_EMAIL_CHARS
|
|
&& !email.chars().any(char::is_whitespace)
|
|
&& email.split_once('@').is_some_and(|(local, domain)| {
|
|
!local.is_empty()
|
|
&& !domain.contains('@')
|
|
&& domain
|
|
.split_once('.')
|
|
.is_some_and(|(host, tld)| !host.is_empty() && !tld.is_empty() && !tld.ends_with('.'))
|
|
});
|
|
if plausible {
|
|
Ok(Some(email.to_string()))
|
|
} else {
|
|
Err(crate::backend_error("FEEDBACK_EMAIL_INVALID"))
|
|
}
|
|
}
|
|
|
|
/// The log excerpt feedback carries: the newest lines, redacted, at most
|
|
/// `MAX_LOG_BYTES`. Empty when the app has not written a log yet.
|
|
pub async fn log_excerpt(dir: PathBuf) -> Result<String, String> {
|
|
tokio::task::spawn_blocking(move || {
|
|
if !dir.exists() {
|
|
return Ok(String::new());
|
|
}
|
|
crate::settings_manager::recent_logs(&dir, MAX_LOG_BYTES)
|
|
})
|
|
.await
|
|
.map_err(|e| crate::backend_error_with_detail("FEEDBACK_LOGS_UNREADABLE", e))?
|
|
.map_err(|e| crate::backend_error_with_detail("FEEDBACK_LOGS_UNREADABLE", e))
|
|
}
|
|
|
|
/// Whether an agent may send one more piece of feedback this hour. Counts the
|
|
/// attempt when it may.
|
|
pub fn agent_send_allowed() -> bool {
|
|
static SENDS: Mutex<VecDeque<Instant>> = Mutex::new(VecDeque::new());
|
|
let mut sends = SENDS
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
allow_send(&mut sends, Instant::now(), AGENT_SENDS_PER_HOUR)
|
|
}
|
|
|
|
fn allow_send(sends: &mut VecDeque<Instant>, now: Instant, per_hour: usize) -> bool {
|
|
let hour = Duration::from_secs(3600);
|
|
while sends
|
|
.front()
|
|
.is_some_and(|at| now.saturating_duration_since(*at) >= hour)
|
|
{
|
|
sends.pop_front();
|
|
}
|
|
if sends.len() >= per_hour {
|
|
return false;
|
|
}
|
|
sends.push_back(now);
|
|
true
|
|
}
|
|
|
|
/// Send feedback to the cloud. A signed-in sender's access token goes with it;
|
|
/// when the session cannot be used, the feedback is sent without one rather
|
|
/// than lost.
|
|
pub async fn send(feedback: Feedback) -> Result<FeedbackReceipt, String> {
|
|
let body = serde_json::to_vec(&Payload::of(&feedback))
|
|
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?;
|
|
let logs_bytes = feedback.logs.as_ref().map_or(0, String::len);
|
|
let url = endpoint();
|
|
let started = Instant::now();
|
|
|
|
let auth = &crate::cloud_auth::CLOUD_AUTH;
|
|
let result = if auth.is_logged_in().await {
|
|
match auth
|
|
.api_call_with_retry(|token| post(url.clone(), body.clone(), Some(token)))
|
|
.await
|
|
{
|
|
Err(error) if retry_without_account(&error) => post(url, body, None).await,
|
|
other => other,
|
|
}
|
|
} else {
|
|
post(url, body, None).await
|
|
};
|
|
|
|
let elapsed_ms = started.elapsed().as_millis();
|
|
match result {
|
|
Ok(receipt) => {
|
|
log::info!(
|
|
"Feedback sent id={} source={} kind={} logs_bytes={logs_bytes} elapsed_ms={elapsed_ms}",
|
|
receipt.id,
|
|
feedback.source.as_str(),
|
|
feedback.kind.as_str(),
|
|
);
|
|
Ok(receipt)
|
|
}
|
|
Err(error) => {
|
|
let coded = failure_code(&error);
|
|
log::warn!(
|
|
"Feedback send failed source={} kind={} err={coded} elapsed_ms={elapsed_ms}",
|
|
feedback.source.as_str(),
|
|
feedback.kind.as_str(),
|
|
);
|
|
Err(coded)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn endpoint() -> String {
|
|
// A test build never reaches the production API: it posts to the suite's
|
|
// capture server, or to a port where nothing listens.
|
|
#[cfg(feature = "e2e")]
|
|
{
|
|
std::env::var("DONUT_E2E_FEEDBACK_URL")
|
|
.ok()
|
|
.filter(|url| !url.is_empty())
|
|
.unwrap_or_else(|| "http://127.0.0.1:9/api/feedback".to_string())
|
|
}
|
|
#[cfg(not(feature = "e2e"))]
|
|
{
|
|
format!("{}/api/feedback", crate::cloud_auth::CLOUD_API_URL)
|
|
}
|
|
}
|
|
|
|
fn http() -> &'static reqwest::Client {
|
|
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
|
|
CLIENT.get_or_init(|| {
|
|
reqwest::Client::builder()
|
|
.timeout(REQUEST_TIMEOUT)
|
|
.connect_timeout(CONNECT_TIMEOUT)
|
|
.build()
|
|
.unwrap_or_else(|_| reqwest::Client::new())
|
|
})
|
|
}
|
|
|
|
async fn post(
|
|
url: String,
|
|
body: Vec<u8>,
|
|
token: Option<String>,
|
|
) -> Result<FeedbackReceipt, String> {
|
|
let mut request = http()
|
|
.post(&url)
|
|
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
|
.body(body);
|
|
if let Some(token) = token {
|
|
request = request.bearer_auth(token);
|
|
}
|
|
let response = request.send().await.map_err(|e| {
|
|
crate::system_proxy::explain(&e).unwrap_or_else(|| format!("{TRANSPORT_PREFIX}{e}"))
|
|
})?;
|
|
let status = response.status().as_u16();
|
|
if !(200..300).contains(&status) {
|
|
let text = response.text().await.unwrap_or_default();
|
|
// api_call_with_retry and cloud_errors::split_status read the status from this prefix.
|
|
return Err(format!("({status}) {text}"));
|
|
}
|
|
response
|
|
.json::<FeedbackReceipt>()
|
|
.await
|
|
.map_err(|e| format!("({status}) decode response: {e}"))
|
|
}
|
|
|
|
/// The account could not be used: its token was refused, or it could not be
|
|
/// refreshed before anything was sent.
|
|
fn retry_without_account(error: &str) -> bool {
|
|
match cloud_errors::split_status(error) {
|
|
Some((status, _)) => status == 401,
|
|
None => {
|
|
!error.starts_with(TRANSPORT_PREFIX) && !crate::system_proxy::is_unreachable_error(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn failure_code(error: &str) -> String {
|
|
if crate::system_proxy::is_unreachable_error(error) {
|
|
return error.to_string();
|
|
}
|
|
match cloud_errors::split_status(error) {
|
|
Some((429, _)) => crate::backend_error("FEEDBACK_RATE_LIMITED"),
|
|
Some((400 | 413 | 422, body)) if body.contains("FEEDBACK_MESSAGE_EMPTY") => {
|
|
crate::backend_error("FEEDBACK_MESSAGE_EMPTY")
|
|
}
|
|
Some((400 | 413 | 422, _)) => crate::backend_error("FEEDBACK_INVALID"),
|
|
Some(_) => crate::backend_error("FEEDBACK_SEND_FAILED"),
|
|
None => crate::backend_error(cloud_errors::UNREACHABLE),
|
|
}
|
|
}
|
|
|
|
/// Send the person's feedback from the feedback dialog.
|
|
#[tauri::command]
|
|
pub async fn send_feedback(
|
|
app_handle: tauri::AppHandle,
|
|
kind: String,
|
|
message: String,
|
|
include_logs: bool,
|
|
email: Option<String>,
|
|
locale: Option<String>,
|
|
) -> Result<FeedbackReceipt, String> {
|
|
let kind = FeedbackKind::parse(&kind).ok_or_else(|| crate::backend_error("FEEDBACK_INVALID"))?;
|
|
let message = validate_message(&message)?;
|
|
let email = validate_email(email.as_deref())?;
|
|
let logs = if include_logs {
|
|
Some(log_excerpt(crate::app_dirs::log_dir(&app_handle)).await?)
|
|
} else {
|
|
None
|
|
};
|
|
let receipt = send(Feedback {
|
|
kind,
|
|
source: FeedbackSource::Person,
|
|
message,
|
|
email,
|
|
locale: locale.filter(|locale| !locale.trim().is_empty()),
|
|
logs,
|
|
agent_client: None,
|
|
context: None,
|
|
})
|
|
.await?;
|
|
tauri::async_runtime::spawn_blocking(crate::settings_manager::note_feedback_sent);
|
|
Ok(receipt)
|
|
}
|
|
|
|
/// Exactly the log excerpt `send_feedback` would attach, so the person can read
|
|
/// it before sending.
|
|
#[tauri::command]
|
|
pub async fn preview_feedback_logs(app_handle: tauri::AppHandle) -> Result<String, String> {
|
|
log_excerpt(crate::app_dirs::log_dir(&app_handle)).await
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use wiremock::matchers::{header, header_exists, method, path};
|
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
|
|
fn code_of(error: &str) -> String {
|
|
serde_json::from_str::<serde_json::Value>(error).unwrap()["code"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_string()
|
|
}
|
|
|
|
#[test]
|
|
fn kinds_round_trip_and_unknown_ones_are_refused() {
|
|
for kind in ["bug", "idea", "praise", "other"] {
|
|
assert_eq!(FeedbackKind::parse(kind).unwrap().as_str(), kind);
|
|
}
|
|
assert_eq!(FeedbackKind::parse("Bug"), None);
|
|
assert_eq!(FeedbackKind::parse(""), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_message_is_trimmed_and_bounded() {
|
|
assert_eq!(validate_message(" Hello \n").unwrap(), "Hello");
|
|
assert_eq!(
|
|
code_of(&validate_message(" \n\t ").unwrap_err()),
|
|
"FEEDBACK_MESSAGE_EMPTY"
|
|
);
|
|
let at_limit = "é".repeat(MAX_MESSAGE_CHARS);
|
|
assert_eq!(validate_message(&at_limit).unwrap(), at_limit);
|
|
let over = validate_message(&"a".repeat(MAX_MESSAGE_CHARS + 1)).unwrap_err();
|
|
assert_eq!(code_of(&over), "FEEDBACK_MESSAGE_TOO_LONG");
|
|
}
|
|
|
|
#[test]
|
|
fn a_reply_address_is_optional_but_must_be_plausible() {
|
|
assert_eq!(validate_email(None).unwrap(), None);
|
|
assert_eq!(validate_email(Some(" ")).unwrap(), None);
|
|
assert_eq!(
|
|
validate_email(Some(" me@example.com ")).unwrap().as_deref(),
|
|
Some("me@example.com")
|
|
);
|
|
for bad in [
|
|
"me",
|
|
"me@",
|
|
"@example.com",
|
|
"me@example",
|
|
"a b@example.com",
|
|
"a@b@c.com",
|
|
"me@example.",
|
|
] {
|
|
assert_eq!(
|
|
code_of(&validate_email(Some(bad)).unwrap_err()),
|
|
"FEEDBACK_EMAIL_INVALID",
|
|
"{bad}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_agent_may_send_a_bounded_amount_per_hour() {
|
|
let mut sends = VecDeque::new();
|
|
let start = Instant::now();
|
|
for _ in 0..3 {
|
|
assert!(allow_send(&mut sends, start, 3));
|
|
}
|
|
assert!(!allow_send(&mut sends, start + Duration::from_secs(60), 3));
|
|
assert!(allow_send(&mut sends, start + Duration::from_secs(3600), 3));
|
|
}
|
|
|
|
#[test]
|
|
fn failures_become_codes_the_dialog_can_explain() {
|
|
assert_eq!(
|
|
code_of(&failure_code("(429) slow down")),
|
|
"FEEDBACK_RATE_LIMITED"
|
|
);
|
|
assert_eq!(
|
|
code_of(&failure_code(r#"(400) {"code":"FEEDBACK_MESSAGE_EMPTY"}"#)),
|
|
"FEEDBACK_MESSAGE_EMPTY"
|
|
);
|
|
assert_eq!(code_of(&failure_code("(400) bad")), "FEEDBACK_INVALID");
|
|
assert_eq!(
|
|
code_of(&failure_code("(502) gateway")),
|
|
"FEEDBACK_SEND_FAILED"
|
|
);
|
|
assert_eq!(
|
|
code_of(&failure_code("reach backend: connection refused")),
|
|
cloud_errors::UNREACHABLE
|
|
);
|
|
let proxied = crate::system_proxy::unreachable_error("127.0.0.1:8888");
|
|
assert_eq!(failure_code(&proxied), proxied);
|
|
}
|
|
|
|
#[test]
|
|
fn only_an_unusable_account_is_retried_without_it() {
|
|
assert!(retry_without_account("(401) expired"));
|
|
assert!(retry_without_account("Not logged in"));
|
|
assert!(!retry_without_account("(429) slow down"));
|
|
assert!(!retry_without_account("(500) broken"));
|
|
assert!(!retry_without_account("reach backend: timed out"));
|
|
assert!(!retry_without_account(
|
|
&crate::system_proxy::unreachable_error("127.0.0.1:8888")
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_payload_carries_the_app_and_the_bearer_when_there_is_one() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/api/feedback"))
|
|
.and(header("authorization", "Bearer t0ken"))
|
|
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
|
|
"id": "f-1", "createdAt": "2026-10-09T00:00:00.000Z"
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
let feedback = Feedback {
|
|
kind: FeedbackKind::Bug,
|
|
source: FeedbackSource::Agent,
|
|
message: "navigate times out".to_string(),
|
|
email: None,
|
|
locale: Some("en".to_string()),
|
|
logs: Some("line\n".to_string()),
|
|
agent_client: Some("Claude Code 2.1.0".to_string()),
|
|
context: Some(serde_json::json!({ "recentCalls": [] })),
|
|
};
|
|
let body = serde_json::to_vec(&Payload::of(&feedback)).unwrap();
|
|
let receipt = post(
|
|
format!("{}/api/feedback", server.uri()),
|
|
body,
|
|
Some("t0ken".to_string()),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(receipt.id, "f-1");
|
|
|
|
let sent: serde_json::Value =
|
|
serde_json::from_slice(&server.received_requests().await.unwrap()[0].body).unwrap();
|
|
assert_eq!(sent["kind"], "bug");
|
|
assert_eq!(sent["source"], "agent");
|
|
assert_eq!(sent["logs"], "line\n");
|
|
assert_eq!(sent["agentClient"], "Claude Code 2.1.0");
|
|
assert_eq!(sent["locale"], "en");
|
|
assert!(sent["appVersion"].is_string() && sent["os"].is_string() && sent["arch"].is_string());
|
|
assert!(sent.get("email").is_none(), "{sent}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_anonymous_post_has_no_bearer_and_a_refusal_keeps_its_status() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/api/feedback"))
|
|
.and(header_exists("content-type"))
|
|
.respond_with(ResponseTemplate::new(429))
|
|
.mount(&server)
|
|
.await;
|
|
let feedback = Feedback {
|
|
kind: FeedbackKind::Idea,
|
|
source: FeedbackSource::Person,
|
|
message: "More themes".to_string(),
|
|
email: Some("me@example.com".to_string()),
|
|
locale: None,
|
|
logs: Some(String::new()),
|
|
agent_client: None,
|
|
context: None,
|
|
};
|
|
let body = serde_json::to_vec(&Payload::of(&feedback)).unwrap();
|
|
let error = post(format!("{}/api/feedback", server.uri()), body, None)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(code_of(&failure_code(&error)), "FEEDBACK_RATE_LIMITED");
|
|
|
|
let request = &server.received_requests().await.unwrap()[0];
|
|
assert!(request.headers.get("authorization").is_none());
|
|
let sent: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
|
|
assert_eq!(sent["email"], "me@example.com");
|
|
assert!(
|
|
sent.get("logs").is_none(),
|
|
"empty logs are not sent: {sent}"
|
|
);
|
|
}
|
|
}
|