Files
vectorcraft/crates/ui-egui/src/dialogs/plugin.rs
T
dotCooCoo ad05c3b99d Plug-ins: an install that automation asked for is not approved in its dialog
An approval question that a control request, MCP, injected input, or a
file dialog one of them opened leads to now keeps OK disabled and
refuses every confirm, a person's click included. The dialog says to
choose Object › Plug-ins › Install Plug-in…, and ui.installPlugin answers
approval: "personMustStartInstall". The person's own Install Plug-in…,
File › Open or drop inspects the module again and asks a new question
with its half-second delay.

The origin is fixed when the request is made and goes with a native
file dialog's deferred answer and the web picker's answers, which now
arrive apart from drops and from window.vectorcraft.openFile (always
automation). A question about taking another module's place now opens
in the approval dialog, and automation's replace: true no longer skips
it. A module that needs no approval still installs at once.
2026-11-11 21:56:58 -08:00

530 lines
24 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.
//! WebAssembly plug-ins in the menus and their generated dialogs.
//!
//! Object › Plug-ins lists the installed object filters (`plugin.dialog {id}`), then Install
//! Plug-in… and Reload Plug-ins; Effect › Plug-ins lists the live-effect plug-ins, which open the
//! effect dialog (`effect.dialog`). A filter with parameters opens dialog `plugin`: one field per
//! declared parameter (fields named after them; `__plugin` is the plug-in id) with a live
//! Preview, and OK keeps the run as one undo step (`plugin.run`). A filter without parameters runs
//! at once. A heavy filter (its manifest's `budget`) has no Preview, since a run may take minutes:
//! it runs in the background ([`crate::plugin_runs`]). A light filter that reports its progress
//! (plug-in API level 2 with the `progress` permission) and calls no commands runs in the
//! background too, with its progress and Cancel, when it runs at once or when OK runs it without a
//! kept preview ([`in_background`]). A filter or live effect with commands of
//! its own is a submenu named after it, holding its item and then its commands; a command with
//! parameters opens the same dialog for them ([`open_command`]). Exporters are formats of File › Export ›
//! Export As; one with parameters opens dialog `pluginExport` once the file is picked, and its
//! Export button writes the file. Importers open the files File › Open finds no built-in format
//! for, and File › Open With lists them, to open a file with one of them by name; one with
//! parameters opens dialog `pluginImport` once the file is picked, and its Open button opens it.
//!
//! Dialog `pluginInstall` asks a person to approve a plug-in's permissions or heavy budget. What it
//! shows and installs is held by the app, not in its fields; a control request or synthetic input
//! can't confirm it. Enter confirms it only when OK has the keyboard focus. OK takes no click, Enter
//! or Space until the question has been on screen in the focused window for [`APPROVAL_DELAY`], and
//! that time starts over for every new question and whenever the window loses the focus.
use std::sync::Arc;
use serde_json::{Map, Value, json};
use vectorcraft_engine::cmd::plugin::InstallApproval;
use vectorcraft_plugins::{Budget, Kind, Manifest, Plugin, permissions, registry};
use super::{DialogAction, DialogSpec, form};
use crate::VectorcraftApp;
use crate::menus::Item;
use crate::state::Dialog;
use crate::widgets;
/// The dialog kind of plug-in filters.
pub const KIND: &str = "plugin";
const CMD: &str = "plugin.run";
/// Object › Plug-ins' header while no filter plug-in is installed.
pub const NO_FILTERS: &str = "No filter plug-ins installed";
/// Headed by the plug-in's name, as it is (plug-in text is never translated).
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |d| d.str("__label"), body, confirm, preview: true, max_width: Some(420.0), ..DialogSpec::FORM };
/// A menu label for plug-in `name`: with an ellipsis when it opens a dialog.
fn label(name: &str, has_params: bool) -> &'static str {
let dots = if has_params && !name.ends_with('…') { "…" } else { "" };
registry::intern(&format!("{name}{dots}"))
}
/// A filter or live effect `plugin`'s item `own`, as the menu lists it: alone, or for a plug-in
/// with commands of its own, a submenu named after it holding `own` and then its commands. Each
/// command runs its command id (`plugin.<id>.<command>`), with an ellipsis when it has parameters,
/// whose dialog [`open_command`] opens.
fn with_commands(plugin: &Plugin, own: Item) -> Item {
let m = plugin.manifest();
if m.commands.is_empty() {
return own;
}
let mut items = vec![own, Item::Sep];
items.extend(m.commands.iter().map(|c| {
let id = registry::intern(&vectorcraft_engine::cmd::plugin::command_id(&m.id, &c.id));
Item::Cmd(label(&c.shown_label(), !c.params.is_empty()), id, Value::Null)
}));
Item::Sub(registry::intern(&permissions::display_text(m.name.trim(), permissions::MAX_SHOWN_NAME)), items)
}
/// Object › Plug-ins: the installed object filters (each with its commands, if it has any), then
/// Install Plug-in… and Reload Plug-ins.
pub fn object_menu() -> Vec<Item> {
let mut items: Vec<Item> = registry::list()
.iter()
.filter(|p| p.manifest().kind == Kind::Filter)
.map(|p| with_commands(p, Item::Cmd(label(&p.manifest().name, !p.manifest().params.is_empty()), "plugin.dialog", json!({ "id": p.id() }))))
.collect();
if items.is_empty() {
items.push(Item::Header(NO_FILTERS));
}
items.extend([
Item::Sep,
Item::Cmd("Install Plug-in…", "ui.installPlugin", Value::Null),
Item::Cmd("Reload Plug-ins", "plugin.reload", Value::Null),
]);
items
}
/// File › Open With's header while no importer plug-in is installed.
pub const NO_IMPORTERS: &str = "No importer plug-ins installed";
/// File › Open With: the installed importers (`file.openWith {importer}`), each by its name.
pub fn open_with_menu() -> Vec<Item> {
let items: Vec<Item> = vectorcraft_plugins::import::installed()
.iter()
.map(|p| Item::Cmd(label(&p.manifest().name, true), "file.openWith", json!({ "importer": vectorcraft_plugins::export::format_id(p.id()) })))
.collect();
if items.is_empty() { vec![Item::Header(NO_IMPORTERS)] } else { items }
}
/// Effect › Plug-ins: the live-effect plug-ins, each with its commands if it has any (`None`
/// while none is installed). Effects without options apply at once, as built-in ones do.
pub fn effect_menu() -> Option<Item> {
let items: Vec<Item> = vectorcraft_effects::plugin_effects()
.into_iter()
.map(|e| {
let own = match e.defaults.as_object().is_some_and(|o| o.is_empty()) {
true => Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })),
false => Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })),
};
match vectorcraft_plugins::effect::plugin_id(e.id).and_then(registry::get) {
Some(plugin) => with_commands(&plugin, own),
None => own,
}
})
.collect();
(!items.is_empty()).then_some(Item::Sub("Plug-ins", items))
}
/// `plugin.dialog {id, params?}`: run filter `id` at once when it takes no parameters or `params`
/// are given, else open its dialog prefilled with its defaults.
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let id = p.get("id").and_then(Value::as_str).ok_or("missing `id` (see plugin.list)")?;
let plugin = registry::get(id).ok_or_else(|| format!("no plug-in {id:?} is installed"))?;
let m = plugin.manifest();
match m.kind {
Kind::Filter => {}
Kind::Effect => return Err(format!("{} is a live effect (Effect › Plug-ins)", m.name)),
Kind::Exporter => return Err(format!("{} is an exporter (File › Export › Export As…)", m.name)),
Kind::Importer => return Err(format!("{} is an importer (File › Open With)", m.name)),
}
let given = p.get("params").filter(|v| v.as_object().is_some_and(|o| !o.is_empty()));
if m.params.is_empty() || given.is_some() {
return run(app, &plugin, json!({"id": id, "params": given.cloned().unwrap_or(Value::Null)}));
}
let mut fields = fields_for(m);
if m.budget == Budget::Light {
fields.insert("preview".into(), json!(true));
}
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields });
Ok(Value::Null)
}
/// The fields of plug-in `m`'s dialog: its parameters at their defaults, `__plugin` (its id) and
/// `__label` (its name, the heading).
fn fields_for(m: &Manifest) -> Map<String, Value> {
let mut fields = m.defaults();
fields.insert("__plugin".into(), json!(m.id));
fields.insert("__label".into(), json!(m.name.trim_end_matches('…')));
fields
}
/// The plug-in dialog `d` is for, its description drawn above the fields. `None` once it was
/// uninstalled, which closes the dialog.
fn described(ui: &mut egui::Ui, d: &Dialog) -> Option<Arc<Plugin>> {
let plugin = registry::get(&d.str("__plugin"))?;
let m = plugin.manifest();
if !m.description.is_empty() {
ui.label(egui::RichText::new(&m.description).color(crate::theme::Tokens::get(ui.ctx()).text_dim));
ui.add_space(8.0);
}
Some(plugin)
}
/// Opens the dialog of plug-in command `id` (`plugin.<plug-in id>.<command id>`), given no params,
/// when it has parameters and a document is open: its fields at their defaults (`__command` is the
/// command id) with a live Preview, and OK keeps the run as one undo step. False for any other
/// command, which then runs as it was given.
pub fn open_command(app: &mut VectorcraftApp, id: &str) -> bool {
let Some((plugin, c)) = vectorcraft_engine::cmd::plugin::command_of(id) else { return false };
if c.params.is_empty() || app.session.active().is_none() {
return false;
}
let mut fields = fields_for(&plugin.manifest().for_command(&c));
fields.insert("__command".into(), json!(id));
fields.insert("__label".into(), json!(c.shown_label()));
fields.insert("preview".into(), json!(true));
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields });
true
}
/// The plug-in command dialog `d` is for (`__command`), and the manifest it runs with; `None` for
/// a filter's own dialog.
fn command_of(d: &Dialog) -> Option<Manifest> {
let (plugin, c) = vectorcraft_engine::cmd::plugin::command_of(&d.str("__command"))?;
Some(plugin.manifest().for_command(&c))
}
/// The command dialog `d` runs: its plug-in command, or `plugin.run`.
fn command(d: &Dialog) -> String {
match d.str("__command") {
c if c.is_empty() => CMD.to_string(),
c => c,
}
}
/// The params of the command dialog `d` runs ([`command`]).
fn params(d: &Dialog) -> Value {
match d.str("__command").is_empty() {
true => json!({"id": d.str("__plugin"), "params": form::params(d)}),
false => json!({"params": form::params(d)}),
}
}
/// Does filter `m` run in the background ([`crate::plugin_runs`]) rather than on the UI thread? A
/// heavy one does, and so does a light one that reports its progress (the `progress` permission)
/// unless it calls commands, which run only in the session.
fn in_background(m: &Manifest) -> bool {
m.budget == Budget::Heavy || (m.permissions().contains(&permissions::Permission::Progress) && m.command_allowlist.is_empty())
}
/// Runs filter `plugin` with `plugin.run`'s params `p`: at once, or in the background
/// ([`in_background`]).
fn run(app: &mut VectorcraftApp, plugin: &Plugin, p: Value) -> Result<Value, String> {
match in_background(plugin.manifest()) {
false => app.run(CMD, p),
true => crate::plugin_runs::start(app, p),
}
}
/// The plug-in's description and fields (a command's: its own parameters), then the live
/// Preview; a heavy filter has a note instead that it runs in the background, since a run may take
/// minutes. A command dialog closes once the plug-in no longer has the command.
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let Some(plugin) = described(ui, d) else { return true };
let run_with = command_of(d);
if run_with.is_none() && !d.str("__command").is_empty() {
return true;
}
form::schema_fields(app, ui, d, run_with.as_ref().unwrap_or(plugin.manifest()));
match plugin.manifest().budget {
Budget::Light => {
let (label, p) = (d.str("__label"), params(d));
form::preview(app, ui, d, &label, &command(d), p);
}
// No Preview: each run may take minutes.
Budget::Heavy => {
ui.add_space(8.0);
let note = tl!("It may take minutes, so it runs in the background, where you can cancel it.");
ui.label(egui::RichText::new(note).color(crate::theme::Tokens::get(ui.ctx()).text_dim));
}
}
false
}
/// OK: a filter keeps its previewed run as one undo step; a heavy one, which has no preview, and a
/// light one that runs in the background ([`in_background`]) with Preview off or a failed preview,
/// start in the background ([`run`]) and the dialog closes. A preview still shown when Preview was
/// just turned off, or a preview that failed, is rolled back first.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let own_run = d.str("__command").is_empty();
let kept = |plugin: &Plugin| {
plugin.manifest().budget == Budget::Light && d.bool("preview") && app.session.in_interaction() && !d.fields.contains_key(form::REFUSED)
};
match registry::get(&d.str("__plugin")) {
Some(plugin) if own_run && in_background(plugin.manifest()) && !kept(&plugin) => {
if d.fields.contains_key(form::PREVIEWED) && app.session.in_interaction() {
let _ = app.session.cancel_interaction();
}
let r = run(app, &plugin, params(d));
if r.is_ok() {
app.ui.dialog = None;
}
r
}
_ => form::commit_preview(app, &command(d), params(d)),
}
}
/// The dialog kind of an exporter's parameters, after Export As picked the file.
pub const EXPORT_KIND: &str = "pluginExport";
/// Headed by the plug-in's name, as it is; Export writes the file.
pub(super) const EXPORT_SPEC: DialogSpec = DialogSpec {
heading: |d| d.str("__label"),
body: export_body,
confirm: export_confirm,
ok: Some("Export"),
max_width: Some(420.0),
..DialogSpec::FORM
};
/// Export As with exporter `plugin`, which has parameters: its fields at their defaults, and
/// `export`, the export's own parameters (`format`, `path`, …), kept for Export.
pub fn open_export(app: &mut VectorcraftApp, plugin: &Plugin, export: Value) {
let mut fields = fields_for(plugin.manifest());
fields.insert("__export".into(), export);
app.ui.dialog = Some(Dialog { kind: EXPORT_KIND.into(), fields });
}
fn export_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let Some(plugin) = described(ui, d) else { return true };
form::schema_fields(app, ui, d, plugin.manifest());
false
}
/// Writes the file with the export's parameters and the plug-in's, as `params`.
fn export_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut p = d.fields.get("__export").cloned().filter(Value::is_object).ok_or("no format to export to")?;
p["params"] = form::params(d);
let format = p.get("format").and_then(Value::as_str).map(str::to_string);
let path = p.get("path").and_then(Value::as_str).map(str::to_string);
app.ui.dialog = None;
crate::io::export(app, format.as_deref(), path, &p)
}
/// The dialog kind of an importer's parameters, after Open With picked the file.
pub const IMPORT_KIND: &str = "pluginImport";
/// Headed by the plug-in's name, as it is; Open opens the file.
pub(super) const IMPORT_SPEC: DialogSpec = DialogSpec {
heading: |d| d.str("__label"),
body: export_body,
confirm: import_confirm,
ok: Some("Open"),
max_width: Some(420.0),
..DialogSpec::FORM
};
/// Open With with importer `plugin`, which has parameters: its fields at their defaults, and
/// `open`, Open With's own parameters (`path`, `importer`, and `picked` for a file from the
/// picker, which is read off the UI thread), kept for Open.
pub fn open_import(app: &mut VectorcraftApp, plugin: &Plugin, open: Value) {
let mut fields = fields_for(plugin.manifest());
fields.insert("__open".into(), open);
app.ui.dialog = Some(Dialog { kind: IMPORT_KIND.into(), fields });
}
/// Opens the file with Open With's parameters and the plug-in's, as `params`.
fn import_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut p = d.fields.get("__open").cloned().filter(Value::is_object).ok_or("no file to open")?;
p["params"] = form::params(d);
app.ui.dialog = None;
let picked = p["picked"] == true;
crate::io::open_with_on(app, &p, picked && crate::opening::available())
}
/// The dialog kind of the approval of a plug-in install.
pub const INSTALL_KIND: &str = "pluginInstall";
/// Who asked for a plug-in install, fixed when the request is made.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum InstallOrigin {
/// The person at the keyboard: Install Plug-in…, File › Open or a drop.
Person,
/// A control or MCP request, a frame of synthetic input, or a file dialog that one of them
/// opened ([`VectorcraftApp::automation_asked`]): nothing in the dialog installs it, and the
/// person starts Install Plug-in… to install it.
Automation,
}
/// What [`ask_install`] answers, as `approval`, for a question automation asked: the person has to
/// start Install Plug-in… to install the module.
pub(crate) const PERSON_MUST_START_INSTALL: &str = "personMustStartInstall";
/// A plug-in install waiting for a person's answer.
pub(crate) struct PendingInstall {
/// The question and the lines under it, in the UI language.
pub message: String,
pub detail: String,
/// `plugin.install`'s params for OK.
pub params: Value,
/// What OK approves: the module the question is about.
pub approval: InstallApproval,
/// Who asked; a question automation asked has no OK that installs.
pub origin: InstallOrigin,
/// How long the question has been on screen; [`ask_install`] starts it over.
pub shown: Shown,
}
/// How long, in seconds, the approval's question stays on screen in the focused window, on every
/// frame and unchanged, before its OK takes a click, Enter or Space.
pub(crate) const APPROVAL_DELAY: f64 = 0.5;
/// How long the approval's question has been on screen, from the frames that drew it.
#[derive(Default)]
pub(crate) struct Shown {
/// The time of the first frame of the current showing: drawn, visible, in the focused window,
/// right after a frame that drew it too. `None` while it isn't shown.
since: Option<f64>,
/// The pass number and time of the last frame that drew it.
pass: Option<u64>,
last: Option<f64>,
/// A pointer button, Enter or Space was down on a frame where OK was disabled, and they haven't
/// all been let go of since.
pressed_early: bool,
/// OK takes input: shown for [`APPROVAL_DELAY`], with no press from before that held.
ready: bool,
}
impl Shown {
/// Notes a frame that draws the question in `ui` → whether OK is enabled on it. The showing
/// starts over when the frame before didn't draw it, when time went back, when the window lost
/// the focus since the frame before, and on any frame that is a measuring pass, is invisible or
/// comes while the window doesn't have the focus.
fn drawn(&mut self, ui: &egui::Ui) -> bool {
let pass = ui.ctx().cumulative_pass_nr();
let (now, focused, left, pressed) = ui.input(|i| {
let left = i.events.iter().any(|e| matches!(e, egui::Event::WindowFocused(false)));
let pressed = i.pointer.any_pressed() || [egui::Key::Enter, egui::Key::Space].into_iter().any(|k| i.key_pressed(k));
(i.time, i.focused, left, pressed || Self::held(i))
});
let seen = focused && ui.is_visible() && !ui.is_sizing_pass() && now.is_finite();
let follows = self.pass.is_some_and(|p| p.checked_add(1) == Some(pass)) && self.last.is_some_and(|t| t <= now);
if !seen || !follows || left {
self.since = None;
}
if seen && self.since.is_none() {
self.since = Some(now);
}
(self.pass, self.last) = (Some(pass), Some(now));
let enabled = self.since.is_some_and(|s| now - s >= APPROVAL_DELAY);
if pressed && !enabled {
self.pressed_early = true;
}
self.ready = enabled && !self.pressed_early;
if let Some(s) = self.since.filter(|_| !enabled) {
ui.ctx().request_repaint_after_secs((s + APPROVAL_DELAY - now) as f32);
}
enabled
}
/// After the frame's OK: a press from before OK took input stops counting once every pointer
/// button, Enter and Space are up.
fn released(&mut self, ui: &egui::Ui) {
if !ui.input(Self::held) {
self.pressed_early = false;
}
}
/// Whether a pointer button, Enter or Space is down: what can click OK when it is let go of or
/// repeats.
fn held(i: &egui::InputState) -> bool {
i.pointer.any_down() || i.key_down(egui::Key::Enter) || i.key_down(egui::Key::Space)
}
/// Whether OK takes input: the question was shown for [`APPROVAL_DELAY`] on the last frame
/// that drew it.
pub(crate) fn ready(&self) -> bool {
self.ready
}
}
/// No OK in the shared footer: the OK in its body is a `Sense::click` button, which Enter clicks
/// only when it has the keyboard focus.
pub(super) const INSTALL_SPEC: DialogSpec = DialogSpec {
heading: |_| tl!("Install Plug-in…").trim_end_matches('…').to_string(),
body_with_actions: Some(install_body),
confirm: install_confirm,
ok: None,
max_width: Some(420.0),
..DialogSpec::FORM
};
/// Asks a person whether to install what `pending` describes, its question not yet shown, even when
/// it reads as the one it replaces. → `{pending}`, and `approval: "personMustStartInstall"` when
/// automation asked.
pub(crate) fn ask_install(app: &mut VectorcraftApp, mut pending: PendingInstall) -> Value {
pending.shown = Shown::default();
let automated = pending.origin == InstallOrigin::Automation;
app.pending_plugin_install = Some(pending);
app.ui.dialog = Some(Dialog::new(INSTALL_KIND, json!({})));
if automated {
return json!({ "pending": INSTALL_KIND, "approval": PERSON_MUST_START_INSTALL });
}
json!({ "pending": INSTALL_KIND })
}
/// What the approval says when automation asked for the install; the error of its OK
/// ([`install_confirm`]) is the same text.
fn automation_asked_note() -> &'static str {
tl!("Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself.")
}
/// The question, the lines under it, and OK, which is disabled until the question has been shown
/// for [`APPROVAL_DELAY`] ([`Shown`]), and Cancel, which never waits. When automation asked, a note
/// says so and OK stays disabled.
fn install_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, _: &mut Dialog) -> DialogAction {
let Some(pending) = app.pending_plugin_install.as_mut() else { return DialogAction::Cancel };
let person = pending.origin == InstallOrigin::Person;
let enabled = pending.shown.drawn(ui) && person;
ui.label(egui::RichText::new(&pending.message).strong());
ui.add_space(8.0);
ui.label(egui::RichText::new(&pending.detail).color(crate::theme::Tokens::get(ui.ctx()).text_dim));
if !person {
ui.add_space(8.0);
ui.label(egui::RichText::new(automation_asked_note()).strong());
}
ui.add_space(16.0);
let mut action = DialogAction::None;
let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y);
ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::primary_button_enabled(ui, "OK", enabled).clicked() && person && pending.shown.ready() {
action = DialogAction::Confirm;
}
pending.shown.released(ui);
ui.add_space(8.0);
if widgets::secondary_button(ui, "Cancel").clicked() {
action = DialogAction::Cancel;
}
});
action
}
/// OK: installs the module the dialog showed, if a person pressed it; automation is refused and the
/// dialog stays open, and so is any OK of a question automation asked. Before the question has been
/// shown for [`APPROVAL_DELAY`] it installs nothing and the dialog stays open. → `{pending}`.
fn install_confirm(app: &mut VectorcraftApp, _: &Dialog) -> Result<Value, String> {
if app.pending_plugin_install.as_ref().is_some_and(|p| p.origin == InstallOrigin::Automation) {
return Err("Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself.".into());
}
if app.automation_is_acting() {
return Err("Only the person at the keyboard can approve installing a plug-in".into());
}
if app.pending_plugin_install.as_ref().is_some_and(|p| !p.shown.ready()) {
return Ok(json!({ "pending": INSTALL_KIND }));
}
app.ui.dialog = None;
let Some(pending) = app.pending_plugin_install.take() else { return Ok(Value::Null) };
app.session.approve_plugin_install(pending.approval);
let r = app.run("plugin.install", pending.params);
app.session.clear_plugin_install_approval();
let r = r?;
crate::io::report_installed(app, &r);
Ok(r)
}