//! Programmatic control of the running app (agents, tests, MCP). //! //! Methods (JSON lines over the host's transport, see `docs/control-protocol.md`): //! - `engine.execute {command, params}` — run any engine or UI command //! - `engine.commands {search?}` — every command with enablement and params docs //! - `document.inspect`, `sheet.read {range?}`, `cell.get {cell?}` — read state //! - `ui.inspect` — UI state (ribbon tab, editor, dialog, selected object, grid geometry, perf) //! - `ui.object.select {id?, kind?}` — select a floating object; replies with its rect //! - `ui.click {x, y, button?, count?, shift?, cmd?, alt?}`, `ui.drag {x, y, toX, toY}`, //! `ui.move {x, y}` — real pointer input in window points //! - `ui.cell.click {cell}`, `ui.cell.drag {from, to}` — pointer input on a cell's centre //! - `ui.key {key, shift?, cmd?, alt?, ctrl?}`, `ui.text {text}` — keyboard input //! - `ui.ribbon {tab}`, `ui.dialog {name}`, `ui.dialog.set {field, value}`, //! `ui.dialog.confirm`, `ui.dialog.cancel`, `ui.message.dismiss` //! - `ui.edit.begin {text?}`, `ui.edit.commit {move?}`, `ui.edit.cancel` //! - `ui.screenshot {path?}` — PNG of the window (base64 when no path); animations are settled //! - `ui.resize {width, height}`, `ui.set {dark?, formulaBar?, ribbonCollapsed?, animations?}` //! - `app.open {path, password?}`, `app.save {path?}`, `app.quit` use std::sync::mpsc::Sender; use craft_ui::capture::CaptureSlot; use gridcraft_engine::core::CellRef; use serde_json::{Value as Json, json}; use crate::SheetApp; mod screenshots; mod synthetic; pub use screenshots::{ CaptureGeometry, SCREENSHOT_DOCUMENT_CHANGED, SCREENSHOT_MAX_AGGREGATE_BYTES, SCREENSHOT_MAX_BASE64_BYTES, SCREENSHOT_MAX_ENCODING_BYTES, SCREENSHOT_MAX_IMAGE_BYTES, SCREENSHOT_MAX_PEAK_BYTES, SCREENSHOT_MAX_REQUESTS, SCREENSHOT_MAX_RETRIES, SCREENSHOT_VIEWPORT_CHANGED, Shots, UNKNOWN_CAPTURE_BYTES, drain_screenshots, encoding_working_bytes, issue_screenshots, save_image, }; use screenshots::{Shot, check_encoding_budget, check_peak_budget, reject_shot}; #[cfg(test)] use screenshots::{base64_len, png_worst_bytes, validate_image}; pub(crate) use screenshots::{current_capture_geometry, note_capture_geometry}; pub use synthetic::{MAX_QUEUED_SYNTHETIC_BYTES, MAX_QUEUED_SYNTHETIC_EVENTS, MAX_SYNTHETIC_TEXT_BYTES}; use synthetic::{cell_center, key_from, modifiers, push_click, push_drag, push_key, push_move, push_text}; pub type ControlResponse = Json; pub struct ControlRequest { pub method: String, pub params: Json, pub reply: Sender, /// Who has the request: [`PENDING`], [`TAKEN`] (the app runs it) or [`ABANDONED`] (the sender /// gave up waiting, so the app must not run it late: a client told its request timed out had /// it applied anyway on the next UI pass, and an agent that retried ran it twice (#1914). pub state: std::sync::Arc, } /// Queued, not yet run. pub const PENDING: u8 = 0; /// The app has started it: it will answer. pub const TAKEN: u8 = 1; /// Its sender gave up waiting (the reply timeout expired): it is dropped unrun. pub const ABANDONED: u8 = 2; impl ControlRequest { pub fn new(method: impl Into, params: Json) -> (Self, std::sync::mpsc::Receiver) { let (tx, rx) = std::sync::mpsc::channel(); (Self { method: method.into(), params, reply: tx, state: Default::default() }, rx) } /// Claim the request for running; false when its sender already gave up on it. pub fn take(&self) -> bool { use std::sync::atomic::Ordering; self.state.compare_exchange(PENDING, TAKEN, Ordering::SeqCst, Ordering::SeqCst).is_ok() || self.state.load(Ordering::SeqCst) == TAKEN } /// Give up on the request; false when the app has already started it (it will answer). pub fn abandon(state: &std::sync::atomic::AtomicU8) -> bool { use std::sync::atomic::Ordering; state.compare_exchange(PENDING, ABANDONED, Ordering::SeqCst, Ordering::SeqCst).is_ok() || state.load(Ordering::SeqCst) == ABANDONED } } pub enum Outcome { Done(Json), Screenshot { path: Option }, } fn ok(v: Json) -> Outcome { Outcome::Done(json!({"ok": true, "result": v})) } fn err(e: impl std::fmt::Display) -> Outcome { Outcome::Done(json!({"ok": false, "error": e.to_string()})) } fn wrap(r: Result) -> Outcome { match r { Ok(v) => ok(v), Err(e) => err(e), } } /// Runs a command for an agent. Commands that move the selection scroll the active cell into /// view, as the Go To dialog, the Name Box and the arrow keys do. fn run(app: &mut SheetApp, id: &str, params: Json) -> Result { let r = app.run(id, params); if r.is_ok() && matches!(id, "edit.goTo" | "edit.goToSpecial" | "selection.set" | "selection.move") { app.grid.ensure_visible = true; } r } /// [`run`] in another window (`window` or `doc` from the request), bringing back the active /// window afterwards unless the command itself switched windows. fn run_in(app: &mut SheetApp, target: gridcraft_engine::Target, id: &str, params: Json) -> Result { let guard = app.session.enter_target(target).map_err(|e| e.to_string())?; let r = run(app, id, params); app.session.leave_target(guard); r } /// A command called by its id as the method. Its params may name a `window` or `doc` to act in, /// except for the window commands, whose own params those are. fn direct(app: &mut SheetApp, id: &str, p: &Json, via_app: bool) -> Result { let mut params = p.clone(); let target = if gridcraft_engine::windows::is_window_command(id) { gridcraft_engine::Target::Active } else { let t = gridcraft_engine::Target::from_json(p).map_err(|e| e.to_string())?; if let Some(o) = params.as_object_mut() { o.remove("window"); o.remove("doc"); } t }; if via_app { return run_in(app, target, id, params); } let guard = app.session.enter_target(target).map_err(|e| e.to_string())?; let r = app.session.run(id, params); app.session.leave_target(guard); r } pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json { let r = ctx.content_rect(); let v = app.view(); json!({ "ui": serde_json::to_value(&app.ui).unwrap_or_default(), "window": [r.width(), r.height()], "grid": app.grid.cells_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])), "scroll": [v.scroll.x, v.scroll.y], "editor": app.editor.as_ref().map(|e| editor_json(app, e)), "dialog": app.dialog.as_ref().map(|d| json!({"name": d.name, "title": d.title, "values": d.values, "tab": d.tab})), "backstage": app.backstage.as_ref().map(|b| b.page.id()), "message": app.message.as_ref().map(|(t, m)| json!({"title": t, "text": m})), "selectedObject": app.selected_object, // The id when the selection is a chart, as before `selectedObject`. "selectedChart": app.selected_chart(), "mode": format!("{:?}", app.session.mode), "documents": app.session.documents().iter().map(|d| json!({"title": d.display_title(), "dirty": d.is_dirty()})).collect::>(), "activeDocument": app.session.active_index(), "windows": app.session.window_list(), "activeWindow": app.session.active_window(), "perf": {"frameMs": app.perf.frame_ms, "gridMs": app.perf.grid_ms, "fps": app.perf.fps}, }) } /// The open edit, for `ui.inspect`. The draft is left out (`text` is null, `hidden` true) while /// its cell's code is hidden on a protected sheet, checked now: a protection command earlier in /// the same batch runs before the next frame. That holds for a draft in Python mode, and for a /// draft that began on a Python cell's code, since leaving Python mode keeps the code as an /// ordinary draft; a draft typed over a Python cell holds only what was typed. The sheet is the /// draft's own, found by identity: a sheet inserted, deleted or moved in the same batch puts /// another at its index. With its sheet gone, such a draft is left out too. fn editor_json(app: &SheetApp, e: &crate::editor::EditState) -> Json { let code = e.python || e.py_cell; let hidden = code && app.session.documents().iter().find(|d| d.uid == e.doc).is_some_and(|d| match e.sheet_in(&d.wb).and_then(|i| d.wb.sheet(i)) { Some(sh) => gridcraft_engine::display::formula_hidden(&d.wb, sh, e.cell), None => true, }); let text = if hidden { Json::Null } else { json!(e.text) }; json!({"cell": e.cell.a1(), "text": text, "hidden": hidden, "formulaBar": e.from_formula_bar, "point": e.can_point(), "python": e.python}) } /// `ui.dialog.cancel`: closes the open dialog as its Cancel button does, going back to the sheet /// and selection from before a reference box picked. fn dialog_cancel(app: &mut SheetApp) -> Outcome { // The macro step editor's draft, overlays and debug run go with its own close. if app.dialog.as_ref().is_some_and(|d| d.name == "macroEditor") { return match crate::macro_editor::close_now(app) { Ok(()) => ok(Json::Null), Err(e) => err(e), }; } // Back to the sheet and selection from before a reference box picked, as Cancel does. // When the dialog's sheet can't be shown again (a text-box draft on the picked sheet // could not be saved), the dialog stays and the script gets the reason; the window's // Cancel closes it and shows the reason, since a person can see the message. // A dialog whose own sheet was deleted (found by identity, so a new sheet given its // name does not count) replies `{"sheetDeleted": true}`: the sheet shown is not the // dialog's, and a script stops before running the dialog's command itself. let mut deleted = false; if let Some(mut d) = app.dialog.take() { deleted = d.home_sheet_deleted(app); if let Err(e) = d.end_pick(app) { app.dialog = Some(d); return err(e); } } ok(if deleted { json!({"sheetDeleted": true}) } else { Json::Null }) } /// `ui.object.select {id?, kind?}`: selects the object with that id on the active sheet (its kind /// is found from the id when left out, and must match when given); no `id` clears the selection. /// Replies with the object and its rect in window points, for `ui.click` and `ui.drag`. fn select_object(app: &mut SheetApp, p: &Json) -> Outcome { use gridcraft_engine::model::{ObjKind, ObjRef}; let Some(id) = p.get("id").and_then(Json::as_u64) else { app.selected_object = None; return ok(Json::Null); }; let Some(sh) = app.session.active().and_then(|d| d.wb.active()) else { return err("no document") }; let found = u32::try_from(id).ok().and_then(|id| match p.get("kind").and_then(Json::as_str) { Some(k) => ObjKind::parse(k).map(|k| ObjRef::new(k, id)).filter(|o| sh.has_object(*o)), None => sh.object_ref(id), }); let Some(o) = found else { return err("no such object on the active sheet") }; let rect = app.grid.rect.and_then(|r| { let geo = crate::grid::Geo::new(sh, r, app.view().scroll); crate::chartview::object_rect(&geo, sh, o).map(|r| geo.mirror_rect(r)) }); app.selected_object = Some(o); ok(json!({"selected": o, "rect": rect.map(|r| [r.left(), r.top(), r.width(), r.height()])})) } /// Keeps the commands whose id, label or ribbon path contains `search` (case-insensitive); `None` keeps all. fn filter_commands(commands: Vec, search: Option<&str>) -> Vec { let Some(q) = search.map(str::to_ascii_lowercase) else { return commands }; let has = |c: &Json, k: &str| c.get(k).and_then(Json::as_str).is_some_and(|v| v.to_ascii_lowercase().contains(&q)); let in_menu = |c: &Json| { let menu = c.get("menu").and_then(Json::as_array); menu.is_some_and(|m| m.iter().filter_map(Json::as_str).any(|v| v.to_ascii_lowercase().contains(&q))) }; commands.into_iter().filter(|c| has(c, "id") || has(c, "label") || in_menu(c)).collect() } /// Dispatches one control request: commands, inspection, UI state, dialogs, editing, synthetic input, open/save/quit and screenshots. Unknown methods (and unknown command ids) return an error. Immediate answers request a repaint; `ui.screenshot` is admitted asynchronously instead of being answered here. pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -> Outcome { let s = |k: &str| p.get(k).and_then(Json::as_str); let f = |k: &str| p.get(k).and_then(Json::as_f64).unwrap_or(0.0) as f32; let out = match method { "engine.execute" | "command" | "ui.menu.invoke" => { let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; let params = p.get("params").cloned().unwrap_or(json!({})); let target = match gridcraft_engine::Target::from_json(p) { Ok(t) => t, Err(e) => return err(e), }; wrap(run_in(app, target, id, if params.is_null() { json!({}) } else { params })) } "engine.commands" => { let mut v: Vec = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); let language_params = format!("{{language: {}}} (alias: code)", crate::i18n::Language::code_list("|")); for (id, label, params) in [ ("ui.ribbonTab", "Ribbon Tab", ""), ("app.checkForUpdates", "Check for Updates…", ""), ("view.collapseRibbon", "Collapse Ribbon", ""), ( "view.expandFormulaBar", "Expand Formula Bar", "{on?: bool} (toggles without it) → {on}; {lines: int} sets the expanded height (1 collapses) → {on, lines}", ), ("ui.pythonMode", "Python Mode", "{on?: bool} (toggles without it) → {on}; a refused start is an error"), ("view.darkMode", "Dark Mode", ""), ("view.theme", "Display Theme", ""), ("view.animations", "Animations", "{on?: bool} (toggles without it) → {on}"), ("options.autoComplete", "Enable AutoComplete for cell values", "{on?: bool} (toggles without it) → {on}"), ("options.highlightActiveRow", "Highlight the active cell's row", "{on?: bool} (toggles without it) → {on}"), ("view.interfaceScale", "Interface Size", "{percent: 75–200 | \"auto\"} (100 = auto; clamped)"), ("ui.dialog", "Open Dialog", ""), ("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|account|close}"), ( "ui.printSettings", "Print Settings", "{printer?: discovered name, copies?: 1..999, duplex?: printerDefault|off|longEdge|shortEdge|null, grayscale?: bool, refresh?: bool} → {printer,copies,duplex,grayscale,printers,ready,busy,discovering,status,detail}; {} inspects without submitting", ), ( "ui.printSubmit", "Submit Print Job", "existing file.print sheet/pagination params → {printing,bytes,pending}; submits without opening a dialog", ), ("app.language.set", "Interface Language", language_params.as_str()), ("app.language.english", "Interface Language: English", ""), ("app.language.japanese", "Interface Language: Japanese", ""), ] { v.push(json!({"id": id, "label": label, "params": params, "enabled": true, "ui": true})); } ok(Json::Array(filter_commands(v, s("search")))) } "engine.journal" => ok(json!(app.session.journal.iter().map(|(id, p)| json!({"command": id, "params": p})).collect::>())), "document.inspect" | "sheet.read" | "cell.get" | "selection.stats" => wrap(direct(app, method, p, false)), "ui.inspect" => ok(inspect(app, ctx)), "ui.ribbon" => { app.ui.ribbon_tab = s("tab").unwrap_or("Home").to_string(); app.ui.ribbon_collapsed = false; ok(Json::Null) } "ui.set" => { if let Some(v) = p.get("dark").and_then(Json::as_bool) { app.ui.dark = v; app.ui.system_theme = false; } if let Some(v) = p.get("formulaBar").and_then(Json::as_bool) { let _ = app.run("view.formulaBar", json!({"on": v})); } if let Some(v) = p.get("ribbonCollapsed").and_then(Json::as_bool) { app.ui.ribbon_collapsed = v; } if let Some(v) = p.get("animations").and_then(Json::as_bool) { app.ui.animations = v; } let mut out = serde_json::to_value(&app.ui).unwrap_or_default(); if let Some(o) = out.as_object_mut() { o.insert("formulaBar".into(), json!(app.session.prefs.formula_bar)); } ok(out) } "ui.dialog" => { app.open_dialog(s("name").unwrap_or(""), p.clone()); ok(json!({"dialog": app.dialog.as_ref().map(|d| d.name.clone())})) } "ui.dialog.set" => match app.dialog.as_mut() { Some(d) => { let Some(field) = s("field") else { return err("missing `field`") }; if field == "tab" { d.tab = s("value").unwrap_or("").to_string(); } else if field == "search" { d.search = s("value").unwrap_or("").to_string(); } else { d.set_value(field, p.get("value").cloned().unwrap_or(Json::Null)); } ok(json!(d.values)) } None => err("no dialog is open"), }, "ui.dialog.confirm" => match app.dialog.take() { Some(mut d) => match d.command { Some(cmd) => { // References without a sheet name mean the sheet the dialog was opened on, // as OK in the window has it: refused once that sheet is deleted, else shown // again before the command runs. if d.home_sheet_deleted(app) { app.dialog = Some(d); return err(crate::dialogs::SHEET_DELETED); } if let Err(e) = d.end_pick(app) { // The dialog's sheet can't be shown again (a text-box draft on the picked // sheet could not be saved): the command stays unrun, the dialog open. app.dialog = Some(d); return err(e); } let params = crate::dialogs::build_params(&d); let home = app.ui_window(); let r = app.run(cmd, params); if r.is_err() { app.return_dialog(home, d); } wrap(r) } None => { app.dialog = Some(d); err("this dialog has no OK command; drive it with its own commands") } }, None => err("no dialog is open"), }, "ui.dialog.cancel" => dialog_cancel(app), "ui.object.select" => select_object(app, p), "ui.message.dismiss" => { app.message = None; ok(Json::Null) } "ui.edit.begin" => { app.begin_edit(s("text").map(str::to_string), false); ok(Json::Null) } "ui.edit.text" => match app.editor.as_mut() { Some(e) => { e.text = s("text").unwrap_or("").to_string(); e.caret = e.text.chars().count(); ok(Json::Null) } None => err("not editing"), }, "ui.edit.commit" => { let (dr, dc) = match s("move") { Some("right") => (0, 1), Some("up") => (-1, 0), Some("left") => (0, -1), Some("none") => (0, 0), _ => (1, 0), }; if app.commit_edit(dr, dc, false, false) { ok(Json::Null) } else { err(app.message.clone().map(|m| m.1).unwrap_or_else(|| "could not commit".into())) } } "ui.edit.cancel" => { app.cancel_edit(); ok(Json::Null) } "ui.key" => { let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") }; match push_key(app, k, modifiers(p)) { Ok(()) => ok(Json::Null), Err(e) => err(e), } } "ui.text" => { let text = s("text").unwrap_or(""); // Check the borrowed payload first: do not make a second, potentially large copy // before the per-event bound is known to hold. if text.len() > MAX_SYNTHETIC_TEXT_BYTES { return err(format!("text input is {} bytes, over the {MAX_SYNTHETIC_TEXT_BYTES}-byte limit for one event", text.len())); } match push_text(app, text.to_string()) { Ok(()) => ok(Json::Null), Err(e) => err(e), } } "ui.move" => match push_move(app, egui::pos2(f("x"), f("y"))) { Ok(()) => ok(Json::Null), Err(e) => err(e), }, "ui.click" => { let button = if s("button") == Some("right") { egui::PointerButton::Secondary } else { egui::PointerButton::Primary }; let count = p.get("count").and_then(Json::as_u64).unwrap_or(1).min(u64::from(u32::MAX)) as u32; match push_click(app, egui::pos2(f("x"), f("y")), button, count, modifiers(p)) { Ok(()) => ok(Json::Null), Err(e) => err(e), } } "ui.drag" => { let steps = p.get("steps").and_then(Json::as_u64).unwrap_or(8).min(u64::from(u32::MAX)) as usize; match push_drag(app, egui::pos2(f("x"), f("y")), egui::pos2(f("toX"), f("toY")), steps, modifiers(p)) { Ok(()) => ok(Json::Null), Err(e) => err(e), } } "ui.cell.click" => { let Some(c) = s("cell").and_then(CellRef::parse) else { return err("missing `cell`") }; let Some(pos) = cell_center(app, c) else { return err("no grid yet") }; let button = if s("button") == Some("right") { egui::PointerButton::Secondary } else { egui::PointerButton::Primary }; let count = p.get("count").and_then(Json::as_u64).unwrap_or(1).min(u64::from(u32::MAX)) as u32; match push_click(app, pos, button, count, modifiers(p)) { Ok(()) => ok(json!({"x": pos.x, "y": pos.y})), Err(e) => err(e), } } "ui.cell.drag" => { let (Some(a), Some(b)) = (s("from").and_then(CellRef::parse), s("to").and_then(CellRef::parse)) else { return err("missing `from`/`to`"); }; let (Some(pa), Some(pb)) = (cell_center(app, a), cell_center(app, b)) else { return err("no grid yet") }; match push_drag(app, pa, pb, 8, modifiers(p)) { Ok(()) => ok(Json::Null), Err(e) => err(e), } } "ui.resize" => { ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(f("width").max(400.0), f("height").max(300.0)))); ok(Json::Null) } "ui.focus" => { ctx.send_viewport_cmd(egui::ViewportCommand::Focus); ok(Json::Null) } "ui.screenshot" => return Outcome::Screenshot { path: s("path").map(str::to_string) }, "app.open" => { let Some(path) = s("path") else { return err("missing `path`") }; // Not `open_path`: that asks for a missing password in a dialog, and programmatic // calls never open one. A password-protected workbook takes `password` instead. let r = app.run("file.open", json!({"path": path, "password": s("password")})); if let Ok(r) = &r { app.after_open(Some(path), r); } app.after_engine(); match (r, app.message.take()) { (Err(e), _) => err(crate::clean_error(&e)), (Ok(_), Some((_, m))) => err(m), (Ok(_), None) => ok(json!({"title": app.session.active().map(|d| d.display_title())})), } } "app.save" => wrap(app.run("file.save", json!({"path": s("path")}))), "app.quit" => { // Programmatic calls never open dialogs: quitting from here doesn't ask about unsaved work. app.allow_close = true; ctx.send_viewport_cmd(egui::ViewportCommand::Close); ok(Json::Null) } // Anything else that names a command runs it. other if gridcraft_engine::find_command(other).is_some() => wrap(direct(app, other, p, true)), other => err(format!("unknown method `{other}`")), }; ctx.request_repaint(); out } /// Drains queued control requests, drops requests whose client stopped waiting, handles immediate outcomes and admits screenshots, then requests a repaint while synthetic input remains. pub fn poll(app: &mut SheetApp, ctx: &egui::Context) { let Some(rx) = app.control_rx.take() else { return }; while let Ok(req) = rx.try_recv() { // A request whose sender stopped waiting is dropped unrun: running it would apply a // command the client was told had failed, and its reply would go nowhere (#1914). if !req.take() { continue; } let outcome = handle(app, ctx, &req.method, &req.params); match outcome { Outcome::Done(v) => { let _ = req.reply.send(v); } Outcome::Screenshot { path } => admit(app, ctx, path, req.reply), } } app.control_rx = Some(rx); if !app.synthetic.is_empty() { ctx.request_repaint(); } } /// Put the request token on every screenshot reply (success, rejection, timeout), so a client can /// correlate an asynchronous capture even when the reply order differs from the request order. fn with_token(mut reply: Json, token: u64) -> Json { if let Some(object) = reply.as_object_mut() { object.insert("request".into(), json!(token)); } reply } /// Returns the next screenshot token, wrapping and skipping zero. fn next_token(app: &mut SheetApp) -> u64 { let mut token = app.shots.token.wrapping_add(1); if token == 0 { token = 1; } app.shots.token = token; token } /// Admit one screenshot request against the count and byte budgets **before** any capture is /// issued. A rejected request gets one error reply and never evicts an earlier request. fn admit(app: &mut SheetApp, ctx: &egui::Context, path: Option, reply: Sender) { let token = next_token(app); let geometry = current_capture_geometry(app, ctx); let expected = geometry.expected_size(); let base64 = path.is_none(); // A minimized or not-yet-laid-out window has no size: reserve the largest image the // per-image limit allows and keep the request queued. Issuance waits for a real size, and the // deadline reports a timeout if none arrives. let bytes = match expected { Some(size) => { let Some(bytes) = geometry.retained_bytes() else { reject_shot(reply, token, "screenshot failed: the window size overflows the screenshot budget"); return; }; if bytes > SCREENSHOT_MAX_IMAGE_BYTES { reject_shot( reply, token, format!( "screenshot budget exceeded: {bytes} bytes for one {}×{} image, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit", size[0], size[1] ), ); return; } if let Err(message) = check_encoding_budget(size, base64) { reject_shot(reply, token, message); return; } bytes } None => UNKNOWN_CAPTURE_BYTES, }; if app.shots.requests.len() >= SCREENSHOT_MAX_REQUESTS { reject_shot( reply, token, format!("screenshot budget exceeded: {} requests are already outstanding (limit {SCREENSHOT_MAX_REQUESTS})", app.shots.requests.len()), ); return; } let reserved = app.shots.reserved().saturating_add(bytes); if reserved > SCREENSHOT_MAX_AGGREGATE_BYTES { reject_shot( reply, token, format!( "screenshot budget exceeded: {reserved} bytes reserved by outstanding requests, over the {SCREENSHOT_MAX_AGGREGATE_BYTES}-byte aggregate limit" ), ); return; } if let Some(size) = expected && let Err(message) = check_peak_budget(reserved, size, base64) { reject_shot(reply, token, message); return; } let now = crate::monotonic_ms(); let document = app.session.active().map(|d| d.uid); let epoch = note_capture_geometry(app, geometry); app.shots.requests.push(Shot { token, path, reply, deadline: now + 8000.0, ready_at: now + 100.0, frames: 0, settle_pass: None, issued: false, retries: 0, geometry, viewport_id: geometry.viewport_id, document, epoch, expected, bytes, // Issued only from `ui`, so the slot is created there against the geometry in force. slot: None, }); // A queued request with a capturable size has not issued a paint-requesting command yet; // `logic` keeps asking for hidden paints until `issue_screenshots` runs. A size-less request // asks for none (see `Shots::needs_hidden_paint`). ctx.request_repaint(); } /// Builds the standard token-carrying timeout reply for a screenshot request. fn timeout_reply(message: &str, token: u64) -> Json { json!({"ok": false, "error": message, "request": token}) } #[cfg(test)] mod tests;