Files
ELYTerminal/src/term/status.rs
T

235 lines
7.7 KiB
Rust

use base64::{
Engine,
alphabet::STANDARD,
engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig},
};
/// What a program says it is doing, OSC 7501.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum State {
Idle,
Working,
Done,
Blocked,
Error,
}
/// What a blocked program waits for.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Permission,
Question,
Auth,
}
/// A record, as the last report for its id left it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Record {
pub state: State,
pub kind: Option<Kind>,
pub progress: Option<u8>,
pub app: Option<String>,
pub title: Option<String>,
pub msg: Option<String>,
}
/// An OSC 7501 sequence, checked whole; an id is empty for the root record.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Status {
Report(String, Record),
/// Drops the record at the id and those under it; with no id, all.
Clear(Option<String>),
/// Asks which states and kinds the terminal takes.
Query,
}
/// The answer to the query: every state and kind this terminal takes.
pub const REPLY: &str =
"\x1b]7501;?:states=idle,working,done,blocked,error:kinds=permission,question,auth\x1b\\";
/// The spec's limits: a whole sequence, a key, an app, an id, its segments and levels.
const SEQUENCE: usize = 4096;
const KEY: usize = 16;
const APP: usize = 32;
const ID: usize = 128;
const SEGMENT: usize = 32;
const LEVELS: usize = 8;
/// Records a terminal keeps; past them, the one updated longest ago goes.
pub const RECORDS: usize = 256;
/// Standard base64, its padding optional.
const BASE64: GeneralPurpose = GeneralPurpose::new(
&STANDARD,
GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent),
);
/// The sequence in an OSC 7501 body past `7501;`, `length` bytes from OSC through ST; none for one the spec drops, logged with why.
pub fn read(body: &[u8], length: usize) -> Option<Status> {
parse(body, length)
.inspect_err(|why| log::warn!("pty: OSC 7501 dropped: {why}"))
.ok()
}
/// A value's bytes, as the spec allows them.
fn valued(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || b"_.,+/=-".contains(&byte)
}
/// An id's or an app's bytes.
fn named(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || b"_.+-".contains(&byte)
}
fn parse(body: &[u8], length: usize) -> Result<Status, String> {
if length > SEQUENCE {
return Err(format!("{length} bytes, over {SEQUENCE}"));
}
if body.trim_ascii() == b"?" {
return Ok(Status::Query);
}
// Every pair is checked before the last of each key wins.
let (mut state, mut id, mut kind, mut progress, mut app, mut title, mut msg) =
(None, None, None, None, None, None, None);
for pair in body.split(|&byte| byte == b':') {
// A malformed pair is skipped: no `=`, no key, or a byte outside the set.
let Some(at) = pair.iter().position(|&byte| byte == b'=') else {
continue;
};
let (key, value) = (pair[..at].trim_ascii(), pair[at + 1..].trim_ascii());
if key.is_empty()
|| !key.iter().all(u8::is_ascii_lowercase)
|| !value.iter().all(|&byte| valued(byte))
{
continue;
}
if key.len() > KEY {
return Err(format!("a key over {KEY} bytes"));
}
let value = String::from_utf8(value.to_vec()).expect("ASCII");
match key {
b"state" => state = Some(value),
b"id" => id = Some(self::id(&value)?),
b"kind" => kind = Some(value),
b"progress" => progress = Some(value),
b"app" if value.len() > APP => return Err(format!("an app over {APP} bytes")),
b"app" => app = Some(value),
b"title" => title = Some(text(&value, "title", 256, 192)?),
b"msg" => msg = Some(text(&value, "msg", 2732, 2048)?),
_ => {}
}
}
let state = match state.as_deref().ok_or("no state")? {
"idle" => State::Idle,
"working" => State::Working,
"done" => State::Done,
"blocked" => State::Blocked,
"error" => State::Error,
"clear" => return Ok(Status::Clear(id)),
other => return Err(format!("state {other} is not the spec's")),
};
let kind = match (state, kind.as_deref()) {
(State::Blocked, Some("permission")) => Some(Kind::Permission),
(State::Blocked, Some("question")) => Some(Kind::Question),
(State::Blocked, Some("auth")) => Some(Kind::Auth),
_ => None,
};
let progress = match state {
State::Working | State::Blocked => progress
.filter(|value| value.bytes().all(|byte| byte.is_ascii_digit()))
.and_then(|value| value.parse::<u8>().ok())
.filter(|&value| value <= 100),
_ => None,
};
let app = app.filter(|app| !app.is_empty() && app.bytes().all(named));
let record = Record {
state,
kind,
progress,
app,
title,
msg,
};
Ok(Status::Report(id.unwrap_or_default(), record))
}
/// An id that keeps to the spec's grammar and limits.
fn id(value: &str) -> Result<String, String> {
let segments: Vec<&str> = value.split('/').collect();
let fits = value.len() <= ID
&& segments.len() <= LEVELS
&& segments
.iter()
.all(|segment| (1..=SEGMENT).contains(&segment.len()) && segment.bytes().all(named));
if fits {
Ok(value.to_string())
} else {
Err(format!("id {value} is not the spec's"))
}
}
/// The text a base64 value names, one line, within `encoded` and `decoded` bytes.
fn text(value: &str, key: &str, encoded: usize, decoded: usize) -> Result<String, String> {
if value.len() > encoded {
return Err(format!("a {key} over {encoded} bytes"));
}
let bytes = BASE64
.decode(value)
.map_err(|error| format!("{key} is no base64: {error}"))?;
if bytes.len() > decoded {
return Err(format!("a {key} over {decoded} bytes decoded"));
}
let text = String::from_utf8(bytes).map_err(|_| format!("{key} is not UTF-8"))?;
if text.chars().any(char::is_control) {
return Err(format!("{key} holds a control character"));
}
Ok(text)
}
/// The records one terminal keeps, by id, each with when it was last reported.
#[derive(Default)]
pub struct Records {
list: Vec<(String, Record, u64)>,
clock: u64,
}
impl Records {
/// Replaces the record at `id` whole.
pub fn report(&mut self, id: String, record: Record) {
self.clock += 1;
if let Some(entry) = self.list.iter_mut().find(|(at, ..)| *at == id) {
*entry = (id, record, self.clock);
return;
}
if self.list.len() == RECORDS {
let oldest = (0..self.list.len())
.min_by_key(|&at| self.list[at].2)
.expect("records");
let (gone, ..) = self.list.remove(oldest);
log::debug!("status: record {gone:?} goes, the one updated longest ago");
}
self.list.push((id, record, self.clock));
}
/// Drops the record at `id` and every one under it; with none, all.
pub fn clear(&mut self, id: Option<&str>) {
match id {
None => self.list.clear(),
Some(id) => {
let under = format!("{id}/");
self.list
.retain(|(at, ..)| at != id && !at.starts_with(&under));
}
}
}
/// A new prompt: working, blocked and idle records go, done and error stay.
pub fn prompt(&mut self) {
self.list
.retain(|(_, record, _)| matches!(record.state, State::Done | State::Error));
}
}
#[cfg(test)]
mod tests;