Add to README.

This commit is contained in:
2026-04-17 20:00:37 +01:00
parent 802fb08431
commit 085e622467
2 changed files with 322 additions and 287 deletions

View File

@@ -82,7 +82,7 @@ login, and attempt to prompt you for your password.
- [`matrix-commander-rs`](https://github.com/8go/matrix-commander-rs) - [`matrix-commander-rs`](https://github.com/8go/matrix-commander-rs)
- [Matrix](https://matrix.org/) - [Matrix](https://matrix.org/)
- Your Matrix user - Your Matrix user
- A Matrix bot user (just a normal user with a noticeable username like `notification-bot`) - A Matrix bot user (just a normal user with a noticeable username like `gpg-bot`)
- A private Matrix channel between you and the bot (preferably encrypted). - A private Matrix channel between you and the bot (preferably encrypted).
- A Matrix client app for mobile, e.g. [Element](https://element.io/) - A Matrix client app for mobile, e.g. [Element](https://element.io/)
- A proxy server with HTTPS support (E.g. [`caddy`](https://caddyserver.com/)) - A proxy server with HTTPS support (E.g. [`caddy`](https://caddyserver.com/))
@@ -92,6 +92,26 @@ For development contributors only:
## Installation instructions ## Installation instructions
Configure the domain used for login links. This should be a subdomain you
control, e.g. `gpg.yourdomain.com`:
```{bash}
mkdir -p "$HOME/.config/web-pinentry"
echo "gpg.yourdomain.com" >> "$HOME/.config/web-pinentry/domain"
```
Install and setup a reverse proxy with HTTPS support, e.g. `caddy`:
- Install `caddy` / `nginx` / `apache` or any other reverse proxy with HTTPS support.
- Configure the reverse proxy to forward requests from your chosen domain to
127.0.0.1:7563, where `web-pinentry` will be running its HTTP server. For example, with `caddy`:
```{caddyfile}
gpg.yourdomain.com {
reverse_proxy 127.0.0.1:7563
}
```
Building and installing the `web-pinentry` program: Building and installing the `web-pinentry` program:
```{bash} ```{bash}
@@ -140,7 +160,7 @@ Testing that you can receive messages from the bot:
matrix-commander-rs --message 'Hello world!' matrix-commander-rs --message 'Hello world!'
``` ```
The above should trigger a notification on your phone via the Matrix client app The above should trigger a message notification on your phone via the Matrix client app
you have installed. you have installed.
You might notice the message come with a warning about the bot's client. You might notice the message come with a warning about the bot's client.

View File

@@ -1,5 +1,5 @@
use std::env; use std::env;
use std::fs::File; use std::fs::{self, File};
use std::io::{self, BufRead, BufReader, Read, Write}; use std::io::{self, BufRead, BufReader, Read, Write};
use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener, TcpStream}; use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener, TcpStream};
use std::process; use std::process;
@@ -8,384 +8,399 @@ const PATH_CHARS: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuv
const MAX_ROUTE_HITS: u8 = 2; const MAX_ROUTE_HITS: u8 = 2;
fn write_ok(stdout: &mut impl Write) -> io::Result<()> { fn write_ok(stdout: &mut impl Write) -> io::Result<()> {
writeln!(stdout, "OK")?; writeln!(stdout, "OK")?;
stdout.flush() stdout.flush()
} }
fn write_data(stdout: &mut impl Write, value: &str) -> io::Result<()> { fn write_data(stdout: &mut impl Write, value: &str) -> io::Result<()> {
let mut encoded = String::with_capacity(value.len()); let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() { for byte in value.bytes() {
match byte { match byte {
b'\r' => encoded.push_str("%0D"), b'\r' => encoded.push_str("%0D"),
b'\n' => encoded.push_str("%0A"), b'\n' => encoded.push_str("%0A"),
b'%' => encoded.push_str("%25"), b'%' => encoded.push_str("%25"),
_ => encoded.push(char::from(byte)), _ => encoded.push(char::from(byte)),
} }
} }
writeln!(stdout, "D {encoded}")?; writeln!(stdout, "D {encoded}")?;
stdout.flush() stdout.flush()
} }
fn write_err(stdout: &mut impl Write, code: u32, message: &str) -> io::Result<()> { fn write_err(stdout: &mut impl Write, code: u32, message: &str) -> io::Result<()> {
writeln!(stdout, "ERR {code} {message}")?; writeln!(stdout, "ERR {code} {message}")?;
stdout.flush() stdout.flush()
} }
fn assuan_unescape(value: &str) -> String { fn assuan_unescape(value: &str) -> String {
let bytes = value.as_bytes(); let bytes = value.as_bytes();
let mut output = Vec::with_capacity(bytes.len()); let mut output = Vec::with_capacity(bytes.len());
let mut index = 0; let mut index = 0;
while index < bytes.len() { while index < bytes.len() {
if bytes[index] == b'%' && index + 2 < bytes.len() { if bytes[index] == b'%' && index + 2 < bytes.len() {
let hex = &value[index + 1..index + 3]; let hex = &value[index + 1..index + 3];
if let Ok(decoded) = u8::from_str_radix(hex, 16) { if let Ok(decoded) = u8::from_str_radix(hex, 16) {
output.push(decoded); output.push(decoded);
index += 3; index += 3;
continue; continue;
} }
} }
output.push(bytes[index]); output.push(bytes[index]);
index += 1; index += 1;
} }
String::from_utf8_lossy(&output).into_owned() String::from_utf8_lossy(&output).into_owned()
} }
fn percent_decode(value: &str) -> String { fn percent_decode(value: &str) -> String {
let bytes = value.as_bytes(); let bytes = value.as_bytes();
let mut output = Vec::with_capacity(bytes.len()); let mut output = Vec::with_capacity(bytes.len());
let mut index = 0; let mut index = 0;
while index < bytes.len() { while index < bytes.len() {
match bytes[index] { match bytes[index] {
b'+' => { b'+' => {
output.push(b' '); output.push(b' ');
index += 1; index += 1;
} }
b'%' if index + 2 < bytes.len() => { b'%' if index + 2 < bytes.len() => {
let hex = &value[index + 1..index + 3]; let hex = &value[index + 1..index + 3];
if let Ok(decoded) = u8::from_str_radix(hex, 16) { if let Ok(decoded) = u8::from_str_radix(hex, 16) {
output.push(decoded); output.push(decoded);
index += 3; index += 3;
} else { } else {
output.push(bytes[index]); output.push(bytes[index]);
index += 1; index += 1;
} }
} }
byte => { byte => {
output.push(byte); output.push(byte);
index += 1; index += 1;
} }
} }
} }
String::from_utf8_lossy(&output).into_owned() String::from_utf8_lossy(&output).into_owned()
} }
fn html_escape(value: &str) -> String { fn html_escape(value: &str) -> String {
let mut escaped = String::with_capacity(value.len()); let mut escaped = String::with_capacity(value.len());
for ch in value.chars() { for ch in value.chars() {
match ch { match ch {
'&' => escaped.push_str("&amp;"), '&' => escaped.push_str("&amp;"),
'<' => escaped.push_str("&lt;"), '<' => escaped.push_str("&lt;"),
'>' => escaped.push_str("&gt;"), '>' => escaped.push_str("&gt;"),
'"' => escaped.push_str("&quot;"), '"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&#39;"), '\'' => escaped.push_str("&#39;"),
_ => escaped.push(ch), _ => escaped.push(ch),
} }
} }
escaped escaped
} }
fn parse_form_value(body: &str, key: &str) -> Option<String> { fn parse_form_value(body: &str, key: &str) -> Option<String> {
for pair in body.split('&') { for pair in body.split('&') {
let mut parts = pair.splitn(2, '='); let mut parts = pair.splitn(2, '=');
let form_key = parts.next()?; let form_key = parts.next()?;
let form_value = parts.next().unwrap_or_default(); let form_value = parts.next().unwrap_or_default();
if percent_decode(form_key) == key { if percent_decode(form_key) == key {
return Some(percent_decode(form_value)); return Some(percent_decode(form_value));
} }
} }
None None
} }
fn random_path_segment() -> io::Result<String> { fn random_path_segment() -> io::Result<String> {
let mut bytes = [0u8; 6]; let mut bytes = [0u8; 6];
File::open("/dev/urandom")?.read_exact(&mut bytes)?; File::open("/dev/urandom")?.read_exact(&mut bytes)?;
Ok(bytes Ok(bytes
.iter() .iter()
.map(|byte| PATH_CHARS[(byte & 63) as usize] as char) .map(|byte| PATH_CHARS[(byte & 63) as usize] as char)
.collect()) .collect())
}
fn advertised_domain() -> io::Result<String> {
let home = env::var("HOME").map_err(|error| io::Error::new(io::ErrorKind::NotFound, error))?;
let domain = fs::read_to_string(format!("{home}/.config/web-pinentry/domain"))?;
let domain = domain.trim();
if domain.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"domain file is empty",
));
}
Ok(domain.to_string())
} }
fn advertised_url(route_path: &str) -> io::Result<String> { fn advertised_url(route_path: &str) -> io::Result<String> {
Ok(format!("https://gpg.seanhealy.ie{route_path}")) Ok(format!("https://{}{route_path}", advertised_domain()?))
} }
fn send_url_message(url: &str) -> io::Result<()> { fn send_url_message(url: &str) -> io::Result<()> {
let status = process::Command::new("matrix-commander-rs") let status = process::Command::new("matrix-commander-rs")
.arg("--message") .arg("--message")
.arg(url) .arg(url)
.status()?; .status()?;
if status.success() { if status.success() {
return Ok(()); return Ok(());
} }
Err(io::Error::other(format!( Err(io::Error::other(format!(
"matrix-commander-rs exited with status {status}" "matrix-commander-rs exited with status {status}"
))) )))
} }
/** /**
* Allow any connections from docker or 127.0.0.1. * Allow any connections from docker or 127.0.0.1.
* Docker IPs looks like: 172.17.x.x or 172.18.x.x. * Docker IPs looks like: 172.17.x.x, 172.18.x.x. etc.
*/ */
fn is_allowed_client(address: SocketAddr) -> bool { fn is_allowed_client(address: SocketAddr) -> bool {
matches!(address.ip(), IpAddr::V4(ip) if ip == Ipv4Addr::LOCALHOST || matches!(address.ip(), IpAddr::V4(ip) if ip == Ipv4Addr::LOCALHOST ||
(ip.octets()[0] == 172 && ip.octets()[1] >= 16 && ip.octets()[1] <= 31)) (ip.octets()[0] == 172 && ip.octets()[1] >= 16 && ip.octets()[1] <= 31))
} }
fn send_http_response( fn send_http_response(
stream: &mut TcpStream, stream: &mut TcpStream,
status: &str, status: &str,
content_type: &str, content_type: &str,
body: &str, body: &str,
) -> io::Result<()> { ) -> io::Result<()> {
write!( write!(
stream, stream,
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n{body}", "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n{body}",
body.len() body.len()
)?; )?;
stream.flush() stream.flush()
} }
fn render_form(title: &str, description: &str, prompt: &str, url: &str, route_path: &str) -> String { fn render_form(title: &str, description: &str, prompt: &str, url: &str, route_path: &str) -> String {
let title = html_escape(title); let title = html_escape(title);
let description = html_escape(description); let description = html_escape(description);
let prompt = html_escape(prompt); let prompt = html_escape(prompt);
let url = html_escape(url); let url = html_escape(url);
let route_path = html_escape(route_path); let route_path = html_escape(route_path);
format!( format!(
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>{title}</title><style>body{{font-family:sans-serif;background:#f6f4ee;color:#1f1f1f;margin:0;min-height:100vh;display:grid;place-items:center}}main{{width:min(28rem,calc(100vw - 2rem));background:#fffdf8;border:1px solid #d9d1c3;border-radius:16px;padding:2rem;box-shadow:0 16px 50px rgba(40,32,20,.12)}}h1{{margin:0 0 1rem;font-size:1.5rem}}p{{margin:0 0 1.25rem;line-height:1.5}}label{{display:block;font-size:.95rem;margin-bottom:.5rem}}input{{width:100%;box-sizing:border-box;padding:.8rem .9rem;border-radius:10px;border:1px solid #b9ae9a;font:inherit}}button{{margin-top:1rem;width:100%;padding:.85rem 1rem;border:0;border-radius:999px;background:#1f5c4d;color:#fff;font:inherit;font-weight:600;cursor:pointer}}small{{display:block;margin-top:1rem;color:#6a6256}}</style></head><body><main><h1>{title}</h1><p>{description}</p><form method=\"post\" action=\"{route_path}\"><label for=\"pin\">{prompt}</label><input id=\"pin\" name=\"pin\" type=\"password\" autocomplete=\"current-password\" autofocus><button type=\"submit\">Submit</button></form><small>Listening at {url}</small></main></body></html>" "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>{title}</title><style>body{{font-family:sans-serif;background:#f6f4ee;color:#1f1f1f;margin:0;min-height:100vh;display:grid;place-items:center}}main{{width:min(28rem,calc(100vw - 2rem));background:#fffdf8;border:1px solid #d9d1c3;border-radius:16px;padding:2rem;box-shadow:0 16px 50px rgba(40,32,20,.12)}}h1{{margin:0 0 1rem;font-size:1.5rem}}p{{margin:0 0 1.25rem;line-height:1.5}}label{{display:block;font-size:.95rem;margin-bottom:.5rem}}input{{width:100%;box-sizing:border-box;padding:.8rem .9rem;border-radius:10px;border:1px solid #b9ae9a;font:inherit}}button{{margin-top:1rem;width:100%;padding:.85rem 1rem;border:0;border-radius:999px;background:#1f5c4d;color:#fff;font:inherit;font-weight:600;cursor:pointer}}small{{display:block;margin-top:1rem;color:#6a6256}}</style></head><body><main><h1>{title}</h1><p>{description}</p><form method=\"post\" action=\"{route_path}\"><label for=\"pin\">{prompt}</label><input id=\"pin\" name=\"pin\" type=\"password\" autocomplete=\"current-password\" autofocus><button type=\"submit\">Submit</button></form><small>Listening at {url}</small></main></body></html>"
) )
} }
fn wait_for_password(title: &str, description: &str, prompt: &str) -> io::Result<String> { fn wait_for_password(title: &str, description: &str, prompt: &str) -> io::Result<String> {
let bind_address = env::var("WEB_PINENTRY_BIND").unwrap_or_else(|_| "0.0.0.0:7563".to_string()); let bind_address = env::var("WEB_PINENTRY_BIND").unwrap_or_else(|_| "0.0.0.0:7563".to_string());
let listener = TcpListener::bind(&bind_address)?; let listener = TcpListener::bind(&bind_address)?;
let route_path = format!("/{}", random_path_segment()?); let route_path = format!("/{}", random_path_segment()?);
let url = advertised_url(&route_path)?; let url = advertised_url(&route_path)?;
let form_page = render_form(title, description, prompt, &url, &route_path); let form_page = render_form(title, description, prompt, &url, &route_path);
let mut route_hits = 0u8; let mut route_hits = 0u8;
send_url_message(&url)?; send_url_message(&url)?;
for connection in listener.incoming() { for connection in listener.incoming() {
let mut stream = connection?; let mut stream = connection?;
if !is_allowed_client(stream.peer_addr()?) { if !is_allowed_client(stream.peer_addr()?) {
send_http_response( send_http_response(
&mut stream, &mut stream,
"403 Forbidden", "403 Forbidden",
"text/plain; charset=utf-8", "text/plain; charset=utf-8",
"Forbidden", "Forbidden",
)?; )?;
continue; continue;
} }
let mut reader = BufReader::new(stream.try_clone()?); let mut reader = BufReader::new(stream.try_clone()?);
let mut request_line = String::new(); let mut request_line = String::new();
if reader.read_line(&mut request_line)? == 0 { if reader.read_line(&mut request_line)? == 0 {
continue; continue;
} }
let request_line = request_line.trim_end_matches(['\r', '\n']); let request_line = request_line.trim_end_matches(['\r', '\n']);
let mut request_parts = request_line.split_whitespace(); let mut request_parts = request_line.split_whitespace();
let method = request_parts.next().unwrap_or_default(); let method = request_parts.next().unwrap_or_default();
let path = request_parts.next().unwrap_or("/"); let path = request_parts.next().unwrap_or("/");
let mut content_length = 0usize; let mut content_length = 0usize;
loop { loop {
let mut header = String::new(); let mut header = String::new();
if reader.read_line(&mut header)? == 0 { if reader.read_line(&mut header)? == 0 {
break; break;
} }
let header = header.trim_end_matches(['\r', '\n']); let header = header.trim_end_matches(['\r', '\n']);
if header.is_empty() { if header.is_empty() {
break; break;
} }
if let Some((name, value)) = header.split_once(':') { if let Some((name, value)) = header.split_once(':') {
if name.eq_ignore_ascii_case("Content-Length") { if name.eq_ignore_ascii_case("Content-Length") {
content_length = value.trim().parse().unwrap_or(0); content_length = value.trim().parse().unwrap_or(0);
} }
} }
} }
if path == route_path { if path == route_path {
route_hits = route_hits.saturating_add(1); route_hits = route_hits.saturating_add(1);
if route_hits > MAX_ROUTE_HITS { if route_hits > MAX_ROUTE_HITS {
send_http_response( send_http_response(
&mut stream, &mut stream,
"410 Gone", "410 Gone",
"text/plain; charset=utf-8", "text/plain; charset=utf-8",
"Link expired", "Link expired",
)?; )?;
return Ok(String::new()); return Ok(String::new());
} }
} }
if method.eq_ignore_ascii_case("GET") && path == route_path { if method.eq_ignore_ascii_case("GET") && path == route_path {
send_http_response(&mut stream, "200 OK", "text/html; charset=utf-8", &form_page)?; send_http_response(&mut stream, "200 OK", "text/html; charset=utf-8", &form_page)?;
continue; continue;
} }
if method.eq_ignore_ascii_case("POST") && path == route_path { if method.eq_ignore_ascii_case("POST") && path == route_path {
let mut body = vec![0; content_length]; let mut body = vec![0; content_length];
reader.read_exact(&mut body)?; reader.read_exact(&mut body)?;
let body = String::from_utf8_lossy(&body); let body = String::from_utf8_lossy(&body);
let password = parse_form_value(&body, "pin").unwrap_or_default(); let password = parse_form_value(&body, "pin").unwrap_or_default();
let response = "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Submitted</title><style>body{font-family:sans-serif;background:#f6f4ee;color:#1f1f1f;margin:0;min-height:100vh;display:grid;place-items:center}main{width:min(24rem,calc(100vw - 2rem));background:#fffdf8;border:1px solid #d9d1c3;border-radius:16px;padding:2rem;text-align:center}</style></head><body><main><h1>Password submitted</h1><p>You can close this tab.</p></main></body></html>"; let response = "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Submitted</title><style>body{font-family:sans-serif;background:#f6f4ee;color:#1f1f1f;margin:0;min-height:100vh;display:grid;place-items:center}main{width:min(24rem,calc(100vw - 2rem));background:#fffdf8;border:1px solid #d9d1c3;border-radius:16px;padding:2rem;text-align:center}</style></head><body><main><h1>Password submitted</h1><p>You can close this tab.</p></main></body></html>";
send_http_response(&mut stream, "200 OK", "text/html; charset=utf-8", response)?; send_http_response(&mut stream, "200 OK", "text/html; charset=utf-8", response)?;
return Ok(password); return Ok(password);
} }
send_http_response( send_http_response(
&mut stream, &mut stream,
"404 Not Found", "404 Not Found",
"text/plain; charset=utf-8", "text/plain; charset=utf-8",
"Not found", "Not found",
)?; )?;
} }
Err(io::Error::new( Err(io::Error::new(
io::ErrorKind::UnexpectedEof, io::ErrorKind::UnexpectedEof,
"password server closed before a password was submitted", "password server closed before a password was submitted",
)) ))
} }
#[derive(Default)] #[derive(Default)]
struct PromptState { struct PromptState {
title: Option<String>, title: Option<String>,
description: Option<String>, description: Option<String>,
prompt: Option<String>, prompt: Option<String>,
} }
fn main() -> io::Result<()> { fn main() -> io::Result<()> {
let version = env!("CARGO_PKG_VERSION"); let version = env!("CARGO_PKG_VERSION");
let stdin = io::stdin(); let stdin = io::stdin();
let mut stdout = io::stdout(); let mut stdout = io::stdout();
let mut prompt_state = PromptState::default(); let mut prompt_state = PromptState::default();
writeln!(stdout, "OK simple rust pinentry ready")?; writeln!(stdout, "OK simple rust pinentry ready")?;
stdout.flush()?; stdout.flush()?;
for line in stdin.lock().lines() { for line in stdin.lock().lines() {
let line = line?; let line = line?;
let command = line.trim(); let command = line.trim();
if command.is_empty() { if command.is_empty() {
continue; continue;
} }
let mut parts = command.splitn(2, char::is_whitespace); let mut parts = command.splitn(2, char::is_whitespace);
let verb = parts.next().unwrap_or_default(); let verb = parts.next().unwrap_or_default();
let argument = parts.next().unwrap_or("").trim(); let argument = parts.next().unwrap_or("").trim();
if verb.eq_ignore_ascii_case("GETPIN") { if verb.eq_ignore_ascii_case("GETPIN") {
let title = prompt_state let title = prompt_state
.title .title
.as_deref() .as_deref()
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.unwrap_or("Pinentry"); .unwrap_or("Pinentry");
let description = prompt_state let description = prompt_state
.description .description
.as_deref() .as_deref()
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.unwrap_or("Enter the password in the browser window to continue."); .unwrap_or("Enter the password in the browser window to continue.");
let prompt = prompt_state let prompt = prompt_state
.prompt .prompt
.as_deref() .as_deref()
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.unwrap_or("Password"); .unwrap_or("Password");
match wait_for_password(title, description, prompt) { match wait_for_password(title, description, prompt) {
Ok(password) => { Ok(password) => {
write_data(&mut stdout, &password)?; write_data(&mut stdout, &password)?;
write_ok(&mut stdout)?; write_ok(&mut stdout)?;
} }
Err(error) => { Err(error) => {
eprintln!("web-pinentry error: {error}"); eprintln!("web-pinentry error: {error}");
write_err(&mut stdout, 83886179, "canceled")?; write_err(&mut stdout, 83886179, "canceled")?;
} }
} }
continue; continue;
} }
if verb.eq_ignore_ascii_case("BYE") { if verb.eq_ignore_ascii_case("BYE") {
write_ok(&mut stdout)?; write_ok(&mut stdout)?;
break; break;
} }
if verb.eq_ignore_ascii_case("GETINFO") { if verb.eq_ignore_ascii_case("GETINFO") {
match argument { match argument {
value if value.eq_ignore_ascii_case("pid") => { value if value.eq_ignore_ascii_case("pid") => {
write_data(&mut stdout, &process::id().to_string())?; write_data(&mut stdout, &process::id().to_string())?;
} }
value if value.eq_ignore_ascii_case("version") => { value if value.eq_ignore_ascii_case("version") => {
write_data(&mut stdout, version)?; write_data(&mut stdout, version)?;
} }
value if value.eq_ignore_ascii_case("flavor") => { value if value.eq_ignore_ascii_case("flavor") => {
write_data(&mut stdout, "browser")?; write_data(&mut stdout, "browser")?;
} }
_ => {} _ => {}
} }
write_ok(&mut stdout)?; write_ok(&mut stdout)?;
continue; continue;
} }
if verb.eq_ignore_ascii_case("SETDESC") { if verb.eq_ignore_ascii_case("SETDESC") {
prompt_state.description = Some(assuan_unescape(argument)); prompt_state.description = Some(assuan_unescape(argument));
write_ok(&mut stdout)?; write_ok(&mut stdout)?;
continue; continue;
} }
if verb.eq_ignore_ascii_case("SETPROMPT") { if verb.eq_ignore_ascii_case("SETPROMPT") {
prompt_state.prompt = Some(assuan_unescape(argument)); prompt_state.prompt = Some(assuan_unescape(argument));
write_ok(&mut stdout)?; write_ok(&mut stdout)?;
continue; continue;
} }
if verb.eq_ignore_ascii_case("SETTITLE") { if verb.eq_ignore_ascii_case("SETTITLE") {
prompt_state.title = Some(assuan_unescape(argument)); prompt_state.title = Some(assuan_unescape(argument));
write_ok(&mut stdout)?; write_ok(&mut stdout)?;
continue; continue;
} }
if verb.eq_ignore_ascii_case("RESET") { if verb.eq_ignore_ascii_case("RESET") {
prompt_state = PromptState::default(); prompt_state = PromptState::default();
write_ok(&mut stdout)?; write_ok(&mut stdout)?;
continue; continue;
} }
write_ok(&mut stdout)?; write_ok(&mut stdout)?;
} }
Ok(()) Ok(())
} }