Files
pdfcraft/crates/ui-egui/src/control.rs
T
Combiler01 4d93503937 Merge remote-tracking branch 'upstream/main'
# Conflicts:
#	ATTRIBUTION.toml
#	crates/automation/src/tools.rs
#	crates/automation/tests/automation.rs
#	crates/edit/src/images.rs
#	crates/edit/src/lib.rs
#	crates/edit/src/tests.rs
#	crates/engine/src/lib.rs
#	crates/ui-egui/src/create_ui.rs
#	crates/ui-egui/src/edit_text_ui.rs
#	crates/ui-egui/src/editing.rs
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/vi.tsv
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/tests/content.rs
#	crates/ui-egui/tests/editing.rs
2026-10-22 13:32:03 +05:30

1526 lines
75 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The UI control channel (M3.9): lets an agent see and drive the running app.
//!
//! **Opt-in only.** Nothing here runs unless the app is started with `--control <file>` (or a
//! test attaches a [`ControlClient`]). The transport ([`serve`]) listens on loopback only, on a
//! random port, and every connection must first present the random token written to `<file>`.
//!
//! How it works:
//! - [`ControlPlugin`], an egui plugin, keeps a copy of the AccessKit tree from every frame's
//! output (the widget tree with ids, roles, labels, rects and states) and injects queued input
//! events (AccessKit click actions, pointer events, keys, text) at the start of later frames.
//! - Requests reach the app through a channel; `PdfCraftApp` answers them at the start of a
//! frame, so they see and change exactly what the user would.
//!
//! Methods (JSON in, JSON out):
//! - `ui.state`: open documents, active document, page, zoom, page errors, mode, panels, dialog,
//! notice and observed window dimensions in logical points.
//! - `ui.inspect {query?, role?, focused?, limit?}`: widgets in tree order with `id` (a string), `role`, `label`,
//! `value`, `rect` (points), `enabled`, `toggled`, `selected`, `clickable`, `focusable`, `focused`, `depth`.
//! - `ui.click {id}` | `{label}` | `{x, y, button?}`: click a widget (by its AccessKit action) or a
//! point (`button`: primary, secondary, or middle).
//! - `ui.focus {id}` | `{label}`: focus one enabled widget through its AccessKit action.
//! - `ui.resize {width, height}`: request a native window size (320–8192 logical points per dimension).
//! The window manager may constrain it; `ui.state.window` reports the observed result.
//! - `ui.quit {}`: normal native exit, or `quitting: false, needs_confirmation: true` and the
//! existing save prompt when any document has unsaved edits or typing. No discard parameter.
//! - `ui.move {x, y}`: move the pointer without pressing a button (e.g. latched autoscroll).
//! - `ui.drag {from: [x, y], to: [x, y], steps?, modifiers?, button?}`: press, move and release (drawing
//! comments, selecting text, moving comments). `ui.state` reports `pages_on_screen` to aim at.
//! - `ui.type {text}`, `ui.key {key, modifiers?}`: keyboard input to the focused widget / app.
//! - `ui.command {id}`: run a registry command (as the menu would). `ui.commands` lists them.
//! - `ui.set {key, value}`: view options (`--page`, `--zoom`, …), plus persistent preferences.
//! `select` accepts physical page numbers and ranges such as `1,12,16-18`; blank clears the
//! explicit selection. This is the same bounded parser as the Organize Pages field; invalid
//! input leaves the previous selection unchanged. `ui.state.active.selected_pages` reports it.
//! `default-mode=all|read|edit|convert|sign` applies to future PDF opens; `mode` overrides it
//! for this session without changing the saved preference. `ui.state.default_mode` reports it.
//! `theme=light|dark|system` changes the saved theme preference; `ui.state.theme_preference`
//! reports the choice and `ui.state.theme` reports the resolved light/dark colours.
//! - `ui.open {path}`: open a file. A file whose tab is already open focuses it
//! (`"focused": true`) instead of adding a tab; native opens report `pending: true` until
//! they complete and are listed in `ui.state.pending_opens`; only a failed open replies `Err`.
//! - `ui.screenshot {region?}`: PNG of the window (base64), optionally cropped to a rect.
use std::collections::{HashMap, VecDeque};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::sync::{Arc, Mutex};
use egui::accesskit::{self, Action, NodeId};
use serde_json::{Value, json};
pub type Reply = Result<Value, String>;
/// Seconds to wait for frames (input to be handled, a screenshot to be taken) before reporting
/// that the window is not being drawn. While a window is hidden, minimized or fully covered,
/// eframe keeps calling `logic` but runs no egui pass, so input and screenshots wait.
const NOT_DRAWN_TIMEOUT: f64 = 5.0;
/// Wall-clock seconds (egui's own clock stops while the window is not drawn).
fn now_secs() -> f64 {
#[cfg(not(target_arch = "wasm32"))]
{
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
START.get_or_init(std::time::Instant::now).elapsed().as_secs_f64()
}
#[cfg(target_arch = "wasm32")]
{
js_sys::Date::now() / 1000.0
}
}
fn not_drawn(what: &str) -> String {
format!(
"{what} did not happen within {NOT_DRAWN_TIMEOUT:.0} s: the window is not being drawn (it may be hidden, minimized or covered); bring it to the front and retry"
)
}
/// One request from a client, answered on `reply`.
pub struct ControlRequest {
pub method: String,
pub params: Value,
pub reply: Sender<Reply>,
}
/// Sends requests to the app (from the transport thread or a test).
#[derive(Clone)]
pub struct ControlClient {
tx: Sender<ControlRequest>,
ctx: egui::Context,
}
impl ControlClient {
/// Queue a request; the reply arrives on the returned receiver once the app has handled it.
pub fn send(&self, method: &str, params: Value) -> Receiver<Reply> {
let (reply, rx) = channel();
let req = ControlRequest { method: method.into(), params, reply };
if let Err(e) = self.tx.send(req) {
let _ = e.0.reply.send(Err("the app has closed".into()));
}
self.ctx.request_repaint();
rx
}
}
/// State shared between the plugin (frame hooks) and the app.
#[derive(Default)]
struct Shared {
nodes: HashMap<NodeId, accesskit::Node>,
root: Option<NodeId>,
focus: Option<NodeId>,
/// Event batches to inject, one batch per frame.
inject: VecDeque<Vec<egui::Event>>,
/// Real egui passes so far (counted in `output_hook`, which `logic`-only calls never reach).
passes: u64,
}
/// The egui plugin half of the control channel.
pub struct ControlPlugin {
shared: Arc<Mutex<Shared>>,
}
impl egui::Plugin for ControlPlugin {
fn debug_name(&self) -> &'static str {
"pdfcraft-control"
}
fn input_hook(&mut self, ctx: &egui::Context, input: &mut egui::RawInput) {
let Ok(mut s) = self.shared.lock() else { return };
if let Some(batch) = s.inject.pop_front() {
input.events.extend(batch);
}
if !s.inject.is_empty() {
ctx.request_repaint();
}
}
fn output_hook(&mut self, _ctx: &egui::Context, output: &mut egui::FullOutput) {
let Ok(mut s) = self.shared.lock() else { return };
s.passes += 1;
let Some(update) = &output.platform_output.accesskit_update else { return };
s.nodes = update.nodes.iter().cloned().collect();
s.root = update.tree.as_ref().map(|t| t.root);
s.focus = Some(update.focus);
}
}
enum Pending {
/// Answer after `frames` more frames (the injected input has been handled by then).
Frames {
/// Answer once this many egui passes have run (the injected input has been handled).
until_pass: u64,
since: f64,
reply: Sender<Reply>,
value: Value,
},
Screenshot {
tag: u64,
region: Option<egui::Rect>,
reply: Sender<Reply>,
/// When it was requested (egui time, seconds).
since: f64,
},
}
/// The app half: the request queue and requests waiting for later frames.
pub struct Control {
rx: Receiver<ControlRequest>,
shared: Arc<Mutex<Shared>>,
pending: Vec<Pending>,
next_tag: u64,
}
/// Install the control channel on `ctx`: returns the app half and a client.
pub fn attach(ctx: &egui::Context) -> (Control, ControlClient) {
let shared = Arc::new(Mutex::new(Shared::default()));
ctx.add_plugin(ControlPlugin { shared: shared.clone() });
ctx.enable_accesskit();
let (tx, rx) = channel();
(Control { rx, shared, pending: Vec::new(), next_tag: 1 }, ControlClient { tx, ctx: ctx.clone() })
}
/// What a request needs from the app (implemented by `PdfCraftApp`).
pub(crate) trait Host {
fn state(&self) -> Value;
fn command(&mut self, id: &str) -> Reply;
fn dock(&mut self, reset: bool, params: &Value) -> Reply;
fn commands(&self) -> Value;
fn set(&mut self, key: &str, value: &str) -> Reply;
fn open(&mut self, path: &str) -> Reply;
fn quit(&mut self, ctx: &egui::Context) -> bool;
}
impl Control {
/// Handle new requests and finish waiting ones. Call at the start of every frame.
pub(crate) fn tick(&mut self, ctx: &egui::Context, host: &mut impl Host) {
self.finish_pending(ctx);
while let Ok(req) = self.rx.try_recv() {
match self.handle(ctx, host, &req.method, &req.params) {
Handled::Now(r) => {
let _ = req.reply.send(r);
}
Handled::AfterFrames(frames, value) => {
let until_pass = self.passes() + u64::from(frames);
self.pending.push(Pending::Frames { until_pass, since: now_secs(), reply: req.reply, value });
}
Handled::Screenshot(region) => {
let tag = self.next_tag;
self.next_tag += 1;
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(tag)));
self.pending.push(Pending::Screenshot { tag, region, reply: req.reply, since: now_secs() });
}
}
}
if !self.pending.is_empty() {
ctx.request_repaint();
}
}
fn finish_pending(&mut self, ctx: &egui::Context) {
let shots: Vec<(u64, Arc<egui::ColorImage>)> = ctx.input(|i| {
i.raw
.events
.iter()
.filter_map(|e| match e {
egui::Event::Screenshot { user_data, image, .. } => {
user_data.data.as_ref().and_then(|d| d.downcast_ref::<u64>()).map(|tag| (*tag, image.clone()))
}
_ => None,
})
.collect()
});
let ppp = ctx.pixels_per_point();
let now = now_secs();
let passes = self.passes();
let mut keep = Vec::new();
for p in self.pending.drain(..) {
match p {
Pending::Frames { until_pass, reply, value, .. } if passes >= until_pass => {
let _ = reply.send(Ok(value));
}
Pending::Frames { since, reply, .. } if now - since > NOT_DRAWN_TIMEOUT => {
// The agent is told it failed, so it must not happen later either.
if let Ok(mut sh) = self.shared.lock() {
sh.inject.clear();
}
let _ = reply.send(Err(not_drawn("handling the input")));
}
p @ Pending::Frames { .. } => keep.push(p),
Pending::Screenshot { tag, region, reply, since } => match shots.iter().find(|(t, _)| *t == tag) {
Some((_, image)) => {
let _ = reply.send(screenshot_png(image, region, ppp));
}
// The platform skips painting windows that are hidden, minimized or fully
// covered, so no screenshot ever arrives: say so instead of hanging.
None if now - since > NOT_DRAWN_TIMEOUT => {
let _ = reply.send(Err(not_drawn("the screenshot")));
}
None => keep.push(Pending::Screenshot { tag, region, reply, since }),
},
}
}
self.pending = keep;
}
fn passes(&self) -> u64 {
self.shared.lock().map(|s| s.passes).unwrap_or(0)
}
fn inject(&self, batches: Vec<Vec<egui::Event>>) -> u32 {
let n = batches.len() as u32;
if let Ok(mut s) = self.shared.lock() {
s.inject.extend(batches);
}
// The last batch is handled during the frame it is injected into; answer one frame later.
n + 1
}
fn handle(&mut self, ctx: &egui::Context, host: &mut impl Host, method: &str, p: &Value) -> Handled {
let str_param = |k: &str| p.get(k).and_then(Value::as_str).ok_or_else(|| format!("{method}: missing string parameter {k}"));
let r: Result<Handled, String> = (|| match method {
"ui.state" => {
let mut state = host.state();
let size = ctx.input(|i| i.viewport_rect().size());
state["window"] = json!({ "width": size.x, "height": size.y, "pixels_per_point": ctx.pixels_per_point() });
Ok(Handled::Now(Ok(state)))
}
"ui.commands" => Ok(Handled::Now(Ok(host.commands()))),
"ui.dock" | "ui.dock.reset" => Ok(Handled::Now(host.dock(method == "ui.dock.reset", p))),
"ui.command" if str_param("id")? == "ui.dock" => Ok(Handled::Now(host.dock(false, p.get("params").unwrap_or(p)))),
"ui.command" => Ok(Handled::Now(host.command(str_param("id")?))),
"ui.set" => {
let value = match p.get("value") {
Some(Value::String(s)) => s.clone(),
Some(v) => v.to_string(),
None => return Err("ui.set: missing parameter value".into()),
};
Ok(Handled::Now(host.set(str_param("key")?, &value)))
}
"ui.open" => Ok(Handled::Now(host.open(str_param("path")?))),
"ui.inspect" => Ok(Handled::Now(Ok(self.inspect(p)))),
"ui.click" => self.click(p),
"ui.focus" => self.focus(p),
"ui.resize" => {
only_params(p, method, &["width", "height"])?;
let dimension = |key: &str| -> Result<f32, String> {
p.get(key)
.and_then(Value::as_f64)
.filter(|n| n.is_finite() && (320.0..=8192.0).contains(n))
.map(|n| n as f32)
.ok_or_else(|| format!("ui.resize: {key} must be between 320 and 8192 logical points"))
};
let size = egui::vec2(dimension("width")?, dimension("height")?);
if cfg!(target_arch = "wasm32") {
return Err("ui.resize needs a native window".into());
}
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
ctx.request_repaint();
Ok(Handled::AfterFrames(2, json!({ "requested": [size.x, size.y] })))
}
"ui.quit" => {
only_params(p, method, &[])?;
if cfg!(target_arch = "wasm32") {
return Err("ui.quit needs a native window".into());
}
let quitting = host.quit(ctx);
Ok(Handled::Now(Ok(json!({ "quitting": quitting, "needs_confirmation": !quitting }))))
}
"ui.drag" => self.drag(p),
"ui.move" => {
let point = |key: &str| -> Result<f32, String> {
p.get(key)
.and_then(Value::as_f64)
.map(|n| n as f32)
.filter(|n| n.is_finite())
.ok_or_else(|| format!("ui.move: {key} must be a finite coordinate in points"))
};
let pos = egui::pos2(point("x")?, point("y")?);
Ok(Handled::AfterFrames(self.inject(vec![vec![egui::Event::PointerMoved(pos)]]), json!({ "moved": [pos.x, pos.y] })))
}
"ui.type" => {
let text = str_param("text")?.to_string();
Ok(Handled::AfterFrames(self.inject(vec![vec![egui::Event::Text(text)]]), json!({ "typed": true })))
}
"ui.key" => {
let name = str_param("key")?;
let key = egui::Key::from_name(name)
.ok_or_else(|| format!("ui.key: unknown key {name:?} (egui key names: A, Enter, Escape, ArrowDown, F5, …)"))?;
let modifiers = modifiers(p.get("modifiers"))?;
let ev = |pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers };
Ok(Handled::AfterFrames(self.inject(vec![vec![ev(true)], vec![ev(false)]]), json!({ "key": name })))
}
"ui.screenshot" => {
let region = match p.get("region") {
None | Some(Value::Null) => None,
Some(r) => Some(rect_param(r)?),
};
ctx.request_repaint();
Ok(Handled::Screenshot(region))
}
other => Err(format!(
"unknown method {other:?} (ui.state, ui.inspect, ui.click, ui.focus, ui.resize, ui.quit, ui.move, ui.drag, ui.type, ui.key, ui.command, ui.commands, ui.set, ui.open, ui.screenshot)"
)),
})();
r.unwrap_or_else(|e| Handled::Now(Err(e)))
}
fn inspect(&self, p: &Value) -> Value {
let query = p.get("query").and_then(Value::as_str).map(str::to_lowercase);
let role = p.get("role").and_then(Value::as_str).map(str::to_lowercase);
let focused = p.get("focused").and_then(Value::as_bool);
let limit = p.get("limit").and_then(Value::as_u64).unwrap_or(500) as usize;
let Ok(s) = self.shared.lock() else { return json!({ "widgets": [] }) };
let mut out = Vec::new();
let mut total = 0usize;
let mut stack: Vec<(NodeId, usize)> = s.root.map(|r| vec![(r, 0)]).unwrap_or_default();
while let Some((id, depth)) = stack.pop() {
let Some(node) = s.nodes.get(&id) else { continue };
for c in node.children().iter().rev() {
stack.push((*c, depth + 1));
}
let w = widget(id, node, depth, s.focus == Some(id));
let text =
format!("{} {} {}", w["label"].as_str().unwrap_or(""), w["value"].as_str().unwrap_or(""), w["description"].as_str().unwrap_or(""))
.to_lowercase();
let role_ok = role.as_ref().is_none_or(|r| w["role"].as_str().is_some_and(|x| x.to_lowercase() == *r));
let query_ok = query.as_ref().is_none_or(|q| text.contains(q.as_str()));
let focus_ok = focused.is_none_or(|want| (s.focus == Some(id)) == want);
if role_ok && query_ok && focus_ok && id != s.root.unwrap_or(id) {
total += 1;
if out.len() < limit {
out.push(w);
}
}
}
json!({ "widgets": out, "count": total, "truncated": total > out.len() })
}
/// Press at `from`, move to `to` in `steps` frames, release (drawing, selecting text, moving).
fn drag(&mut self, p: &Value) -> Result<Handled, String> {
let point = |k: &str| -> Result<egui::Pos2, String> {
match p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).collect::<Vec<_>>()).as_deref() {
Some([x, y]) => Some(egui::pos2(*x as f32, *y as f32))
.filter(|pos| pos.is_finite())
.ok_or_else(|| format!("ui.drag: {k} must be [x, y], finite coordinates in points")),
_ => Err(format!("ui.drag: {k} must be [x, y] in points")),
}
};
let (from, to) = (point("from")?, point("to")?);
if !(to - from).is_finite() {
return Err("ui.drag: from and to must be a finite distance apart, in points".into());
}
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200) as usize;
let modifiers = modifiers(p.get("modifiers"))?;
let which = pointer_button(p, "ui.drag")?;
// The events carry the modifiers and so does the input state: gestures that read
// `ui.input(|i| i.modifiers)` (a Ctrl-drag copy, an Alt-drag column select) see them.
let held = |mut events: Vec<egui::Event>| {
if modifiers != egui::Modifiers::NONE {
events.insert(0, egui::Event::ModifiersChanged(modifiers));
}
events
};
let button = |pos, pressed| egui::Event::PointerButton { pos, button: which, pressed, modifiers };
let mut frames = vec![held(vec![egui::Event::PointerMoved(from)]), held(vec![button(from, true)])];
for k in 1..=steps {
frames.push(held(vec![egui::Event::PointerMoved(from + (to - from) * (k as f32 / steps as f32))]));
}
frames.push(held(vec![button(to, false)]));
if modifiers != egui::Modifiers::NONE {
frames.push(vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)]);
}
let n = self.inject(frames);
Ok(Handled::AfterFrames(n, json!({ "dragged": [[from.x, from.y], [to.x, to.y]] })))
}
fn click(&mut self, p: &Value) -> Result<Handled, String> {
if let (Some(x), Some(y)) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
let point = |key: &str, n: f64| {
Some(n as f32).filter(|n| n.is_finite()).ok_or_else(|| format!("ui.click: {key} must be a finite coordinate in points"))
};
let pos = egui::pos2(point("x", x)?, point("y", y)?);
let which = pointer_button(p, "ui.click")?;
let button = |pressed| egui::Event::PointerButton { pos, button: which, pressed, modifiers: egui::Modifiers::NONE };
// Keep hover/layout warmup, then make an instantaneous click even on slow frames.
let frames = self.inject(vec![vec![egui::Event::PointerMoved(pos)], vec![button(true), button(false)]]);
return Ok(Handled::AfterFrames(frames, json!({ "clicked": [x, y] })));
}
let s = self.shared.lock().map_err(|_| "control state poisoned")?;
let id = match (p.get("id"), p.get("label").and_then(Value::as_str)) {
(Some(id), _) => {
let id: u64 =
id.as_u64().or_else(|| id.as_str().and_then(|s| s.parse().ok())).ok_or("ui.click: id must be a widget id from ui.inspect")?;
let id = NodeId(id);
if !s.nodes.contains_key(&id) {
return Err(format!("ui.click: no widget with id {} on screen (run ui.inspect again)", id.0));
}
id
}
(None, Some(label)) => {
let want = label.to_lowercase();
let matches: Vec<NodeId> = s
.nodes
.iter()
.filter(|(_, n)| n.supports_action(Action::Click) && !n.is_disabled() && n.label().is_some_and(|l| l.to_lowercase() == want))
.map(|(id, _)| *id)
.collect();
match matches.as_slice() {
[one] => *one,
[] => return Err(format!("ui.click: no enabled clickable widget labelled {label:?} (try ui.inspect with query)")),
many => return Err(format!("ui.click: {} widgets are labelled {label:?}; click one by id", many.len())),
}
}
(None, None) => return Err("ui.click: pass id, label, or x and y".into()),
};
let node = &s.nodes[&id];
if node.is_disabled() {
return Err(format!("ui.click: widget {} ({}) is disabled", id.0, node.label().unwrap_or("unlabelled")));
}
let label = node.label().map(str::to_owned);
let action = accesskit::ActionRequest { action: Action::Click, target_tree: accesskit::TreeId::ROOT, target_node: id, data: None };
drop(s);
let frames = self.inject(vec![vec![egui::Event::AccessKitActionRequest(action)]]);
Ok(Handled::AfterFrames(frames, json!({ "clicked": id.0.to_string(), "label": label })))
}
fn focus(&mut self, p: &Value) -> Result<Handled, String> {
only_params(p, "ui.focus", &["id", "label"])?;
let s = self.shared.lock().map_err(|_| "control state poisoned")?;
let id = match (p.get("id"), p.get("label")) {
(Some(id), None) => {
NodeId(id.as_u64().or_else(|| id.as_str().and_then(|s| s.parse().ok())).ok_or("ui.focus: id must be a widget id from ui.inspect")?)
}
(None, Some(Value::String(label))) => {
let matches: Vec<NodeId> = s
.nodes
.iter()
.filter(|(_, n)| n.supports_action(Action::Focus) && !n.is_disabled() && n.label().is_some_and(|l| l.eq_ignore_ascii_case(label)))
.map(|(id, _)| *id)
.collect();
match matches.as_slice() {
[one] => *one,
[] => return Err(format!("ui.focus: no enabled focusable widget labelled {label:?}")),
many => return Err(format!("ui.focus: {} widgets are labelled {label:?}; use a widget id", many.len())),
}
}
_ => return Err("ui.focus: pass exactly one widget id or label".into()),
};
let node = s.nodes.get(&id).ok_or_else(|| format!("ui.focus: no widget with id {} on screen (run ui.inspect again)", id.0))?;
if node.is_disabled() || !node.supports_action(Action::Focus) {
return Err(format!("ui.focus: widget {} is disabled or not focusable", id.0));
}
let action = accesskit::ActionRequest { action: Action::Focus, target_tree: accesskit::TreeId::ROOT, target_node: id, data: None };
drop(s);
let frames = self.inject(vec![vec![egui::Event::AccessKitActionRequest(action)]]);
Ok(Handled::AfterFrames(frames, json!({ "focus_requested": id.0.to_string() })))
}
}
enum Handled {
Now(Reply),
AfterFrames(u32, Value),
Screenshot(Option<egui::Rect>),
}
fn widget(id: NodeId, n: &accesskit::Node, depth: usize, focused: bool) -> Value {
let rect = n.bounds().map(|b| [b.x0, b.y0, b.x1, b.y1]);
let mut w = json!({
// A string: 64-bit ids don't survive JSON parsers that use doubles.
"id": id.0.to_string(),
"role": format!("{:?}", n.role()),
"depth": depth,
"enabled": !n.is_disabled(),
"clickable": n.supports_action(Action::Click),
"focusable": n.supports_action(Action::Focus),
});
let mut more = serde_json::Map::new();
let obj = &mut more;
if let Some(l) = n.label() {
obj.insert("label".into(), json!(l));
}
if let Some(v) = n.value() {
obj.insert("value".into(), json!(v));
}
if let Some(d) = n.description() {
obj.insert("description".into(), json!(d));
}
if let Some(r) = rect {
obj.insert("rect".into(), json!(r));
}
if let Some(t) = n.toggled() {
obj.insert("toggled".into(), json!(format!("{t:?}").to_lowercase()));
}
if let Some(s) = n.is_selected() {
obj.insert("selected".into(), json!(s));
}
if focused {
obj.insert("focused".into(), json!(true));
}
if let Value::Object(m) = &mut w {
m.extend(more);
}
w
}
fn only_params(p: &Value, method: &str, allowed: &[&str]) -> Result<(), String> {
// The JSON-RPC transport represents omitted params as null.
if p.is_null() && allowed.is_empty() {
return Ok(());
}
let object = p.as_object().ok_or_else(|| format!("{method}: params must be an object"))?;
if let Some(key) = object.keys().find(|key| !allowed.contains(&key.as_str())) {
return Err(format!("{method}: unknown parameter {key:?}"));
}
Ok(())
}
fn pointer_button(p: &Value, method: &str) -> Result<egui::PointerButton, String> {
match p.get("button").and_then(Value::as_str).unwrap_or("primary") {
"primary" | "left" => Ok(egui::PointerButton::Primary),
"secondary" | "right" => Ok(egui::PointerButton::Secondary),
"middle" => Ok(egui::PointerButton::Middle),
other => Err(format!("{method}: unknown button {other:?} (primary, secondary, middle)")),
}
}
fn modifiers(v: Option<&Value>) -> Result<egui::Modifiers, String> {
let mut m = egui::Modifiers::NONE;
for name in v.and_then(Value::as_array).into_iter().flatten() {
match name.as_str().unwrap_or("") {
"command" | "cmd" => m.command = true,
"shift" => m.shift = true,
"alt" | "option" => m.alt = true,
"ctrl" | "control" => m.ctrl = true,
"mac_cmd" => m.mac_cmd = true,
other => return Err(format!("ui.key: unknown modifier {other:?} (command, shift, alt, ctrl)")),
}
}
// "command" is ⌘ on macOS and Ctrl elsewhere, as egui expects.
if m.command {
if cfg!(target_os = "macos") {
m.mac_cmd = true;
} else {
m.ctrl = true;
}
}
Ok(m)
}
fn rect_param(v: &Value) -> Result<egui::Rect, String> {
let a: Vec<f32> = v.as_array().map(|a| a.iter().filter_map(Value::as_f64).map(|f| f as f32).collect()).unwrap_or_default();
match a.as_slice() {
[x0, y0, x1, y1] if x1 > x0 && y1 > y0 => Ok(egui::Rect::from_min_max(egui::pos2(*x0, *y0), egui::pos2(*x1, *y1))),
_ => Err("region must be [x0, y0, x1, y1] in points".into()),
}
}
/// Encode (a region of) a screenshot as PNG; the reply carries base64 data and the size.
fn screenshot_png(image: &egui::ColorImage, region: Option<egui::Rect>, ppp: f32) -> Reply {
let [w, h] = image.size;
let (x0, y0, x1, y1) = match region {
Some(r) => {
let px = |v: f32, max: usize| ((v * ppp).round().max(0.0) as usize).min(max);
(px(r.min.x, w), px(r.min.y, h), px(r.max.x, w), px(r.max.y, h))
}
None => (0, 0, w, h),
};
if x1 <= x0 || y1 <= y0 {
return Err("the region is outside the window".into());
}
let mut rgba = Vec::with_capacity((x1 - x0) * (y1 - y0) * 4);
for y in y0..y1 {
for px in &image.pixels[y * w + x0..y * w + x1] {
rgba.extend_from_slice(&px.to_srgba_unmultiplied());
}
}
let (cw, ch) = ((x1 - x0) as u32, (y1 - y0) as u32);
let mut out = Vec::new();
let mut enc = png::Encoder::new(&mut out, cw, ch);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut writer = enc.write_header().map_err(|e| e.to_string())?;
writer.write_image_data(&rgba).map_err(|e| e.to_string())?;
writer.finish().map_err(|e| e.to_string())?;
use base64::Engine as _;
Ok(json!({ "png_base64": base64::engine::general_purpose::STANDARD.encode(out), "width": cw, "height": ch, "pixels_per_point": ppp }))
}
impl Host for crate::PdfCraftApp {
fn dock(&mut self, reset: bool, params: &Value) -> Reply {
crate::docking::command(self, reset, params)
}
fn quit(&mut self, ctx: &egui::Context) -> bool {
self.request_quit(ctx)
}
fn state(&self) -> Value {
let active = self.active.and_then(|i| self.views.get(i));
let docs: Vec<Value> = self
.views
.iter()
.enumerate()
.filter_map(|(i, v)| {
let d = self.session.get(v.id)?;
Some(json!({ "tab": i, "doc": v.id.0, "name": d.name, "path": d.path, "pages": d.info.pages.len(), "dirty": d.dirty, "active": self.active == Some(i) }))
})
.collect();
#[cfg(not(target_arch = "wasm32"))]
let pending_opens: Vec<Value> =
self.pending_opens.iter().map(|pending| json!({ "token": pending.token, "name": pending.name, "path": pending.path })).collect();
#[cfg(target_arch = "wasm32")]
let pending_opens: Vec<Value> = Vec::new();
json!({
"docking": self.docking,
"documents": docs,
"pending_opens": pending_opens,
"active": active.map(|v| json!({
"doc": v.id.0,
"page": v.current + 1,
"zoom_percent": (v.zoom * 100.0).round(),
"fit": format!("{:?}", v.fit),
"layout": format!("{:?}", v.layout),
"continuous": v.continuous(),
"page_gaps": v.show_page_gaps,
"organize": v.organize,
"grid_zoom": v.grid_zoom(),
"thumbnail_zoom": v.thumbnail_zoom(),
// Pages picked in the organize grid or the Pages panel (empty: the current page).
"selected_pages": v.selected.iter().map(|p| p + 1).collect::<Vec<_>>(),
"auto_scrolling": v.auto_scrolling(),
"raster_memory": v.raster_memory(),
"viewport": [v.viewport_rect().min.x, v.viewport_rect().min.y, v.viewport_rect().max.x, v.viewport_rect().max.y],
"find_open": v.find.is_some(),
"page_errors": v.page_errors().iter().map(|(p, e)| json!({ "page": p + 1, "error": e })).collect::<Vec<_>>(),
// Where pages are on screen (points), to aim ui.click / ui.drag at page content.
"pages_on_screen": v.visible_page_rects().iter().map(|(p, r)| json!({ "page": p + 1, "rect": [r.min.x, r.min.y, r.max.x, r.max.y] })).collect::<Vec<_>>(),
"selected_comment": v.comments.selected.map(|(p, i)| json!({ "page": p + 1, "index": i + 1 })),
"comment_composer_open": v.comments.composer.is_some(),
})),
// The Combine files tab: how its files show, and each grid thumbnail's state.
"combine": {
"showing": self.combine_showing(),
"view": self.combine_view.as_str(),
"zoom": self.combine_zoom,
"files": self.combine_draft.iter().map(|f| f.name.clone()).collect::<Vec<_>>(),
"selected": self.combine_selection(),
"thumbnails": self.combine_thumbnails().iter().map(|t| format!("{t:?}")).collect::<Vec<_>>(),
"expanded": self.combine_expanded().iter().filter_map(|i| self.combine_draft.get(*i).map(|f| f.name.clone())).collect::<Vec<_>>(),
"preview": self.combine_preview().map(|(file, page, render)| json!({ "file": file, "page": page + 1, "render": format!("{render:?}") })),
},
"quick_tool": match self.quick_tool {
crate::QuickTool::Measure(t) => format!("measure-{}", t.name()),
crate::QuickTool::Select => "select".to_string(),
crate::QuickTool::Hand => "hand".to_string(),
crate::QuickTool::Crop => "crop".to_string(),
crate::QuickTool::EnhanceCamera => "enhance-camera".to_string(),
crate::QuickTool::Redact => "redact".to_string(),
crate::QuickTool::AddText => "add-text".to_string(),
crate::QuickTool::EditText => "edit-text".to_string(),
crate::QuickTool::Link => "link".to_string(),
crate::QuickTool::SignArea { certify: false } => "sign".to_string(),
crate::QuickTool::SignArea { certify: true } => "certify".to_string(),
crate::QuickTool::MarqueeZoom => "marquee-zoom".to_string(),
crate::QuickTool::Snapshot => "snapshot".to_string(),
crate::QuickTool::ColumnSelect => "column-select".to_string(),
crate::QuickTool::Stamp(k) => format!("stamp-{}", k.name().trim_start_matches("PC").to_ascii_lowercase()),
crate::QuickTool::CustomStamp(i) => format!("custom-stamp-{i}"),
crate::QuickTool::Fill(f) => format!("fill-{}", f.command().trim_start_matches("sign.fill.")),
crate::QuickTool::Field(f) => format!("field-{}", f.command().trim_start_matches("form.add.")),
crate::QuickTool::Comment(t) => t.command().trim_start_matches("comment.").to_string(),
},
"home": self.active.is_none(),
"mode": format!("{:?}", self.mode),
"default_mode": self.default_mode,
"left_panel": if self.left_open { json!(format!("{:?}", self.left)) } else { Value::Null },
"tools_side": if self.docking.tools_on_right() { "right" } else { "left" },
"right_panel": self.right.map(|r| format!("{r:?}")),
"dialog": self.dialog.map(|d| format!("{d:?}")),
"palette_open": self.palette_open,
"theme": format!("{:?}", self.theme),
"theme_preference": self.theme_preference,
"language": self.language,
"notice": self.toast.as_ref().map(|t| t.0.clone()),
"progress": self.progress_notice.as_ref().map(|p| json!({ "label": p.label, "fraction": p.fraction })),
"password_prompt": self.password_prompt.is_some(),
"close_prompt": self.close_request.is_some(),
})
}
fn command(&mut self, id: &str) -> Reply {
let spec = pdfcraft_engine::commands::command(id).ok_or_else(|| format!("unknown command {id:?} (see ui.commands)"))?;
if !self.command_enabled(spec) {
return Err(format!("{id} is disabled right now"));
}
self.execute(id);
Ok(json!({ "ran": id }))
}
fn commands(&self) -> Value {
let mut list: Vec<Value> = pdfcraft_engine::commands::COMMANDS
.iter()
.map(|c| json!({ "id": c.id, "label": c.label, "menu": c.menu, "enabled": self.command_enabled(c), "shortcut": c.shortcut.map(|s| s.label(cfg!(target_os = "macos"))) }))
.collect();
list.push(json!({"id": "ui.dock", "label": "Arrange Panel", "enabled": true, "params": "action, or operation/panel; move: target/zone/before; float/moveFloating: rect; resizeSplit: path/size; setStackOpen: open; resizeStack: height"}));
json!({ "commands": list })
}
fn set(&mut self, key: &str, value: &str) -> Reply {
self.set_option(key, value).map(|()| json!({ "set": key, "value": value }))
}
#[cfg(not(target_arch = "wasm32"))]
fn open(&mut self, path: &str) -> Reply {
match self.open_path(path) {
crate::OpenOutcome::Opened => Ok(json!({ "opened": path, "password_prompt": self.password_prompt.is_some() })),
crate::OpenOutcome::Focused => Ok(json!({ "opened": path, "focused": true })),
crate::OpenOutcome::Pending => Ok(json!({ "accepted": path, "pending": true })),
crate::OpenOutcome::Failed => Err(self.toast.as_ref().map(|t| t.0.clone()).unwrap_or_else(|| format!("couldn't open {path}"))),
}
}
#[cfg(target_arch = "wasm32")]
fn open(&mut self, _path: &str) -> Reply {
Err("ui.open needs a file system; on the web, drop the file on the page".into())
}
}
// ---- transport (native only) -----------------------------------------------------------------
/// Where a running app's control channel can be reached (written to the `--control` file).
#[cfg(not(target_arch = "wasm32"))]
pub struct Endpoint {
pub port: u16,
pub token: String,
}
/// What a connection may do before it authenticates, so an unauthenticated local client cannot
/// hold a thread for ever or exhaust them (#1806).
#[cfg(not(target_arch = "wasm32"))]
#[derive(Clone, Copy)]
struct Limits {
/// How long a connection has to authenticate, counted from its acceptance: an absolute
/// deadline, so a client feeding one byte at a time cannot extend it.
auth_deadline: std::time::Duration,
/// How many connections may wait for authentication at once; further ones are refused.
max_unauthenticated: usize,
/// How long a write may block on a client that never reads its replies.
write_timeout: std::time::Duration,
/// How long a connection refused after a reply is kept half-open, discarding input, so the
/// reply is not lost to a reset (see [`close_after_reply`]).
linger: std::time::Duration,
/// How many refused connections may linger at once. A lingering connection has given back
/// its place among [`Self::max_unauthenticated`], so this is what bounds their threads;
/// beyond it a refused connection is closed at once.
max_lingering: usize,
}
#[cfg(not(target_arch = "wasm32"))]
const LIMITS: Limits = Limits {
auth_deadline: std::time::Duration::from_secs(10),
max_unauthenticated: 8,
write_timeout: std::time::Duration::from_secs(30),
linger: std::time::Duration::from_secs(1),
max_lingering: 8,
};
/// One of a limited number of places, given back when dropped: a place among
/// [`Limits::max_unauthenticated`] (given back when the connection authenticates or is refused,
/// and on every way its thread can end), or among [`Limits::max_lingering`].
#[cfg(not(target_arch = "wasm32"))]
struct Slot(std::sync::Arc<std::sync::atomic::AtomicUsize>);
#[cfg(not(target_arch = "wasm32"))]
impl Slot {
fn take(waiting: &std::sync::Arc<std::sync::atomic::AtomicUsize>, max: usize) -> Option<Self> {
use std::sync::atomic::Ordering;
let mut n = waiting.load(Ordering::Acquire);
loop {
if n >= max {
return None;
}
match waiting.compare_exchange_weak(n, n.saturating_add(1), Ordering::AcqRel, Ordering::Acquire) {
Ok(_) => return Some(Slot(waiting.clone())),
Err(now) => n = now,
}
}
}
}
#[cfg(not(target_arch = "wasm32"))]
impl Drop for Slot {
fn drop(&mut self) {
self.0.fetch_sub(1, std::sync::atomic::Ordering::AcqRel);
}
}
/// The reading half of a connection, with the authentication deadline: every read before it
/// gets the time left as its timeout, and none at all once it has passed.
#[cfg(not(target_arch = "wasm32"))]
struct Reading {
stream: std::net::TcpStream,
until: Option<std::time::Instant>,
}
#[cfg(not(target_arch = "wasm32"))]
impl std::io::Read for Reading {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if let Some(until) = self.until {
let left = until.saturating_duration_since(std::time::Instant::now());
if left.is_zero() {
return Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "the authentication deadline passed"));
}
self.stream.set_read_timeout(Some(left))?;
}
self.stream.read(buf)
}
}
/// Close a connection whose last reply has just been written without losing that reply.
///
/// Closing a socket with input still unread makes the kernel send a reset instead of a FIN, and
/// the reset can discard the reply before the client reads it (Linux does): a client still
/// sending when it is refused would never learn why. So send FIN first, then read and discard
/// whatever arrives until the client closes its side or `linger` passes, and only then close.
///
/// Only [`Limits::max_lingering`] connections linger at once: `place` is one of those places,
/// taken by [`refuse`] before the reply was written, and held for the drain only. Without one
/// the connection is closed at once after the FIN, accepting the risk of a reset rather than
/// holding another thread.
#[cfg(not(target_arch = "wasm32"))]
fn close_after_reply(reading: &mut Reading, place: Option<Slot>, linger: std::time::Duration) {
use std::io::Read;
let _ = reading.stream.shutdown(std::net::Shutdown::Write);
let Some(_place) = place else { return };
reading.until = Some(std::time::Instant::now() + linger);
let mut sink = [0u8; 1024];
// Each read is bounded by the time left, and none is made once `linger` has passed.
while matches!(reading.read(&mut sink), Ok(n) if n > 0) {}
}
/// A connection is being refused before `auth`: give back its place among those waiting to
/// authenticate, so it does not keep a real client out while it lingers, and take a place to
/// linger in if one is free. Done before the reply is written, so a client that has read the
/// reply sees the places as they will be.
#[cfg(not(target_arch = "wasm32"))]
fn refuse(slot: &mut Option<Slot>, lingering: &std::sync::Arc<std::sync::atomic::AtomicUsize>, limits: Limits) -> Option<Slot> {
drop(slot.take());
Slot::take(lingering, limits.max_lingering)
}
/// Listen on `127.0.0.1` (random port) and forward authenticated requests to `client`.
///
/// Protocol: newline-delimited JSON-RPC 2.0. The first request on a connection must be
/// `{"method": "auth", "params": {"token": …}}`; anything else closes the connection, and so does
/// a line longer than `MAX_AUTH_LINE` before that, or `LIMITS` passing without it.
#[cfg(not(target_arch = "wasm32"))]
pub fn serve(client: ControlClient) -> std::io::Result<Endpoint> {
serve_with(client, LIMITS)
}
/// How long the accepting thread waits after a failed accept. Accepting can keep failing, for
/// example while the process has no file descriptors left, and retrying at once would keep a
/// core busy until it recovers. The single-instance listener waits the same way.
#[cfg(not(target_arch = "wasm32"))]
const ACCEPT_RETRY: std::time::Duration = std::time::Duration::from_millis(100);
/// The connections `incoming` accepts, calling `wait` after each failed accept.
#[cfg(not(target_arch = "wasm32"))]
fn accepted<T>(incoming: impl Iterator<Item = std::io::Result<T>>, mut wait: impl FnMut()) -> impl Iterator<Item = T> {
incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok())
}
#[cfg(not(target_arch = "wasm32"))]
fn serve_with(client: ControlClient, limits: Limits) -> std::io::Result<Endpoint> {
use std::io::{BufReader, Write};
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
let port = listener.local_addr()?.port();
let token = random_token()?;
let expected = token.clone();
let waiting = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let lingering = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
std::thread::Builder::new().name("pdfcraft-control".into()).spawn(move || {
for stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
let _ = stream.set_write_timeout(Some(limits.write_timeout));
// Admitted only while fewer than the limit wait to authenticate: refused on the
// accepting thread, so a refused connection costs no thread at all.
let Some(slot) = Slot::take(&waiting, limits.max_unauthenticated) else {
let mut write = stream;
let e = rpc_error(Value::Null, -32003, "too many connections waiting to authenticate; try again later");
let _ = writeln!(write, "{e}").and_then(|_| write.flush());
continue;
};
let client = client.clone();
let expected = expected.clone();
let lingering = lingering.clone();
let _ = std::thread::Builder::new().name("pdfcraft-control-conn".into()).spawn(move || {
// Held until `auth` succeeds; given back however this thread ends.
let mut slot = Some(slot);
let Ok(read) = stream.try_clone() else { return };
let mut write = stream;
let mut authed = false;
let mut reader = BufReader::new(Reading { stream: read, until: Some(std::time::Instant::now() + limits.auth_deadline) });
let mut buf = Vec::new();
loop {
// Until `auth` succeeds, lines are capped at MAX_AUTH_LINE; afterwards they
// are read whole, as before.
let max = if authed { usize::MAX } else { MAX_AUTH_LINE };
match read_line_limited(&mut reader, &mut buf, max) {
Ok(LineRead::Line) => {}
// The deadline passed before `auth`: say so and close.
Err(e) if !authed && matches!(e.kind(), std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock) => {
let secs = limits.auth_deadline.as_secs_f64();
let e = rpc_error(Value::Null, -32004, &format!("not authenticated within {secs} s"));
let place = refuse(&mut slot, &lingering, limits);
if writeln!(write, "{e}").and_then(|_| write.flush()).is_ok() {
close_after_reply(reader.get_mut(), place, limits.linger);
}
break;
}
// End of stream or a failed read ends the connection, as before.
Ok(LineRead::End) | Err(_) => break,
Ok(LineRead::TooLong) => {
let e = rpc_error(Value::Null, -32600, &format!("request line longer than {max} bytes before auth"));
let place = refuse(&mut slot, &lingering, limits);
if writeln!(write, "{e}").and_then(|_| write.flush()).is_ok() {
close_after_reply(reader.get_mut(), place, limits.linger);
}
break;
}
}
// Bytes that aren't UTF-8 end the connection, as `BufRead::lines` did.
let Ok(line) = std::str::from_utf8(&buf) else { break };
if line.trim().is_empty() {
continue;
}
let (reply, close) = match serde_json::from_str::<Value>(line) {
// Before `auth`, like any other first request, this closes the connection.
Err(e) => (rpc_error(Value::Null, -32700, &format!("parse error: {e}")), !authed),
Ok(msg) => {
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("");
let params = msg.get("params").cloned().unwrap_or(Value::Null);
if !authed {
let ok =
method == "auth" && params.get("token").and_then(Value::as_str).is_some_and(|t| constant_time_eq(t, &expected));
authed = ok;
if ok {
// Authenticated: no deadline from here on, and its place is free.
reader.get_mut().until = None;
let _ = reader.get_mut().stream.set_read_timeout(None);
drop(slot.take());
(json!({ "jsonrpc": "2.0", "id": id, "result": { "ok": true } }), false)
} else {
(rpc_error(id, -32001, "authenticate first: auth {token}"), true)
}
} else {
let rx = client.send(method, params);
match rx.recv_timeout(std::time::Duration::from_secs(30)) {
Ok(Ok(v)) => (json!({ "jsonrpc": "2.0", "id": id, "result": v }), false),
Ok(Err(e)) => (rpc_error(id, -32000, &e), false),
Err(_) => (rpc_error(id, -32002, "the app did not answer within 30 s"), false),
}
}
}
};
// `close` is only ever set before `auth`.
let place = if close { refuse(&mut slot, &lingering, limits) } else { None };
if writeln!(write, "{reply}").and_then(|_| write.flush()).is_err() {
break;
}
if close {
close_after_reply(reader.get_mut(), place, limits.linger);
break;
}
}
});
}
})?;
Ok(Endpoint { port, token })
}
/// Longest line read before a connection has authenticated. The only acceptable request then is
/// `auth`, about a hundred bytes; a longer line is refused without buffering the rest of it.
#[cfg(not(target_arch = "wasm32"))]
const MAX_AUTH_LINE: usize = 4 * 1024;
/// What [`read_line_limited`] found.
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, PartialEq, Eq)]
enum LineRead {
/// A line, now in the buffer (newline and a trailing `\r` stripped).
Line,
/// End of stream.
End,
/// The line is longer than the limit; at most limit + 1 bytes of it were read.
TooLong,
}
/// Read one `\n`-terminated line of at most `max` bytes into `buf`.
#[cfg(not(target_arch = "wasm32"))]
fn read_line_limited(reader: &mut impl std::io::BufRead, buf: &mut Vec<u8>, max: usize) -> std::io::Result<LineRead> {
use std::io::{BufRead, Read};
buf.clear();
// `take` bounds what is buffered for one line: never more than `max` + 1 bytes.
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
if reader.take(limit).read_until(b'\n', buf)? == 0 {
return Ok(LineRead::End);
}
if buf.last() == Some(&b'\n') {
buf.pop();
if buf.last() == Some(&b'\r') {
buf.pop();
}
return Ok(LineRead::Line);
}
// No newline: either the limit was passed, or this is a last line before end of stream.
Ok(if buf.len() > max { LineRead::TooLong } else { LineRead::Line })
}
#[cfg(not(target_arch = "wasm32"))]
fn rpc_error(id: Value, code: i64, message: &str) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
}
#[cfg(not(target_arch = "wasm32"))]
fn constant_time_eq(a: &str, b: &str) -> bool {
a.len() == b.len() && a.bytes().zip(b.bytes()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
/// 128 random bits from the OS, hex-encoded.
#[cfg(not(target_arch = "wasm32"))]
fn random_token() -> std::io::Result<String> {
let mut bytes = [0u8; 16];
getrandom::fill(&mut bytes).map_err(|e| std::io::Error::other(e.to_string()))?;
Ok(bytes.iter().map(|b| format!("{b:02x}")).collect())
}
#[cfg(test)]
mod transport_tests {
/// A failed accept is followed by a wait before the next one, so a listener that keeps
/// failing does not spin, and the connections around the failures still come through.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_failed_accept_waits_before_the_next() {
let failed = || Err(std::io::Error::other("no file descriptors left"));
let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)];
let mut waits = 0;
let connections: Vec<i32> = super::accepted(incoming.into_iter(), || waits += 1).collect();
assert_eq!(connections, [1, 2]);
assert_eq!(waits, 3, "one wait per failed accept");
}
/// Before authenticating, a line that isn't JSON gets a parse error and closes the
/// connection, as [`super::serve`] documents for any first request other than `auth`.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn malformed_input_before_auth_closes_the_connection() {
use std::io::{BufRead, BufReader, ErrorKind, Write};
let (_control, client) = super::attach(&egui::Context::default());
let ep = super::serve(client).unwrap();
let s = std::net::TcpStream::connect(("127.0.0.1", ep.port)).unwrap();
s.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap();
writeln!(s.try_clone().unwrap(), "not json").unwrap();
let mut r = BufReader::new(s);
let mut first = String::new();
r.read_line(&mut first).unwrap();
let reply: serde_json::Value = serde_json::from_str(&first).unwrap();
assert_eq!(reply["error"]["code"], -32700, "{first}");
// Closed: end of stream (or a reset), not a connection left waiting for more input.
let mut next = String::new();
let closed = match r.read_line(&mut next) {
Ok(n) => n == 0,
Err(e) => matches!(e.kind(), ErrorKind::ConnectionReset | ErrorKind::ConnectionAborted),
};
assert!(closed, "the connection stayed open: {next:?}");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn lines_are_read_up_to_the_limit() {
use super::{LineRead, read_line_limited};
let mut buf = Vec::new();
let mut input: &[u8] = b"aaaaaaaaaa\nbb\r\ncc";
assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::Line);
assert_eq!(buf, b"aaaaaaaaaa", "a line exactly at the limit is accepted");
assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::Line);
assert_eq!(buf, b"bb", "CRLF is stripped");
assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::Line);
assert_eq!(buf, b"cc", "a last line without a newline is still read");
assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::End, "then end of stream");
let long = [b'x'; 100];
let mut input: &[u8] = &long;
assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::TooLong);
assert_eq!(input.len(), 100 - 11, "at most limit + 1 bytes are read; the rest is left");
}
/// Before authenticating, a line longer than [`super::MAX_AUTH_LINE`] is refused as soon as
/// the limit is passed (no newline needed) and the connection is closed.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn an_overlong_line_before_auth_is_refused() {
use std::io::{BufRead, BufReader, ErrorKind, Write};
let (_control, client) = super::attach(&egui::Context::default());
let ep = super::serve(client).unwrap();
let s = std::net::TcpStream::connect(("127.0.0.1", ep.port)).unwrap();
s.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap();
// Exactly one byte over the limit, without a newline: the server reads it all, so the
// close below is a clean end of stream.
s.try_clone().unwrap().write_all(&vec![b'x'; super::MAX_AUTH_LINE + 1]).unwrap();
let mut r = BufReader::new(s);
let mut first = String::new();
r.read_line(&mut first).unwrap();
let reply: serde_json::Value = serde_json::from_str(&first).unwrap();
assert_eq!(reply["error"]["code"], -32600, "{first}");
let mut next = String::new();
let closed = match r.read_line(&mut next) {
Ok(n) => n == 0,
Err(e) => matches!(e.kind(), ErrorKind::ConnectionReset | ErrorKind::ConnectionAborted),
};
assert!(closed, "the connection stayed open: {next:?}");
}
/// Small limits for the tests below: a 300 ms deadline, two places to wait in.
#[cfg(not(target_arch = "wasm32"))]
const TEST_LIMITS: super::Limits = super::Limits {
auth_deadline: std::time::Duration::from_millis(300),
max_unauthenticated: 2,
write_timeout: std::time::Duration::from_secs(5),
linger: std::time::Duration::from_secs(1),
max_lingering: 2,
};
/// The next line the server sends, as JSON, or `None` when it closed the connection instead.
#[cfg(not(target_arch = "wasm32"))]
fn next_line(r: &mut std::io::BufReader<std::net::TcpStream>) -> Option<serde_json::Value> {
use std::io::BufRead;
let mut line = String::new();
match r.read_line(&mut line) {
Ok(0) | Err(_) => None,
Ok(_) => Some(serde_json::from_str(&line).unwrap()),
}
}
#[cfg(not(target_arch = "wasm32"))]
fn connect(port: u16) -> std::net::TcpStream {
let s = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
s.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap();
s
}
/// #1806: a connection that never authenticates is closed at the deadline, counted from its
/// acceptance, and a client feeding a byte at a time cannot push that deadline back.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn an_unauthenticated_connection_is_closed_at_the_deadline() {
use std::io::{BufReader, Write};
let (_control, client) = super::attach(&egui::Context::default());
let ep = super::serve_with(client, TEST_LIMITS).unwrap();
// Idle.
let started = std::time::Instant::now();
let mut r = BufReader::new(connect(ep.port));
let reply = next_line(&mut r).expect("the server says why before closing");
assert_eq!(reply["error"]["code"], -32004, "{reply}");
let took = started.elapsed();
assert!(took >= std::time::Duration::from_millis(250) && took < std::time::Duration::from_secs(3), "{took:?}");
assert!(next_line(&mut r).is_none(), "then the connection is closed");
// Dripping: a byte every 50 ms for as long as the server keeps the connection.
let started = std::time::Instant::now();
let s = connect(ep.port);
let mut w = s.try_clone().unwrap();
let drip = std::thread::spawn(move || {
while w.write_all(b"x").is_ok() {
std::thread::sleep(std::time::Duration::from_millis(50));
if started.elapsed() > std::time::Duration::from_secs(3) {
break;
}
}
});
let mut r = BufReader::new(s);
let reply = next_line(&mut r).expect("the server says why before closing");
assert_eq!(reply["error"]["code"], -32004, "{reply}");
let took = started.elapsed();
assert!(took < std::time::Duration::from_secs(2), "the deadline is absolute: {took:?}");
drop(r);
drip.join().unwrap();
}
/// A client refused while the server still has its input unread gets the reason, not a
/// reset: closing on top of unread input sends a reset, which can discard the reply before
/// the client reads it (Linux does). Here a line far over the limit leaves most of itself
/// unread when the server refuses it.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_client_refused_with_input_unread_gets_the_reason() {
use std::io::{BufReader, Write};
let (_control, client) = super::attach(&egui::Context::default());
let ep = super::serve_with(client, TEST_LIMITS).unwrap();
let s = connect(ep.port);
let mut w = s.try_clone().unwrap();
w.write_all(&vec![b'x'; 16 * super::MAX_AUTH_LINE]).unwrap();
// Let the server refuse it (and, before the fix, close) before reading the reply.
std::thread::sleep(std::time::Duration::from_millis(200));
let mut r = BufReader::new(s);
let reply = next_line(&mut r).expect("the server says why before closing");
assert_eq!(reply["error"]["code"], -32600, "{reply}");
}
/// A refused connection gives back its place among those waiting to authenticate before it
/// lingers, so lingering does not keep real clients out.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_lingering_connection_holds_no_place_to_authenticate() {
use std::io::{BufReader, Write};
// A linger far longer than the checks below take, even on a loaded machine.
let limits = super::Limits { linger: std::time::Duration::from_secs(5), ..TEST_LIMITS };
let (_control, client) = super::attach(&egui::Context::default());
let ep = super::serve_with(client, limits).unwrap();
let auth = serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": "auth", "params": { "token": ep.token } });
// Refused with most of its line unread, and left open: it lingers for `linger`.
let refused = connect(ep.port);
refused.try_clone().unwrap().write_all(&vec![b'x'; 16 * super::MAX_AUTH_LINE]).unwrap();
let mut r = BufReader::new(refused.try_clone().unwrap());
assert_eq!(next_line(&mut r).expect("refused with a reason")["error"]["code"], -32600);
// Well within the linger, both places are free for new connections.
let started = std::time::Instant::now();
let others: Vec<_> = (0..limits.max_unauthenticated).map(|_| connect(ep.port)).collect();
for s in &others {
// A refused one has already been closed, so its write may fail; its reply says why.
let _ = writeln!(s.try_clone().unwrap(), "{auth}");
let reply = next_line(&mut BufReader::new(s.try_clone().unwrap())).expect("a reply");
assert_eq!(reply["result"]["ok"], true, "admitted while the refused one lingers: {reply}");
}
assert!(started.elapsed() < limits.linger, "checked while it lingered: {:?}", started.elapsed());
drop(refused);
}
/// Only [`super::Limits::max_lingering`] refused connections linger at once: one more still
/// gets its reply, and is then closed at once instead of lingering.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn only_so_many_refused_connections_linger() {
use std::io::{BufReader, Write};
// A linger far longer than the checks below take, even on a loaded machine.
let limits = super::Limits { linger: std::time::Duration::from_secs(5), max_lingering: 1, ..TEST_LIMITS };
let (_control, client) = super::attach(&egui::Context::default());
let ep = super::serve_with(client, limits).unwrap();
let bad = r#"{"jsonrpc": "2.0", "id": 1, "method": "page_count"}"#;
// The first refused connection takes the only lingering place and keeps it.
let first = connect(ep.port);
writeln!(first.try_clone().unwrap(), "{bad}").unwrap();
let mut r1 = BufReader::new(first.try_clone().unwrap());
assert_eq!(next_line(&mut r1).expect("refused with a reason")["error"]["code"], -32001);
// The second still gets its reason (it has nothing unread, so no reset)...
let second = connect(ep.port);
let mut w2 = second.try_clone().unwrap();
writeln!(w2, "{bad}").unwrap();
let mut r2 = BufReader::new(second.try_clone().unwrap());
assert_eq!(next_line(&mut r2).expect("refused with a reason")["error"]["code"], -32001);
// ...and is closed outright, not lingering: writing to it soon fails, where a lingering
// connection would read and discard for the whole of `linger`.
let started = std::time::Instant::now();
let closed = loop {
if w2.write_all(b"x").is_err() {
break true;
}
if started.elapsed() > limits.linger {
break false;
}
std::thread::sleep(std::time::Duration::from_millis(20));
};
assert!(closed && started.elapsed() < limits.linger / 2, "it lingered: {:?}", started.elapsed());
drop(first);
}
/// #1806: only so many connections may wait to authenticate at once. One more is refused
/// on the spot; once a waiting one authenticates or times out, its place is free again.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn only_so_many_connections_may_wait_to_authenticate() {
use std::io::{BufReader, Write};
let (_control, client) = super::attach(&egui::Context::default());
let ep = super::serve_with(client, TEST_LIMITS).unwrap();
let auth = serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": "auth", "params": { "token": ep.token } });
let first = connect(ep.port);
let second = connect(ep.port);
// The kernel accepts a connection before the listener admits it: give the listener a
// moment to admit both, well within their deadline.
std::thread::sleep(std::time::Duration::from_millis(100));
// The third finds both places taken.
let mut r3 = BufReader::new(connect(ep.port));
let reply = next_line(&mut r3).expect("refused with a reason");
assert_eq!(reply["error"]["code"], -32003, "{reply}");
assert!(next_line(&mut r3).is_none(), "and closed");
// The second authenticates: its place is free, so a new connection is admitted.
let mut r2 = BufReader::new(second.try_clone().unwrap());
let mut w2 = second.try_clone().unwrap();
writeln!(w2, "{auth}").unwrap();
assert_eq!(next_line(&mut r2).unwrap()["result"]["ok"], true);
let fourth = connect(ep.port);
let mut r4 = BufReader::new(fourth.try_clone().unwrap());
let mut w4 = fourth.try_clone().unwrap();
writeln!(w4, "{auth}").unwrap();
assert_eq!(next_line(&mut r4).unwrap()["result"]["ok"], true, "admitted and authenticated");
// The first was closed at the deadline (its place freed on that way out too): after it,
// another connection is admitted even though the first never authenticated.
drop(first);
std::thread::sleep(TEST_LIMITS.auth_deadline + std::time::Duration::from_millis(100));
let fifth = connect(ep.port);
let mut r5 = BufReader::new(fifth.try_clone().unwrap());
let mut w5 = fifth.try_clone().unwrap();
writeln!(w5, "{auth}").unwrap();
assert_eq!(next_line(&mut r5).unwrap()["result"]["ok"], true);
}
/// After `auth` the limit no longer applies: a long line is read as before (here it isn't
/// JSON, so it gets a parse error and the connection stays open).
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn long_lines_after_auth_are_still_read() {
use std::io::{BufRead, BufReader, Write};
let (_control, client) = super::attach(&egui::Context::default());
let ep = super::serve(client).unwrap();
let s = std::net::TcpStream::connect(("127.0.0.1", ep.port)).unwrap();
s.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap();
let mut w = s.try_clone().unwrap();
let mut r = BufReader::new(s);
let reply = |r: &mut BufReader<std::net::TcpStream>| {
let mut line = String::new();
r.read_line(&mut line).unwrap();
serde_json::from_str::<serde_json::Value>(&line).unwrap()
};
let auth = serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": "auth", "params": { "token": ep.token } });
writeln!(w, "{auth}").unwrap();
assert_eq!(reply(&mut r)["result"]["ok"], true);
writeln!(w, "{}", "x".repeat(4 * super::MAX_AUTH_LINE)).unwrap();
assert_eq!(reply(&mut r)["error"]["code"], -32700, "a long line after auth is parsed, not refused");
writeln!(w, "y").unwrap();
assert_eq!(reply(&mut r)["error"]["code"], -32700, "and the connection is still open");
}
}
#[cfg(test)]
mod pointer_tests {
use super::*;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
#[test]
fn coordinate_clicks_activate_once_with_slow_frames() {
struct Probe {
control: Option<Control>,
client: Option<ControlClient>,
host: crate::PdfCraftApp,
primary: usize,
secondary: usize,
disabled: usize,
discard: bool,
discarded: bool,
events: Vec<(f64, egui::PointerButton, bool, egui::Pos2)>,
}
fn request(h: &mut Harness<'_, Probe>, c: &ControlClient, params: Value) -> Reply {
let rx = c.send("ui.click", params);
for _ in 0..8 {
let before = h.ctx.input(|i| i.time);
h.step();
assert!(h.ctx.input(|i| i.time) > before, "simulated input time advances");
if let Ok(reply) = rx.try_recv() {
return reply;
}
}
panic!("coordinate click did not reply within eight steps");
}
let max_duration = egui::Context::default().options(|o| o.input_options.max_click_duration);
let slow_dt = (max_duration + 0.25) as f32;
assert!(f64::from(slow_dt) > max_duration);
for discard in [false, true] {
let state = Probe {
control: None,
client: None,
host: crate::PdfCraftApp::new(),
primary: 0,
secondary: 0,
disabled: 0,
discard,
discarded: false,
events: Vec::new(),
};
let mut h = Harness::builder().with_size(egui::vec2(320.0, 200.0)).with_step_dt(slow_dt).build_ui_state(
|ui, state: &mut Probe| {
if state.control.is_none() {
let (control, client) = attach(ui.ctx());
state.control = Some(control);
state.client = Some(client);
ui.ctx().options_mut(|o| o.max_passes = if state.discard { 2 } else { 1 }.try_into().unwrap());
}
state.control.as_mut().unwrap().tick(ui.ctx(), &mut state.host);
ui.input(|i| {
for event in &i.events {
if let egui::Event::PointerButton { pos, button, pressed, .. } = event {
assert!(state.events.len() < 32, "bounded pointer trace");
state.events.push((i.time, *button, *pressed, *pos));
}
}
});
let response = ui.button("Click probe");
if response.clicked() {
state.primary += 1;
if state.discard && !state.discarded {
state.discarded = true;
ui.ctx().request_discard("control click must not replay across passes");
}
}
if response.secondary_clicked() {
state.secondary += 1;
}
let disabled = ui.add_enabled(false, egui::Button::new("Disabled probe"));
if disabled.clicked() || disabled.secondary_clicked() {
state.disabled += 1;
}
},
state,
);
h.run_steps(3);
let c = h.state().client.as_ref().unwrap().clone();
assert_eq!(h.ctx.options(|o| o.input_options.max_click_duration), max_duration);
let pos = h.get_by_label("Click probe").rect().center();
assert_eq!((h.state().primary, h.state().secondary, h.state().disabled), (0, 0, 0));
let reply = request(&mut h, &c, json!({"x":pos.x,"y":pos.y})).unwrap();
assert_eq!(reply["clicked"], json!([pos.x, pos.y]));
println!("slow control click discard={discard} duration_limit={max_duration} step_dt={slow_dt} pointer_events={:?}", h.state().events);
let events = &h.state().events;
assert_eq!(
events.iter().map(|(_, button, pressed, point)| (*button, *pressed, *point)).collect::<Vec<_>>(),
[(egui::PointerButton::Primary, true, pos), (egui::PointerButton::Primary, false, pos)]
);
let elapsed = events[1].0 - events[0].0;
assert!(elapsed == 0.0 || elapsed >= f64::from(slow_dt) - 1e-6, "the pair is atomic or crosses a simulated slow frame");
assert_eq!(h.state().primary, 1, "a coordinate click activates the real button once on slow frames");
h.run_steps(3);
assert_eq!(h.state().primary, 1, "later passes must not replay the click");
assert_eq!(h.state().discarded, discard);
request(&mut h, &c, json!({"x":pos.x,"y":pos.y,"button":"secondary"})).unwrap();
assert_eq!((h.state().primary, h.state().secondary), (1, 1));
let disabled = h.get_by_label("Disabled probe").rect().center();
request(&mut h, &c, json!({"x":disabled.x,"y":disabled.y})).unwrap();
assert_eq!((h.state().primary, h.state().secondary, h.state().disabled), (1, 1, 0));
let disabled_id = h
.state()
.control
.as_ref()
.unwrap()
.shared
.lock()
.unwrap()
.nodes
.iter()
.find(|(_, node)| node.label() == Some("Disabled probe"))
.unwrap()
.0
.0;
assert!(request(&mut h, &c, json!({"id":disabled_id})).unwrap_err().contains("disabled"));
assert!(h.state().host.views.is_empty(), "pointer probes create no document edits");
assert_eq!(h.ctx.options(|o| o.input_options.max_click_duration), max_duration);
}
}
}