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.
This commit is contained in:
dotCooCoo
2026-11-11 21:56:58 -08:00
parent 485d4e3fff
commit ad05c3b99d
51 changed files with 569 additions and 100 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
//! Differences from the desktop app:
//! - no TCP control server (browsers can't listen on sockets);
//! - File → Open uses the browser file picker; bytes arrive asynchronously through
//! `Services::inbox`;
//! `Services::picked_inbox`;
//! - Save / Export trigger a browser download;
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox;
//! - pen and finger input is read from Pointer Events by `pencil` (Apple Pencil pressure and
+28 -13
View File
@@ -17,7 +17,7 @@ use vectorcraft_ui_egui::i18n;
use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox};
use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer};
use vectorcraft_ui_egui::stylus::StylusFeed;
use vectorcraft_ui_egui::{Services, VectorcraftApp};
use vectorcraft_ui_egui::{PickedInbox, Services, VectorcraftApp};
use wasm_bindgen::JsCast as _;
use crate::locks::WebLocks;
@@ -25,6 +25,18 @@ use crate::pencil::SharedPointers;
pub(crate) type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// The files waiting to open, by where they came from (`Services::inbox`, `picked_inbox` and
/// `page_inbox`).
#[derive(Clone, Default)]
struct OpenInboxes {
/// Dropped on the page.
dropped: Inbox,
/// The open picker's answers.
picked: PickedInbox,
/// Handed over by the page's script (`window.vectorcraft.openFile`).
page: Inbox,
}
/// Where the pointer was over the canvas during the last file drag (CSS px from its top-left) and
/// whether Shift was held: where dropped files land.
type DragPos = Rc<Cell<Option<(f32, f32, bool)>>>;
@@ -48,7 +60,7 @@ const MAX_RESTARTS: u32 = 3;
#[derive(Clone)]
struct Host {
runner: eframe::WebRunner,
inbox: Inbox,
inbox: OpenInboxes,
place_inbox: PlaceInbox,
script_inbox: ScriptInbox,
drag: DragPos,
@@ -77,7 +89,7 @@ pub fn start() {
crate::fonts::load_startup_fonts().await;
let host = Host {
runner: eframe::WebRunner::new(),
inbox: Arc::default(),
inbox: OpenInboxes::default(),
place_inbox: Arc::default(),
script_inbox: Arc::default(),
drag: DragPos::default(),
@@ -132,13 +144,13 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
// The old services asked the old context for frames.
app.services = services;
app.status("The graphics were lost and have been restarted");
crate::api::connect(None, host.inbox.clone(), ctx.clone());
crate::api::connect(None, host.inbox.page.clone(), ctx.clone());
app
}
None => {
// `window.vectorcraft`: the page's control channel (crate::api).
let (control_tx, control_rx) = std::sync::mpsc::channel();
crate::api::connect(Some(control_tx), host.inbox.clone(), ctx.clone());
crate::api::connect(Some(control_tx), host.inbox.page.clone(), ctx.clone());
if let Err(e) = crate::api::install() {
log::error!("vectorcraft-web: couldn't install window.vectorcraft: {}", js_err(e));
}
@@ -409,7 +421,7 @@ impl eframe::App for WebShell {
for f in dropped {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
let target = app.drop_target(&name, pos, shift);
let (inbox, place_inbox, repaint) = (self.host.inbox.clone(), self.host.place_inbox.clone(), ctx.clone());
let (inbox, place_inbox, repaint) = (self.host.inbox.dropped.clone(), self.host.place_inbox.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
match f.bytes_async().await {
Ok(bytes) => {
@@ -432,14 +444,14 @@ impl eframe::App for WebShell {
}
fn services(
inbox: Inbox,
inbox: OpenInboxes,
place_inbox: PlaceInbox,
script_inbox: ScriptInbox,
ctx: egui::Context,
locks: Option<WebLocks>,
stylus: StylusFeed,
) -> Services {
let open_inbox = inbox.clone();
let open_inbox = inbox.picked.clone();
let picked = place_inbox.clone();
let place_ctx = ctx.clone();
let screen_color = eye_dropper(ctx.clone());
@@ -488,11 +500,12 @@ fn services(
let dialog = fileio::open_filters().fold(rfd::AsyncFileDialog::new(), |d, (name, exts)| {
d.add_filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts)
});
let Some(file) = dialog.pick_file().await else {
return;
// A canceled pick answers `None`.
let answer = match dialog.pick_file().await {
Some(file) => Some((file.file_name(), file.read().await)),
None => None,
};
let bytes = file.read().await;
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
inbox.lock().unwrap_or_else(|e| e.into_inner()).push(answer);
ctx.request_repaint();
});
})),
@@ -500,7 +513,9 @@ fn services(
screen_color,
// A download that couldn't be started fails the save or export it is for (#1693).
download: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes).inspect_err(|e| log::error!("download of {name} failed: {e}")))),
inbox: Some(inbox),
inbox: Some(inbox.dropped),
picked_inbox: Some(inbox.picked),
page_inbox: Some(inbox.page),
recovery_store: Some(Arc::new(BrowserStore { locks })),
// File → Print: the browser's print dialog.
print: Some(Box::new(BrowserPrint)),
+1 -1
View File
@@ -1523,7 +1523,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.installPlugin",
"Install Plug-in…",
"",
"{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK when it asks about permissions or a heavy budget: ui.dialog.confirm, answer_dialog and synthetic input are refused → plugin.install's result | {pending: \"pluginInstall\"} while it asks about permissions or a heavy budget | {pending: \"confirm\"} while it asks only about replacing",
"{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK: ui.dialog.confirm, answer_dialog and synthetic input are refused. When automation asked (a control or MCP request, synthetic input, or a file dialog one of them opened, even when the person picked the file), replace: true doesn't skip the question about replacing, and nothing in the dialog installs it, the person's OK included: the person chooses Object › Plug-ins › Install Plug-in… to install it → plugin.install's result | {pending: \"pluginInstall\"} while it asks | {pending: \"pluginInstall\", approval: \"personMustStartInstall\"} while it asks what automation asked",
),
(
"plugin.cancelRuns",
+20 -7
View File
@@ -1,4 +1,5 @@
use super::{VectorcraftApp, io, place};
use crate::services::Inbox;
impl VectorcraftApp {
/// A file dialog is open off the UI thread (a sheet on macOS, [`picks`]): what asked for it runs
@@ -13,13 +14,25 @@ impl VectorcraftApp {
self.services.dialogs_are_sheets && self.file_dialog_open()
}
pub(super) fn drain_inbox(&mut self) {
let arrived: Vec<(String, Vec<u8>)> =
self.services.inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default();
for (name, bytes) in arrived {
if let Err(e) = io::open_bytes(self, &name, &bytes, None) {
io::report_open_error(self, &name, &e);
}
pub(crate) fn drain_inbox(&mut self) {
fn take(q: Option<&Inbox>) -> Vec<(String, Vec<u8>)> {
q.map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default()
}
let answers = self.services.picked_inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner())));
// The web's file picker answers for automation when automation opened it; the page's script
// is automation.
let mut arrived: Vec<_> = take(self.services.inbox.as_ref()).into_iter().map(|f| (f, false)).collect();
for answer in answers.unwrap_or_default() {
let automated = self.picks.answer_web_open();
arrived.extend(answer.map(|f| (f, automated)));
}
arrived.extend(take(self.services.page_inbox.as_ref()).into_iter().map(|f| (f, true)));
for ((name, bytes), automated) in arrived {
crate::picks::as_automated(self, automated, |app| {
if let Err(e) = io::open_bytes(app, &name, &bytes, None) {
io::report_open_error(app, &name, &e);
}
});
}
place::drain(self);
}
+7
View File
@@ -36,6 +36,13 @@ impl VectorcraftApp {
self.ui.answering_control || self.synthetic_frame
}
/// Did automation ask for what is being handled now: [`Self::automation_is_acting`], or the
/// answer of a file dialog that automation opened ([`crate::picks::as_automated`]), even when a
/// person picked the file in it?
pub(crate) fn automation_asked(&self) -> bool {
self.automation_is_acting() || self.picks.automated()
}
/// Run a Help link command and open the URL it returns.
pub fn open_link(&mut self, id: &str) {
if let Ok(v) = self.run(id, serde_json::json!({}))
@@ -784,8 +784,7 @@ fn save_preset_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog)
/// ones → the names added
/// (none on the web, where the picked file arrives later and imports through `io::open_bytes`).
pub(crate) fn import_presets(app: &mut VectorcraftApp) -> Result<Vec<String>, String> {
if let Some(f) = app.services.open_async.as_mut() {
f();
if crate::picks::open_async(app) {
return Ok(vec![]);
}
let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Export presets", fileio::SCREEN_EXPORT_PRESET_EXTS)], ..Default::default() };
@@ -197,8 +197,7 @@ fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) ->
}
Action::Import => {
// On the web the picked file arrives later and imports through `io::open_bytes`.
if let Some(f) = app.services.open_async.as_mut() {
f();
if crate::picks::open_async(app) {
return Ok(());
}
let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Flattener presets", PRESET_EXTS)], ..Default::default() };
+1 -2
View File
@@ -173,8 +173,7 @@ fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) ->
}
Action::Import => {
// On the web the picked file arrives later and imports through `io::open_bytes`.
if let Some(f) = app.services.open_async.as_mut() {
f();
if crate::picks::open_async(app) {
return Ok(());
}
let pick = crate::FilePick {
@@ -191,8 +191,7 @@ pub(super) fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current
}
Action::Import => {
// On the web the picked file arrives later and imports through `io::open_bytes`.
if let Some(f) = app.services.open_async.as_mut() {
f();
if crate::picks::open_async(app) {
return Ok(());
}
let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Perspective grid presets", PRESET_EXTS)], ..Default::default() };
+43 -6
View File
@@ -341,6 +341,21 @@ fn import_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String>
/// 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.
@@ -350,6 +365,8 @@ pub(crate) struct PendingInstall {
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,
}
@@ -439,27 +456,44 @@ pub(super) const INSTALL_SPEC: DialogSpec = DialogSpec {
};
/// Asks a person whether to install what `pending` describes, its question not yet shown, even when
/// it reads as the one it replaces. → `{pending}`.
/// 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.
/// 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 enabled = pending.shown.drawn(ui);
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() && pending.shown.ready() {
if widgets::primary_button_enabled(ui, "OK", enabled).clicked() && person && pending.shown.ready() {
action = DialogAction::Confirm;
}
pending.shown.released(ui);
@@ -472,9 +506,12 @@ fn install_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, _: &mut Dialog) ->
}
/// OK: installs the module the dialog showed, if a person pressed it; automation is refused and the
/// dialog stays open. Before the question has been shown for [`APPROVAL_DELAY`] it installs nothing
/// and the dialog stays open. → `{pending}`.
/// 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());
}
+1 -2
View File
@@ -153,8 +153,7 @@ pub(super) fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current
}
Action::Import => {
// On the web the picked file arrives later and imports through `io::open_bytes`.
if let Some(f) = app.services.open_async.as_mut() {
f();
if crate::picks::open_async(app) {
return Ok(());
}
let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Print presets", PRESET_EXTS)], ..Default::default() };
+1
View File
@@ -5889,6 +5889,7 @@ axis Y Axis Y ос
Version: {version} Версия: {version}
Install “{name}” in place of the plug-in with the same ID? Да се инсталира ли „{name}“ на мястото на приставката със същия ID?
This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Този файл не е приставката, инсталирана преди с този ID. Той получава настройките и кеша, пазени за този ID.
Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Автоматизация поиска инсталирането на тази приставка. За да я инсталирате, изберете Обект › Приставки › Инсталиране на приставка… сами.
File SHA-256: {hash} SHA-256 на файла: {hash}
Read the objects it is given Четене на обектите, които получава
Change, add and delete objects in the document Промяна, добавяне и изтриване на обекти в документа
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5910,6 +5910,8 @@ axis Y Axis Y osa
Version: {version} Verzija: {version}
Install “{name}” in place of the plug-in with the same ID? Instalirati „{name}” umjesto dodatka s istim ID-om?
This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Ova datoteka nije dodatak ranije instaliran s ovim ID-om. Dobija postavke i predmemoriju sačuvane za taj ID.
Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Automatizacija je zatražila instalaciju ovog dodatka. Da biste ga instalirali, izaberite Objekat › Dodaci › Instaliraj dodatak… sami.
@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Automatizacija je zatražila instalaciju ovog dodatka. Da biste ga instalirali, izaberite Objekat › Dodaci › Instaliraj dodatak… sami.
@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}” je drugi modul od dodatka s ID-om {_1} i dobio bi postavke i predmemoriju sačuvane za njega: instalirajte ga s `replace: true` kada korisnik pristane
File SHA-256: {hash} SHA-256 datoteke: {hash}
Read the objects it is given Čitati objekte koje dobije
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -6122,6 +6122,8 @@ digits Malayalam Malaiàlam
Version: {version} Versió: {version}
Install “{name}” in place of the plug-in with the same ID? Instal·la «{name}» en lloc del complement amb el mateix ID?
This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Aquest fitxer no és el complement instal·lat abans amb aquest ID. Rep la configuració i la memòria cau desades per a aquest ID.
Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Una automatització ha demanat d’instal·lar aquest complement. Per instal·lar-lo, tria Objecte › Complements › Instal·la el complement… tu mateix.
@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Una automatització ha demanat d’instal·lar aquest complement. Per instal·lar-lo, tria Objecte › Complements › Instal·la el complement… tu mateix.
@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees «{name}» és un mòdul diferent del complement amb l’ID {_1} i rebria la configuració i la memòria cau desades per a aquest: instal·la’l amb `replace: true` quan l’usuari hi estigui d’acord
File SHA-256: {hash} SHA-256 del fitxer: {hash}
Read the objects it is given Llegir els objectes que rep
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -6075,6 +6075,8 @@ axis Y Axis osa Y
Version: {version} Verze: {version}
Install “{name}” in place of the plug-in with the same ID? Nainstalovat „{name}“ místo zásuvného modulu se stejným ID?
This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Tento soubor není zásuvný modul dříve nainstalovaný s tímto ID. Získá nastavení a mezipaměť uchovávané pro toto ID.
Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. O instalaci tohoto zásuvného modulu požádala automatizace. Chcete-li ho nainstalovat, zvolte Objekt › Zásuvné moduly › Nainstalovat zásuvný modul… sami.
@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. O instalaci tohoto zásuvného modulu požádala automatizace. Chcete-li ho nainstalovat, zvolte Objekt › Zásuvné moduly › Nainstalovat zásuvný modul… sami.
@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}“ je jiný modul než zásuvný modul s ID {_1} a získal by nastavení a mezipaměť uchovávané pro něj: nainstalujte ho s `replace: true`, jakmile uživatel souhlasí
File SHA-256: {hash} SHA-256 souboru: {hash}
Read the objects it is given Číst objekty, které dostane
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5904,6 +5904,7 @@ axis Y Axis Y-akse
Version: {version} Version: {version}
Install “{name}” in place of the plug-in with the same ID? Installér »{name}« i stedet for det plug-in, der har samme id?
This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Denne fil er ikke det plug-in, der før var installeret med dette id. Den får de indstillinger og den cache, der er gemt for dette id.
Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. En automatisering bad om at installere dette plug-in. Vælg selv Objekt › Plug-ins › Installér plug-in… for at installere det.
File SHA-256: {hash} Filens SHA-256: {hash}
Read the objects it is given Læse de objekter, den får
Change, add and delete objects in the document Ændre, tilføje og slette objekter i dokumentet
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5658,6 +5658,8 @@ axis Y Axis Y-Achse
Version: {version} Version: {version}
Install “{name}” in place of the plug-in with the same ID? „{name}“ anstelle des Plug-ins mit derselben ID installieren?
This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Diese Datei ist nicht das Plug-in, das vorher mit dieser ID installiert war. Sie erhält die Einstellungen und den Cache, die für diese ID gespeichert sind.
Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Eine Automatisierung hat angefordert, dieses Plug-in zu installieren. Um es zu installieren, wählen Sie selbst „Objekt › Plug-ins › Plug-in installieren…“.
@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Eine Automatisierung hat angefordert, dieses Plug-in zu installieren. Um es zu installieren, wählen Sie selbst „Objekt › Plug-ins › Plug-in installieren…“.
@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}“ ist ein anderes Modul als das Plug-in mit der ID {_1} und würde die dafür gespeicherten Einstellungen und den Cache erhalten: Installieren Sie es mit `replace: true`, sobald der Benutzer zustimmt
File SHA-256: {hash} Datei-SHA-256: {hash}
Read the objects it is given Die Objekte lesen, die es erhält
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -6026,6 +6026,8 @@ axis Y Axis Άξονας Y
Version: {version} Έκδοση: {version}
Install “{name}” in place of the plug-in with the same ID? Να εγκατασταθεί το «{name}» στη θέση του πρόσθετου με το ίδιο αναγνωριστικό;
This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Αυτό το αρχείο δεν είναι το πρόσθετο που ήταν εγκατεστημένο πριν με αυτό το αναγνωριστικό. Παίρνει τις ρυθμίσεις και την κρυφή μνήμη που διατηρούνται για αυτό το αναγνωριστικό.
Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Μια αυτοματοποίηση ζήτησε να εγκατασταθεί αυτό το πρόσθετο. Για να το εγκαταστήσετε, επιλέξτε Αντικείμενο › Πρόσθετα › Εγκατάσταση προσθέτου… μόνοι σας.
@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Μια αυτοματοποίηση ζήτησε να εγκατασταθεί αυτό το πρόσθετο. Για να το εγκαταστήσετε, επιλέξτε Αντικείμενο › Πρόσθετα › Εγκατάσταση προσθέτου… μόνοι σας.
@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees το «{name}» είναι άλλη λειτουργική μονάδα από το πρόσθετο με αναγνωριστικό {_1} και θα έπαιρνε τις ρυθμίσεις και την κρυφή μνήμη που διατηρούνται για αυτό: εγκαταστήστε το με `replace: true` μόλις συμφωνήσει ο χρήστης
File SHA-256: {hash} SHA-256 αρχείου: {hash}
Read the objects it is given Ανάγνωση των αντικειμένων που του δίνονται
Can't render this file because it is too large.
+2
View File
@@ -6017,6 +6017,8 @@ axis Y Axis Eje Y
Version: {version} Versión: {version}
Install “{name}” in place of the plug-in with the same ID? ¿Instalar “{name}” en lugar del plugin con el mismo ID?
This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Este archivo no es el plugin instalado antes con este ID. Recibe la configuración y la caché guardadas para ese ID.
Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Una automatización pidió instalar este plugin. Para instalarlo, elige Objeto › Plugins › Instalar plugin… tú mismo.
@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Una automatización pidió instalar este plugin. Para instalarlo, elige Objeto › Plugins › Instalar plugin… tú mismo.
@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” es un módulo distinto del plugin con ID {_1} y recibiría la configuración y la caché guardadas para él: instálalo con `replace: true` cuando el usuario lo acepte
File SHA-256: {hash} SHA-256 del archivo: {hash}
Read the objects it is given Leer los objetos que recibe
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -6105,6 +6105,8 @@ axis Y Axis Y-akseli
Version: {version} Versio: {version}
Install “{name}” in place of the plug-in with the same ID? Asennetaanko ”{name}” samaa tunnusta käyttävän laajennuksen tilalle?
This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Tämä tiedosto ei ole laajennus, joka oli aiemmin asennettu tällä tunnuksella. Se saa tälle tunnukselle säilytetyt asetukset ja välimuistin.
Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Automaatio pyysi tämän laajennuksen asentamista. Valitse itse Objekti › Laajennukset › Asenna laajennus…, jos haluat asentaa sen.
@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Automaatio pyysi tämän laajennuksen asentamista. Valitse itse Objekti › Laajennukset › Asenna laajennus…, jos haluat asentaa sen.
@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees ”{name}” on eri moduuli kuin laajennus, jonka tunnus on {_1}, ja se saisi sille säilytetyt asetukset ja välimuistin: asenna se asetuksella `replace: true`, kun käyttäjä hyväksyy
File SHA-256: {hash} Tiedoston SHA-256: {hash}
Read the objects it is given Lukea sille annetut objektit
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -6070,6 +6070,8 @@ axis Y Axis Axe Y
Version: {version} Version : {version}
Install “{name}” in place of the plug-in with the same ID? Installer « {name} » à la place du module externe qui a le même ID ?
This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Ce fichier n’est pas le module externe installé auparavant avec cet ID. Il reçoit les réglages et le cache conservés pour cet ID.
Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Une automatisation a demandé l’installation de ce module externe. Pour l’installer, choisissez Objet › Modules externes › Installer un module externe… vous-même.
@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Une automatisation a demandé l’installation de ce module externe. Pour l’installer, choisissez Objet › Modules externes › Installer un module externe… vous-même.
@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees « {name} » est un autre module que le module externe d’ID {_1} et recevrait les réglages et le cache conservés pour celui-ci : installez-le avec `replace: true` une fois que l’utilisateur l’accepte
File SHA-256: {hash} SHA-256 du fichier : {hash}
Read the objects it is given Lire les objets qui lui sont transmis
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5375,6 +5375,8 @@ antiAlias Sharp शार्प
Version: {version} संस्करण: {version}
Install “{name}” in place of the plug-in with the same ID? समान ID वाले प्लग-इन की जगह “{name}” इंस्टॉल करें?
This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. यह फ़ाइल इस ID के साथ पहले इंस्टॉल किया गया प्लग-इन नहीं है। इसे उस ID के लिए रखी गई सेटिंग्स और कैश मिलते हैं।
Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. एक ऑटोमेशन ने इस प्लग-इन को इंस्टॉल करने के लिए कहा। इसे इंस्टॉल करने के लिए, स्वयं ऑब्जेक्ट › प्लग-इन्स › प्लग-इन इंस्टॉल करें… चुनें।
@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. एक ऑटोमेशन ने इस प्लग-इन को इंस्टॉल करने के लिए कहा। इसे इंस्टॉल करने के लिए, स्वयं ऑब्जेक्ट › प्लग-इन्स › प्लग-इन इंस्टॉल करें… चुनें।
@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” आईडी {_1} वाले प्लग-इन से अलग मॉड्यूल है और उसके लिए रखी गई सेटिंग्स और कैश पा लेगा: उपयोगकर्ता की सहमति मिलने पर इसे `replace: true` से इंस्टॉल करें
File SHA-256: {hash} फ़ाइल SHA-256: {hash}
Read the objects it is given उसे दिए गए ऑब्जेक्ट्स पढ़ना
Can't render this file because it is too large.
+2
View File
@@ -6058,6 +6058,8 @@ axis Y Axis Y tengely
Version: {version} Verzió: {version}
Install “{name}” in place of the plug-in with the same ID? Telepíted a(z) „{name}” bővítményt az azonos azonosítójú bővítmény helyére?
This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Ez a fájl nem az a bővítmény, amely korábban ezzel az azonosítóval volt telepítve. Megkapja az ehhez az azonosítóhoz tárolt beállításokat és gyorsítótárat.
Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Egy automatizálás kérte ennek a bővítménynek a telepítését. A telepítéshez válaszd ki te magad az Objektum › Bővítmények › Bővítmény telepítése… parancsot.
@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Egy automatizálás kérte ennek a bővítménynek a telepítését. A telepítéshez válaszd ki te magad az Objektum › Bővítmények › Bővítmény telepítése… parancsot.
@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees a(z) „{name}” más modul, mint a(z) {_1} azonosítójú bővítmény, és megkapná az ahhoz tárolt beállításokat és gyorsítótárat: telepítsd a `replace: true` megadásával, miután a felhasználó beleegyezett
File SHA-256: {hash} A fájl SHA-256 értéke: {hash}
Read the objects it is given Olvashatja a kapott objektumokat
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5904,6 +5904,7 @@ axis Y Axis Sumbu Y
Version: {version} Versi: {version}
Install “{name}” in place of the plug-in with the same ID? Instal “{name}” sebagai pengganti plug-in dengan ID yang sama?
This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. File ini bukan plug-in yang sebelumnya diinstal dengan ID ini. File ini mendapatkan pengaturan dan cache yang disimpan untuk ID tersebut.
Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Otomatisasi meminta untuk menginstal plug-in ini. Untuk menginstalnya, pilih Objek › Plug-in › Instal plug-in… sendiri.
File SHA-256: {hash} SHA-256 file: {hash}
Read the objects it is given Membaca objek yang diberikan kepadanya
Change, add and delete objects in the document Mengubah, menambah, dan menghapus objek dalam dokumen
Can't render this file because it contains an unexpected character in line 2364 and column 5.
+2
View File
@@ -5692,6 +5692,8 @@ axis Y Axis Asse Y
Version: {version} Versione: {version}
Install “{name}” in place of the plug-in with the same ID? Installare “{name}” al posto del plug-in con lo stesso ID?
This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Questo file non è il plug-in installato prima con questo ID. Riceve le impostazioni e la cache conservate per quell’ID.
Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Un’automazione ha chiesto di installare questo plug-in. Per installarlo, scegli Oggetto › Plug-in › Installa plug-in… di persona.
@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Un’automazione ha chiesto di installare questo plug-in. Per installarlo, scegli Oggetto › Plug-in › Installa plug-in… di persona.
@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” è un modulo diverso dal plug-in con ID {_1} e riceverebbe le impostazioni e la cache conservate per esso: installalo con `replace: true` quando l’utente acconsente
File SHA-256: {hash} SHA-256 del file: {hash}
Read the objects it is given Leggere gli oggetti che riceve
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5757,6 +5757,8 @@ axis Y Axis Y 軸
Version: {version} バージョン: {version}
Install “{name}” in place of the plug-in with the same ID? 同じ ID のプラグインの代わりに「{name}」をインストールしますか?
This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. このファイルは、以前にこの ID でインストールされていたプラグインではありません。この ID 用に保持されている設定とキャッシュを引き継ぎます。
Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 自動操作がこのプラグインのインストールを要求しました。インストールするには、オブジェクト › プラグイン › プラグインをインストール… をご自身で選んでください。
@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 自動操作がこのプラグインのインストールを要求しました。インストールするには、オブジェクト › プラグイン › プラグインをインストール… をご自身で選んでください。
@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees 「{name}」は ID {_1} のプラグインとは別のモジュールで、そのプラグイン用に保持されている設定とキャッシュを引き継ぐことになります。ユーザーが同意したら `replace: true` を指定してインストールしてください
File SHA-256: {hash} ファイルの SHA-256: {hash}
Read the objects it is given 渡されたオブジェクトの読み取り
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -6026,6 +6026,8 @@ axis Y Axis Y ღერძი
Version: {version} ვერსია: {version}
Install “{name}” in place of the plug-in with the same ID? დავაყენოთ „{name}“ იმავე ID-ის მქონე პლაგინის ნაცვლად?
This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. ეს ფაილი არ არის ამ ID-ით ადრე დაყენებული პლაგინი. ის მიიღებს ამ ID-სთვის შენახულ პარამეტრებს და ქეშს.
Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. ავტომატიზაციამ ამ პლაგინის დაყენება მოითხოვა. მის დასაყენებლად აირჩიეთ ობიექტი › პლაგინები › პლაგინის დაყენება… თავად.
@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. ავტომატიზაციამ ამ პლაგინის დაყენება მოითხოვა. მის დასაყენებლად აირჩიეთ ობიექტი › პლაგინები › პლაგინის დაყენება… თავად.
@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}“ სხვა მოდულია, ვიდრე პლაგინი ID-ით {_1}, და მიიღებდა მისთვის შენახულ პარამეტრებს და ქეშს: მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `replace: true` პარამეტრით
File SHA-256: {hash} ფაილის SHA-256: {hash}
Read the objects it is given მიღებული ობიექტების წაკითხვა
Can't render this file because it is too large.
+2
View File
@@ -5653,6 +5653,8 @@ axis Y Axis Y축
Version: {version} 버전: {version}
Install “{name}” in place of the plug-in with the same ID? 같은 ID의 플러그인 대신 “{name}”을(를) 설치하시겠습니까?
This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. 이 파일은 이전에 이 ID로 설치된 플러그인이 아닙니다. 이 ID에 보관된 설정과 캐시를 받습니다.
Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 자동화가 이 플러그인 설치를 요청했습니다. 설치하려면 직접 개체 › 플러그인 › 플러그인 설치…를 선택하세요.
@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 자동화가 이 플러그인 설치를 요청했습니다. 설치하려면 직접 개체 › 플러그인 › 플러그인 설치…를 선택하세요.
@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}”은(는) ID {_1}의 플러그인과 다른 모듈이며 그 플러그인에 보관된 설정과 캐시를 받게 됩니다. 사용자가 동의하면 `replace: true`로 설치하세요
File SHA-256: {hash} 파일 SHA-256: {hash}
Read the objects it is given 전달받은 개체 읽기
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5955,6 +5955,7 @@ axis Y Axis Y-as
Version: {version} Versie: {version}
Install “{name}” in place of the plug-in with the same ID? „{name}” installeren in plaats van de plug-in met dezelfde ID?
This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Dit bestand is niet de plug-in die eerder met deze ID was geïnstalleerd. Het krijgt de instellingen en cache die voor die ID bewaard zijn.
Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Een automatisering heeft gevraagd deze plug-in te installeren. Kies zelf Object › Plug-ins › Plug-in installeren… om hem te installeren.
File SHA-256: {hash} SHA-256 van het bestand: {hash}
Read the objects it is given De objecten lezen die het krijgt
Change, add and delete objects in the document Objecten in het document wijzigen, toevoegen en verwijderen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5929,6 +5929,8 @@ axis Y Axis Oś Y
Version: {version} Wersja: {version}
Install “{name}” in place of the plug-in with the same ID? Zainstalować „{name}” zamiast wtyczki o tym samym ID?
This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Ten plik nie jest wtyczką zainstalowaną wcześniej z tym ID. Otrzyma ustawienia i pamięć podręczną przechowywane dla tego ID.
Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Automatyzacja poprosiła o zainstalowanie tej wtyczki. Aby ją zainstalować, wybierz Obiekt › Wtyczki › Zainstaluj wtyczkę… samodzielnie.
@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Automatyzacja poprosiła o zainstalowanie tej wtyczki. Aby ją zainstalować, wybierz Obiekt › Wtyczki › Zainstaluj wtyczkę… samodzielnie.
@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}” to inny moduł niż wtyczka o ID {_1} i otrzymałby przechowywane dla niej ustawienia i pamięć podręczną: zainstaluj go z `replace: true`, gdy użytkownik się zgodzi
File SHA-256: {hash} SHA-256 pliku: {hash}
Read the objects it is given Odczytywać obiekty, które otrzymuje
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5990,6 +5990,8 @@ axis Y Axis Eixo Y
Version: {version} Versão: {version}
Install “{name}” in place of the plug-in with the same ID? Instalar “{name}” no lugar do plug-in com o mesmo ID?
This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Este arquivo não é o plug-in instalado antes com este ID. Ele recebe as configurações e o cache guardados para esse ID.
Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Uma automação pediu para instalar este plug-in. Para instalá-lo, escolha Objeto › Plug-ins › Instalar Plug-in… você mesmo.
@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Uma automação pediu para instalar este plug-in. Para instalá-lo, escolha Objeto › Plug-ins › Instalar Plug-in… você mesmo.
@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” é um módulo diferente do plug-in com ID {_1} e receberia as configurações e o cache guardados para ele: instale-o com `replace: true` depois que o usuário concordar
File SHA-256: {hash} SHA-256 do arquivo: {hash}
Read the objects it is given Ler os objetos que recebe
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -5686,6 +5686,8 @@ axis Y Axis Ось Y
Version: {version} Версия: {version}
Install “{name}” in place of the plug-in with the same ID? Установить «{name}» вместо модуля с тем же ID?
This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Этот файл не является модулем, установленным ранее с этим ID. Он получит настройки и кэш, сохранённые для этого ID.
Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Автоматизация запросила установку этого модуля. Чтобы установить его, выберите «Объект › Модули › Установить модуль…» сами.
@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Автоматизация запросила установку этого модуля. Чтобы установить его, выберите «Объект › Модули › Установить модуль…» сами.
@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees «{name}» отличается от модуля с ID {_1} и получил бы сохранённые для него настройки и кэш: установите его с `replace: true`, когда пользователь согласится
File SHA-256: {hash} SHA-256 файла: {hash}
Read the objects it is given Читать переданные ему объекты
Can't render this file because it is too large.
+2
View File
@@ -6097,6 +6097,8 @@ axis Y Axis Y-axel
Version: {version} Version: {version}
Install “{name}” in place of the plug-in with the same ID? Installera ”{name}” i stället för det plugin som har samma id?
This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Den här filen är inte det plugin som tidigare installerades med detta id. Den får de inställningar och den cache som sparats för detta id.
Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. En automatisering bad om att installera det här pluginet. Välj själv Objekt › Plugins › Installera plugin… för att installera det.
@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. En automatisering bad om att installera det här pluginet. Välj själv Objekt › Plugins › Installera plugin… för att installera det.
@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees ”{name}” är en annan modul än pluginet med id {_1} och skulle få de inställningar och den cache som sparats för det: installera det med `replace: true` när användaren har godkänt
File SHA-256: {hash} Filens SHA-256: {hash}
Read the objects it is given Läsa objekten den får
Can't render this file because it contains an unexpected character in line 5 and column 25.
+2
View File
@@ -6031,6 +6031,8 @@ axis Y Axis Y ekseni
Version: {version} Sürüm: {version}
Install “{name}” in place of the plug-in with the same ID? Aynı kimliğe sahip eklentinin yerine “{name}” yüklensin mi?
This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Bu dosya, daha önce bu kimlikle yüklenen eklenti değil. O kimlik için saklanan ayarları ve önbelleği alır.
Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Bir otomasyon bu eklentinin yüklenmesini istedi. Yüklemek için Nesne › Eklentiler › Eklentiyi Yükle… komutunu kendiniz seçin.
@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Bir otomasyon bu eklentinin yüklenmesini istedi. Yüklemek için Nesne › Eklentiler › Eklentiyi Yükle… komutunu kendiniz seçin.
@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}”, {_1} kimlikli eklentiden farklı bir modül ve onun için saklanan ayarları ve önbelleği alacak: kullanıcı onayladıktan sonra `replace: true` ile yükleyin
File SHA-256: {hash} Dosya SHA-256: {hash}
Read the objects it is given Kendisine verilen nesneleri okuma
Can't render this file because it contains an unexpected character in line 5 and column 25.
+2
View File
@@ -5672,6 +5672,8 @@ axis Y Axis Вісь Y
Version: {version} Версія: {version}
Install “{name}” in place of the plug-in with the same ID? Встановити «{name}» замість модуля з тим самим ID?
This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Цей файл не є модулем, установленим раніше з цим ID. Він отримає налаштування й кеш, збережені для цього ID.
Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Автоматизація попросила встановити цей модуль. Щоб установити його, виберіть «Об’єкт › Модулі › Встановити модуль…» самостійно.
@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Автоматизація попросила встановити цей модуль. Щоб установити його, виберіть «Об’єкт › Модулі › Встановити модуль…» самостійно.
@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees «{name}» відрізняється від модуля з ID {_1} і отримав би збережені для нього налаштування й кеш: встановіть його з `replace: true`, коли користувач погодиться
File SHA-256: {hash} SHA-256 файлу: {hash}
Read the objects it is given Читати передані йому об’єкти
Can't render this file because it contains an unexpected character in line 182 and column 5.
+2
View File
@@ -5673,6 +5673,8 @@ axis Y Axis Trục Y
Version: {version} Phiên bản: {version}
Install “{name}” in place of the plug-in with the same ID? Cài đặt “{name}” thay cho plug-in có cùng ID?
This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Tệp này không phải là plug-in đã cài đặt trước đây với ID này. Tệp sẽ nhận cài đặt và bộ nhớ đệm được lưu cho ID đó.
Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Một tác vụ tự động đã yêu cầu cài đặt plug-in này. Để cài đặt, hãy tự mình chọn lệnh Đối tượng › Plug-in › Cài đặt plug-in… trong menu.
@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Một tác vụ tự động đã yêu cầu cài đặt plug-in này. Để cài đặt, hãy tự mình chọn lệnh Đối tượng › Plug-in › Cài đặt plug-in… trong menu.
@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” là mô-đun khác với plug-in có ID {_1} và sẽ nhận cài đặt và bộ nhớ đệm được lưu cho plug-in đó: hãy cài đặt với `replace: true` khi người dùng đồng ý
File SHA-256: {hash} SHA-256 của tệp: {hash}
Read the objects it is given Đọc các đối tượng được trao cho nó
Can't render this file because it contains an unexpected character in line 204 and column 5.
+2
View File
@@ -5913,6 +5913,8 @@ axis Y Axis Y 轴
Version: {version} 版本:{version}
Install “{name}” in place of the plug-in with the same ID? 要安装“{name}”来替换具有相同 ID 的插件吗?
This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. 此文件不是之前以此 ID 安装的插件。它将获得为该 ID 保存的设置和缓存。
Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 自动化请求安装此增效工具。要安装它,请自行选择“对象 › 增效工具 › 安装增效工具…”。
@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 自动化请求安装此增效工具。要安装它,请自行选择“对象 › 增效工具 › 安装增效工具…”。
@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}”与 ID 为 {_1} 的插件是不同的模块,并会获得为其保存的设置和缓存:请在用户同意后使用 `replace: true` 安装
File SHA-256: {hash} 文件 SHA-256:{hash}
Read the objects it is given 读取交给它的对象
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -5710,6 +5710,8 @@ axis Y Axis Y 軸
Version: {version} 版本:{version}
Install “{name}” in place of the plug-in with the same ID? 要安裝「{name}」來取代具有相同 ID 的增效模組嗎?
This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. 此檔案不是先前以此 ID 安裝的增效模組。它會取得為該 ID 保存的設定和快取。
Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 自動化要求安裝此增效模組。若要安裝,請自行選擇「物件 › 增效模組 › 安裝增效模組…」。
@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 自動化要求安裝此增效模組。若要安裝,請自行選擇「物件 › 增效模組 › 安裝增效模組…」。
@msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees 「{name}」與 ID 為 {_1} 的增效模組是不同的模組,並會取得為其保存的設定和快取:請在使用者同意後以 `replace: true` 安裝
File SHA-256: {hash} 檔案 SHA-256:{hash}
Read the objects it is given 讀取交給它的物件
Can't render this file because it contains an unexpected character in line 6 and column 47.
+18 -13
View File
@@ -215,16 +215,24 @@ fn permission_words(name: &str, commands: &[String]) -> String {
/// place of another one with its id and get its settings and cache (`replace`); OK installs it if
/// the module still has the hash the prompt showed, Cancel installs nothing → `{pending}` while it
/// asks. The `grant` and `allowHeavy` of a request are ignored: only the dialog's OK approves.
/// Every question opens the approval dialog ([`dialogs::plugin::ask_install`]). When automation
/// asked ([`VectorcraftApp::automation_asked`]), the question has no OK that installs, its
/// `replace: true` doesn't skip asking about replacing, and the answer while it asks adds
/// `approval: "personMustStartInstall"`: the person starts Install Plug-in… to install it.
/// Every text from the manifest is shown with its hidden characters escaped and its length capped
/// ([`permissions::display_text`]).
pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result<Value, String> {
let origin = if app.automation_asked() { dialogs::plugin::InstallOrigin::Automation } else { dialogs::plugin::InstallOrigin::Person };
let automated = origin == dialogs::plugin::InstallOrigin::Automation;
let given = |k: &str| module.get(k).map(|v| (k.to_string(), v.clone()));
let source = Value::Object(["path", "dataBase64", "name"].into_iter().filter_map(given).collect());
let mut install = Value::Object(["path", "dataBase64", "name", "replace", "hash"].into_iter().filter_map(given).collect());
let info = app.run("plugin.inspect", source)?;
let (needs_grant, heavy) = (info["needsApproval"] == true, info["budget"] == "heavy");
// `replace: false` asks for no replacing, so only a module without `replace` asks.
let asks_replace = module.get("replace").is_none_or(Value::is_null) && info["needsReplace"] == true;
// `replace: false` asks for no replacing, so only a module without `replace` asks, or one whose
// `replace: true` automation gave.
let replace = module.get("replace").filter(|v| !v.is_null());
let asks_replace = info["needsReplace"] == true && replace.is_none_or(|r| automated && *r == true);
if !needs_grant && !heavy && !asks_replace {
let r = app.run("plugin.install", install)?;
report_installed(app, &r);
@@ -245,13 +253,8 @@ pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result<Value,
if asks_replace {
install["replace"] = json!(true);
}
// A client may pass `replace` itself, so taking another module's place alone needs no approval
// that only a person can give.
if !needs_grant && !heavy {
let message = crate::i18n::fmt(tl!("Install “{name}” in place of the plug-in with the same ID?"), &[("name", &name)]);
dialogs::confirm::ask(app, &message, &format!("{identity}\n\n{takes_place}"), "ui.installPlugin", install);
return Ok(json!({ "pending": dialogs::confirm::KIND }));
}
// Taking another module's place alone asks in the approval dialog too, whose params no request
// can rewrite.
let asked = info["permissions"].as_array().cloned().unwrap_or_default();
let mut paragraphs = vec![];
if needs_grant {
@@ -285,8 +288,10 @@ pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result<Value,
}
let message = if needs_grant {
crate::i18n::fmt(tl!("Install “{name}”, which asks for these permissions?"), &[("name", &name)])
} else {
} else if heavy {
crate::i18n::fmt(tl!("Install “{name}”, which asks for a heavy budget?"), &[("name", &name)])
} else {
crate::i18n::fmt(tl!("Install “{name}” in place of the plug-in with the same ID?"), &[("name", &name)])
};
let approval = InstallApproval {
id: info["id"].as_str().unwrap_or_default().to_string(),
@@ -294,7 +299,8 @@ pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result<Value,
permissions: asked.iter().filter_map(Value::as_str).map(str::to_string).collect(),
heavy,
};
let pending = dialogs::plugin::PendingInstall { message, detail: paragraphs.join("\n\n"), params: install, approval, shown: Default::default() };
let pending =
dialogs::plugin::PendingInstall { message, detail: paragraphs.join("\n\n"), params: install, approval, origin, shown: Default::default() };
Ok(dialogs::plugin::ask_install(app, pending))
}
@@ -311,8 +317,7 @@ fn open_dialog_folder(app: &VectorcraftApp) -> Option<String> {
/// File → Open…
pub fn open_dialog(app: &mut VectorcraftApp) -> Result<(), String> {
if let Some(f) = app.services.open_async.as_mut() {
f();
if crate::picks::open_async(app) {
return Ok(());
}
let path =
+2 -1
View File
@@ -120,7 +120,8 @@ pub use craft_ui::docking::Monitor as DockMonitor;
#[cfg(test)]
pub(crate) use services::browsable_url;
pub use services::{
DownloadFn, ExistsFn, FileFilter, FilePick, Inbox, NativeInputHook, OpenUrlFn, PickOpen, PickSave, ReadFn, RevealFn, Services, WriteFn,
DownloadFn, ExistsFn, FileFilter, FilePick, Inbox, NativeInputHook, OpenUrlFn, PickOpen, PickSave, PickedInbox, ReadFn, RevealFn, Services,
WriteFn,
};
use state::{GeometryStamp, InputGeneration, PendingScreenshot, QueuedScreenshot};
pub(crate) use state::{InputFrame, take_frame_once};
+1 -1
View File
@@ -1524,7 +1524,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.installPlugin",
"Install Plug-in…",
"",
"{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK when it asks about permissions or a heavy budget: ui.dialog.confirm, answer_dialog and synthetic input are refused → plugin.install's result | {pending: \"pluginInstall\"} while it asks about permissions or a heavy budget | {pending: \"confirm\"} while it asks only about replacing",
"{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK: ui.dialog.confirm, answer_dialog and synthetic input are refused. When automation asked (a control or MCP request, synthetic input, or a file dialog one of them opened, even when the person picked the file), replace: true doesn't skip the question about replacing, and nothing in the dialog installs it, the person's OK included: the person chooses Object › Plug-ins › Install Plug-in… to install it → plugin.install's result | {pending: \"pluginInstall\"} while it asks | {pending: \"pluginInstall\", approval: \"personMustStartInstall\"} while it asks what automation asked",
),
(
"plugin.cancelRuns",
+1 -2
View File
@@ -827,8 +827,7 @@ pub(crate) fn import(app: &mut VectorcraftApp, p: Value) -> Result<Value, String
/// Import Library…: a picked `.vclibrary` file becomes a new library (on the web the picked file
/// arrives later through [`crate::io::open_bytes`]).
fn import_picked(app: &mut VectorcraftApp) {
if let Some(f) = app.services.open_async.as_mut() {
f();
if crate::picks::open_async(app) {
return;
}
let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Libraries", LIBRARY_EXTS)], ..Default::default() };
+1 -4
View File
@@ -584,10 +584,7 @@ pub(crate) fn pick_library_file(
path: Option<String>,
filters: Vec<(&'static str, &'static [&'static str])>,
) -> Result<Option<String>, String> {
if path.is_none()
&& let Some(f) = app.services.open_async.as_mut()
{
f();
if path.is_none() && crate::picks::open_async(app) {
return Ok(None);
}
path.or_else(|| {
+54 -4
View File
@@ -105,6 +105,9 @@ struct Waiting {
/// The automation roots in force when it was asked for: the entry runs again under them, not
/// with the desktop's access of the frame the answer comes in.
scope: ScopeHandle,
/// Automation asked for it ([`VectorcraftApp::automation_asked`]): the entry runs again as
/// automation's ([`as_automated`]).
automated: bool,
}
/// The app's file dialogs ([`VectorcraftApp`]'s `picks`).
@@ -122,6 +125,13 @@ pub(crate) struct Picks {
replacing: Option<Replacing>,
/// The file that question's OK allows to be replaced while what asked runs again.
replace_ok: Option<String>,
/// What runs now handles the answer of a file dialog automation opened ([`as_automated`]).
automated: bool,
/// The web's file pickers opened ([`open_async`]) that have not answered yet
/// (`Services::picked_inbox`).
web_opens: u32,
/// Automation opened one of them: their answers are automation's.
web_open_automated: bool,
}
/// A save dialog's answer that names, once the app gave it an extension, a file that is there.
@@ -152,6 +162,23 @@ impl Picks {
self.entry = None;
self.answer = None;
self.replace_ok = None;
self.automated = false;
}
/// What runs now handles the answer of a file dialog automation opened ([`as_automated`]).
pub(crate) fn automated(&self) -> bool {
self.automated
}
/// One of the web's file pickers answered (a file, or canceled): whether the answer is
/// automation's. Once none is waiting, the next one the person opens answers for the person.
pub(crate) fn answer_web_open(&mut self) -> bool {
let automated = self.web_open_automated;
self.web_opens = self.web_opens.saturating_sub(1);
if self.web_opens == 0 {
self.web_open_automated = false;
}
automated
}
/// The kind of dialog open off the UI thread (`ui.inspect`'s `fileDialog`).
@@ -188,6 +215,29 @@ pub(crate) fn as_entry<R>(app: &mut VectorcraftApp, entry: impl FnOnce() -> Entr
r
}
/// Run `f` as handling the answer of a file dialog automation opened when `automated` (and inside
/// one already, as that): [`VectorcraftApp::automation_asked`] is true meanwhile.
pub(crate) fn as_automated<R>(app: &mut VectorcraftApp, automated: bool, f: impl FnOnce(&mut VectorcraftApp) -> R) -> R {
let outer = app.picks.automated;
app.picks.automated = outer || automated;
let r = f(app);
app.picks.automated = outer;
r
}
/// Opens the web's file picker, whose file arrives later through `Services::picked_inbox`, noting
/// whether automation asked for it ([`VectorcraftApp::automation_asked`]) → false when the host has
/// none. While a picker automation opened has not answered, every answer is automation's, also one
/// to a picker the person opened.
pub(crate) fn open_async(app: &mut VectorcraftApp) -> bool {
let automated = app.automation_asked();
let Some(f) = app.services.open_async.as_mut() else { return false };
f();
app.picks.web_opens += 1;
app.picks.web_open_automated |= automated;
true
}
/// [`as_entry`] for a panel's or a dialog's button that `f` handles.
pub(crate) fn button(app: &mut VectorcraftApp, f: impl Fn(&mut VectorcraftApp) + 'static) {
let f = Rc::new(f);
@@ -319,8 +369,8 @@ fn ask(app: &mut VectorcraftApp, request: PickRequest) -> Option<PickAnswer> {
match (app.picks.entry.clone(), app.services.start_pick.as_mut()) {
(Some(entry), Some(start)) => {
if let Some(answer) = start(request.clone()) {
let doc = entry.document(app);
app.picks.waiting = Some(Waiting { request, answer, entry, doc, scope: ScopeHandle::capture() });
let (doc, automated) = (entry.document(app), app.automation_asked());
app.picks.waiting = Some(Waiting { request, answer, entry, doc, scope: ScopeHandle::capture(), automated });
return None;
}
}
@@ -367,7 +417,7 @@ pub(crate) fn poll(app: &mut VectorcraftApp, ctx: &egui::Context) {
// The dialog's thread is gone: as cancelled.
Err(TryRecvError::Disconnected) => vec![],
};
let Some(Waiting { request, entry, doc, scope, .. }) = app.picks.waiting.take() else { return };
let Some(Waiting { request, entry, doc, scope, automated, .. }) = app.picks.waiting.take() else { return };
app.picks.refused = false;
// Once more: what waited for the dialog (files from the Finder, the menu bar) goes on.
ctx.request_repaint();
@@ -379,7 +429,7 @@ pub(crate) fn poll(app: &mut VectorcraftApp, ctx: &egui::Context) {
return;
}
app.picks.answer = Some((request, answer));
rerun(app, entry);
as_automated(app, automated, |app| rerun(app, entry));
// An entry that took another way this time leaves the answer unused.
app.picks.answer = None;
});
+11 -2
View File
@@ -47,6 +47,9 @@ pub type ExistsFn = Box<dyn Fn(&str) -> bool>;
pub type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// The web open picker's answers in order: the picked file, or `None` when it was canceled.
pub type PickedInbox = Arc<Mutex<Vec<Option<(String, Vec<u8>)>>>>;
/// Starts a browser download of the bytes under a file name; an error when it couldn't be started
/// (the save or export then fails as a refused write does: #1693).
pub type DownloadFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
@@ -89,11 +92,17 @@ pub struct Services {
/// save panel's name names once the app gives it an extension ([`picks::ask_to_replace`]).
/// Without it, no file counts as there.
pub exists: Option<ExistsFn>,
/// Files that arrived asynchronously (web open / drops): (name, bytes).
/// Files that arrived asynchronously (web drops): (name, bytes).
pub inbox: Option<Inbox>,
/// Web: the open picker's answers (`open_async`). A file opens as automation's when automation
/// opened a picker that has not answered yet.
pub picked_inbox: Option<PickedInbox>,
/// Web: files the page's script handed over (`window.vectorcraft.openFile`). They open as
/// automation's.
pub page_inbox: Option<Inbox>,
/// Web: trigger a browser download instead of writing a path.
pub download: Option<DownloadFn>,
/// Web: start an async open (bytes arrive via `inbox`).
/// Web: start an async open (bytes arrive via `picked_inbox`).
pub open_async: Option<Box<dyn FnMut()>>,
/// Read the system clipboard's text (desktop). Without it, pasted text only arrives with
/// egui's Paste event (web, and keyboard paste everywhere).
+291 -17
View File
@@ -630,9 +630,11 @@ fn an_unidentifiable_stale_injected_batch_fails_closed() {
let _ = std::fs::remove_dir_all(base);
}
/// A plug-in's permissions are approved only by a person. A control request (or MCP, which comes
/// through it) can't pass `grant`, can't answer the approval dialog or rewrite what it installs,
/// and a frame of synthetic input can't press its OK; the person at the keyboard can.
/// A plug-in's permissions are approved only by a person who asked for the install. A control
/// request (or MCP, which comes through it) can't pass `grant`, can't answer the approval dialog or
/// rewrite what it installs, and a frame of synthetic input can't press its OK. The person can't
/// approve the question automation asked either: they start Install Plug-in… themselves, which asks
/// again, and its OK installs.
#[test]
fn automation_cannot_approve_a_plugin_install() {
let (tx, rx) = std::sync::mpsc::channel();
@@ -650,8 +652,9 @@ fn automation_cannot_approve_a_plugin_install() {
let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {"dataBase64": data}}));
assert_eq!(r["result"]["pending"], crate::dialogs::plugin::INSTALL_KIND, "{r}");
assert_eq!(r["result"]["approval"], crate::dialogs::plugin::PERSON_MUST_START_INSTALL, "{r}");
let r = request(&mut app, &tx, "ui.dialog.confirm", json!({}));
refused(&r, "Only the person at the keyboard");
refused(&r, ASKED);
assert!(app.ui.dialog.is_some(), "the refused answer leaves it open");
assert!(vectorcraft_plugins::registry::get(id).is_none());
@@ -662,22 +665,293 @@ fn automation_cannot_approve_a_plugin_install() {
let r = request(&mut app, &tx, "ui.dialog.set", json!({"fields": [{"field": "__params", "value": {"dataBase64": "AAAA"}}]}));
assert!(r.get("error").is_none(), "{r}");
let r = request(&mut app, &tx, "ui.dialog.confirm", json!({}));
refused(&r, "Only the person at the keyboard");
refused(&r, ASKED);
assert!(app.ui.dialog.is_some() && vectorcraft_plugins::registry::get(id).is_none());
// Shown for long enough for a person's OK, it still refuses automation.
// Shown for long enough for a person's OK, it still refuses automation, and the person too.
super::plugins::show_approval(&mut app);
let r = request(&mut app, &tx, "ui.dialog.confirm", json!({}));
refused(&r, ASKED);
app.synthetic_frame = true;
assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED));
assert!(vectorcraft_plugins::registry::get(id).is_none());
app.synthetic_frame = false;
assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED), "a person's OK on automation's question");
assert!(app.ui.dialog.is_some() && vectorcraft_plugins::registry::get(id).is_none() && vectorcraft_plugins::registry::granted(id).is_none());
// The person starts Install Plug-in… with the file: a new question, which a person's OK approves
// once it was shown for the delay, and which automation still can't.
let r = app.run("ui.installPlugin", json!({"dataBase64": data})).unwrap();
assert_eq!(r, json!({"pending": crate::dialogs::plugin::INSTALL_KIND}), "the person's question");
assert_eq!(crate::dialogs::confirm(&mut app).unwrap()["pending"], crate::dialogs::plugin::INSTALL_KIND, "not shown yet");
super::plugins::show_approval(&mut app);
let r = request(&mut app, &tx, "ui.dialog.confirm", json!({}));
refused(&r, "Only the person at the keyboard");
app.synthetic_frame = true;
assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains("Only the person at the keyboard"));
assert!(vectorcraft_plugins::registry::get(id).is_none());
app.synthetic_frame = false;
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(vectorcraft_plugins::permissions::sha256_hex(&bytes)));
app.run("plugin.remove", json!({"id": id})).unwrap();
}
/// The refusal of an OK on an install question automation asked.
const ASKED: &str = "Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself.";
/// The approval module `id` that lists `doc.read`, and its bytes as base64.
fn approval_module(id: &str) -> (Vec<u8>, String) {
let manifest = format!(r#"{{"id":"{id}","name":"Approval","version":"1","kind":"filter","permissions":["doc.read"]}}"#);
let bytes = wat::parse_str(vectorcraft_plugins::wat::returning(&manifest, r#"{"objects":[]}"#)).unwrap();
let data = vectorcraft_format::base64_encode(&bytes);
(bytes, data)
}
/// An install question that a control request asked keeps OK disabled in the person's window, even
/// once it was shown for the delay: a person's click, Enter or Space install nothing, and the dialog
/// says to start Install Plug-in…. Cancel still closes it at once.
#[test]
fn a_person_cannot_approve_an_install_question_automation_asked() {
use super::plugins::{at, click_ok, ok_enabled, waiting, window};
use egui_kittest::kittest::Queryable;
let id = "test.ui.approval.asked";
let (tx, rx) = std::sync::mpsc::channel();
let app = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx);
let mut h = window(app);
let (_, data) = approval_module(id);
let r = request(h.state_mut(), &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {"dataBase64": data}}));
assert_eq!(r["result"]["approval"], crate::dialogs::plugin::PERSON_MUST_START_INSTALL, "{r}");
at(&mut h, 10.0);
at(&mut h, 10.0);
at(&mut h, 10.6);
assert!(!ok_enabled(&h), "OK stays disabled past the delay");
assert!(h.query_by_label(ASKED).is_some(), "the dialog says what to do");
click_ok(&mut h, 10.7);
at(&mut h, 10.8);
assert!(waiting(h.state(), id), "a person's click");
for (key, time) in [(egui::Key::Enter, 10.9), (egui::Key::Space, 11.0)] {
h.key_press(key);
at(&mut h, time);
assert!(waiting(h.state(), id), "{key:?}");
}
assert!(crate::dialogs::confirm(h.state_mut()).unwrap_err().contains(ASKED), "a confirm that skips the button");
assert!(waiting(h.state(), id));
h.get_by_label("Cancel").click();
at(&mut h, 11.1);
assert!(h.state().ui.dialog.is_none() && h.state().pending_plugin_install.is_none(), "Cancel");
assert!(vectorcraft_plugins::registry::get(id).is_none() && vectorcraft_plugins::registry::granted(id).is_none());
}
/// A question automation asks in place of the one a person asked can't be approved: it keeps
/// automation's origin, though the person's question was shown and ready.
#[test]
fn automation_replacing_a_persons_install_question_does_not_take_its_approval() {
let (tx, rx) = std::sync::mpsc::channel();
let mut app = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx);
let (mine, theirs) = ("test.ui.approval.mine", "test.ui.approval.theirs");
app.run("ui.installPlugin", json!({"dataBase64": approval_module(mine).1})).unwrap();
super::plugins::show_approval(&mut app);
let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {"dataBase64": approval_module(theirs).1}}));
assert_eq!(r["result"]["approval"], crate::dialogs::plugin::PERSON_MUST_START_INSTALL, "{r}");
super::plugins::show_approval(&mut app);
assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED));
for id in [mine, theirs] {
assert!(vectorcraft_plugins::registry::get(id).is_none() && vectorcraft_plugins::registry::granted(id).is_none(), "{id}");
}
// Asked again by automation with the same file, it is still automation's.
let r = request(&mut app, &tx, "engine.execute", json!({"command": "ui.installPlugin", "params": {"dataBase64": approval_module(mine).1}}));
assert_eq!(r["result"]["approval"], crate::dialogs::plugin::PERSON_MUST_START_INSTALL, "{r}");
super::plugins::show_approval(&mut app);
assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED));
assert!(vectorcraft_plugins::registry::get(mine).is_none());
}
/// A file a person picks in the file dialog that a control request opened (Install Plug-in… or
/// File › Open with no path) is automation's request: its question can't be approved. The file
/// dialog a person opens asks a question they can approve. On the desktop the dialog answers later
/// ([`crate::picks`]); on the web the file arrives through the picker's inbox, apart from drops and
/// the page's script, which is automation.
#[test]
fn a_file_picked_in_a_dialog_automation_opened_stays_automations() {
let base = std::env::temp_dir().join(format!("vc-ui-approval-picked-{}", std::process::id()));
std::fs::create_dir_all(&base).unwrap();
let (desktop, web) = ("test.ui.approval.picked", "test.ui.approval.picked.web");
let (bytes, _) = approval_module(desktop);
let wasm = base.join("picked.wasm");
std::fs::write(&wasm, &bytes).unwrap();
let mut services = Services::default();
let (shown, _) = super::picks::off_the_ui_thread(&mut services);
services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string())));
let (tx, rx) = std::sync::mpsc::channel();
let mut app = VectorcraftApp::new(Session::new(), services).with_control(rx);
let pick = |app: &mut VectorcraftApp| {
shown.answer(&[&s(wasm.clone())]);
crate::picks::poll(app, &egui::Context::default());
};
for command in ["ui.installPlugin", "file.open"] {
let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": command, "params": {}}));
assert!(app.file_dialog_open(), "{command}: {r}");
pick(&mut app);
assert!(super::plugins::waiting(&app, desktop), "{command}: {:?}", app.ui.status);
super::plugins::show_approval(&mut app);
assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED), "{command}");
crate::dialogs::cancel(&mut app);
}
let _ = app.run("ui.installPlugin", json!({}));
assert!(app.file_dialog_open());
pick(&mut app);
super::plugins::show_approval(&mut app);
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(vectorcraft_plugins::registry::granted(desktop).map(|g| g.hash), Some(vectorcraft_plugins::permissions::sha256_hex(&bytes)));
app.run("plugin.remove", json!({"id": desktop})).unwrap();
// The web: the browser's file picker answers through its own inbox, apart from the files a
// person drops and the ones the page's script hands over.
let (dropped, page) = <(crate::services::Inbox, crate::services::Inbox)>::default();
let picked = crate::services::PickedInbox::default();
let services = Services {
open_async: Some(Box::new(|| {})),
inbox: Some(dropped.clone()),
picked_inbox: Some(picked.clone()),
page_inbox: Some(page.clone()),
..Default::default()
};
let (tx, rx) = std::sync::mpsc::channel();
let mut app = VectorcraftApp::new(Session::new(), services).with_control(rx);
let (by_drop, by_page) = ("test.ui.approval.picked.dropped", "test.ui.approval.picked.page");
let file = |id: &str| (format!("{id}.wasm"), approval_module(id).0);
let hand_over = |app: &mut VectorcraftApp, inbox: &crate::services::Inbox, id: &str| {
inbox.lock().unwrap().push(file(id));
app.drain_inbox();
};
// The picker answers with a file, or `None` when it is canceled.
let answer = |app: &mut VectorcraftApp, id: Option<&str>| {
picked.lock().unwrap().push(id.map(file));
app.drain_inbox();
};
let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {}}));
assert!(r.get("error").is_none(), "{r}");
// A file the person drops before the picker answers stays the person's.
hand_over(&mut app, &dropped, by_drop);
super::plugins::show_approval(&mut app);
crate::dialogs::confirm(&mut app).unwrap();
assert!(vectorcraft_plugins::registry::granted(by_drop).is_some());
answer(&mut app, Some(web));
super::plugins::show_approval(&mut app);
assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED), "the web");
crate::dialogs::cancel(&mut app);
app.run("ui.installPlugin", json!({})).unwrap();
answer(&mut app, Some(web));
super::plugins::show_approval(&mut app);
crate::dialogs::confirm(&mut app).unwrap();
assert!(vectorcraft_plugins::registry::granted(web).is_some());
// Opening the picker again as the person before it answers automation leaves the answer
// automation's.
let (again, after) = ("test.ui.approval.picked.again", "test.ui.approval.picked.after");
let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {}}));
assert!(r.get("error").is_none(), "{r}");
app.run("ui.installPlugin", json!({})).unwrap();
answer(&mut app, Some(again));
super::plugins::show_approval(&mut app);
assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED), "opened again");
crate::dialogs::cancel(&mut app);
// Once the other picker is canceled, the next one the person opens answers for them again.
answer(&mut app, None);
app.run("ui.installPlugin", json!({})).unwrap();
answer(&mut app, Some(after));
super::plugins::show_approval(&mut app);
crate::dialogs::confirm(&mut app).unwrap();
assert!(vectorcraft_plugins::registry::granted(after).is_some());
// The page's script is automation.
hand_over(&mut app, &page, by_page);
super::plugins::show_approval(&mut app);
assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED), "the page's script");
crate::dialogs::cancel(&mut app);
assert!(vectorcraft_plugins::registry::granted(by_page).is_none());
for id in [web, by_drop, after] {
app.run("plugin.remove", json!({"id": id})).unwrap();
}
let _ = std::fs::remove_dir_all(base);
}
/// A `.wasm` that a control request opens by path (`app.open`) asks a question that can't be
/// approved; one the person opens can be.
#[test]
fn a_plugin_automation_opened_by_path_asks_a_question_no_one_approves() {
let base = std::env::temp_dir().join(format!("vc-ui-approval-open-{}", std::process::id()));
std::fs::create_dir_all(&base).unwrap();
let id = "test.ui.approval.opened";
let (bytes, _) = approval_module(id);
let wasm = base.join("opened.wasm");
std::fs::write(&wasm, &bytes).unwrap();
let services = Services { read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), ..Default::default() };
let (tx, rx) = std::sync::mpsc::channel();
let mut app = VectorcraftApp::new(Session::new(), services).with_control(rx);
let r = request(&mut app, &tx, "app.open", json!({"path": s(wasm.clone())}));
assert!(r.get("error").is_none(), "{r}");
assert!(super::plugins::waiting(&app, id));
super::plugins::show_approval(&mut app);
assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED));
crate::dialogs::cancel(&mut app);
crate::io::open_path(&mut app, &s(wasm.clone())).unwrap();
super::plugins::show_approval(&mut app);
crate::dialogs::confirm(&mut app).unwrap();
assert!(vectorcraft_plugins::registry::granted(id).is_some());
app.run("plugin.remove", json!({"id": id})).unwrap();
let _ = std::fs::remove_dir_all(base);
}
/// A module that takes the place of another with its id, and asks for nothing else: automation's
/// `replace: true` doesn't skip the question and its question can't be approved. The question a
/// person asked keeps what it installs out of reach of `ui.dialog.set`, refuses a control confirm,
/// and the person's own OK replaces.
#[test]
fn automation_cannot_approve_replacing_a_plugin() {
let (tx, rx) = std::sync::mpsc::channel();
let mut app = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx);
let id = "test.ui.approval.replaced";
let plain = |output: &str| {
wat::parse_str(vectorcraft_plugins::wat::returning(&format!(r#"{{"id":"{id}","name":"Plain","kind":"filter"}}"#), output)).unwrap()
};
let (first, other) = (plain(r#"{"objects":[]}"#), plain(r#"{"objects":[],"error":null}"#));
let installed = || vectorcraft_plugins::registry::get(id).map(|p| p.hash().to_string());
let first_hash = vectorcraft_plugins::permissions::sha256_hex(&first);
let data = vectorcraft_format::base64_encode(&other);
app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&first)})).unwrap();
assert_eq!(installed(), Some(first_hash.clone()));
for params in [json!({"dataBase64": data}), json!({"dataBase64": data, "replace": true})] {
let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": params}));
assert_eq!(r["result"]["pending"], crate::dialogs::plugin::INSTALL_KIND, "{params}: {r}");
assert_eq!(r["result"]["approval"], crate::dialogs::plugin::PERSON_MUST_START_INSTALL, "{params}: {r}");
let pending = app.pending_plugin_install.as_ref().unwrap();
assert_eq!(pending.message, "Install “Plain” in place of the plug-in with the same ID?");
super::plugins::show_approval(&mut app);
assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED), "{params}");
let r = request(&mut app, &tx, "ui.dialog.confirm", json!({}));
assert!(r["error"].as_str().is_some_and(|e| e.contains(ASKED)), "{r}");
crate::dialogs::cancel(&mut app);
assert_eq!(installed(), Some(first_hash.clone()), "{params}");
}
// The person's question: a control request can't point its OK at other bytes, nor press it.
let r = app.run("ui.installPlugin", json!({"dataBase64": data})).unwrap();
assert_eq!(r, json!({"pending": crate::dialogs::plugin::INSTALL_KIND}));
let evil = vectorcraft_format::base64_encode(&plain(r#"{"objects":[{"name":"x"}]}"#));
let r = request(&mut app, &tx, "ui.dialog.set", json!({"fields": [{"field": "__params", "value": {"dataBase64": evil, "replace": true}}]}));
assert!(r.get("error").is_none(), "{r}");
super::plugins::show_approval(&mut app);
let r = request(&mut app, &tx, "ui.dialog.confirm", json!({}));
assert!(r["error"].as_str().is_some_and(|e| e.contains("Only the person at the keyboard")), "{r}");
assert_eq!(installed(), Some(first_hash.clone()));
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(installed(), Some(vectorcraft_plugins::permissions::sha256_hex(&other)), "the bytes the question showed");
app.run("plugin.remove", json!({"id": id})).unwrap();
// A module that asks for nothing still installs at once when automation asks.
let free = "test.ui.approval.free";
let module = wat::parse_str(vectorcraft_plugins::wat::returning(&format!(r#"{{"id":"{free}","name":"Free","kind":"filter"}}"#), "{}")).unwrap();
let params = json!({"dataBase64": vectorcraft_format::base64_encode(&module)});
let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": params}));
assert!(r["result"]["pending"].is_null() && app.ui.dialog.is_none(), "{r}");
assert!(vectorcraft_plugins::registry::get(free).is_some());
app.run("plugin.remove", json!({"id": free})).unwrap();
}
type DialogHarness = egui_kittest::Harness<'static, VectorcraftApp>;
/// Runs one UI pass of `h` for each batch of synthetic input the control path queued, each taken
@@ -702,10 +976,10 @@ fn still_waiting(h: &DialogHarness, id: &str) -> bool {
&& vectorcraft_plugins::registry::granted(id).is_none()
}
/// Keys and pointer input sent through the control path approve nothing at the open install
/// approval: Enter and Space press nothing with no button focused, and with OK focused they and a
/// `ui.click` on it press it and are refused. `ui.pointer` drives the canvas. A person's click on
/// the same button approves.
/// Keys and pointer input sent through the control path approve nothing at the install approval a
/// person asked for: Enter and Space press nothing with no button focused, and with OK focused they
/// and a `ui.click` on it press it and are refused. `ui.pointer` drives the canvas. A person's click
/// on the same button approves.
#[test]
fn injected_keys_and_pointer_input_cannot_approve_a_plugin_install() {
use egui_kittest::kittest::{NodeT, Queryable};
@@ -717,8 +991,8 @@ fn injected_keys_and_pointer_input_cannot_approve_a_plugin_install() {
let manifest = format!(r#"{{"id":"{id}","name":"Approval","version":"1","kind":"filter","permissions":["doc.read"]}}"#);
let bytes = wat::parse_str(vectorcraft_plugins::wat::returning(&manifest, r#"{"objects":[]}"#)).unwrap();
let data = vectorcraft_format::base64_encode(&bytes);
let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {"dataBase64": data}}));
assert_eq!(r["result"]["pending"], crate::dialogs::plugin::INSTALL_KIND, "{r}");
let r = app.run("ui.installPlugin", json!({"dataBase64": data})).unwrap();
assert_eq!(r, json!({"pending": crate::dialogs::plugin::INSTALL_KIND}), "the person's question");
let mut fonts = false;
let mut h: DialogHarness = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 800.0)).build_ui_state(
move |ui, app: &mut VectorcraftApp| {
+16 -14
View File
@@ -45,11 +45,11 @@ pub(super) fn show_approval(app: &mut VectorcraftApp) {
panic!("the approval's OK never took input");
}
type Window = egui_kittest::Harness<'static, VectorcraftApp>;
pub(super) type Window = egui_kittest::Harness<'static, VectorcraftApp>;
/// The dialog layer of `app` in a window, each frame drawn at the time [`at`] gives it. Building it
/// draws frames at times of its own, so a test opens the dialog after.
fn window(app: VectorcraftApp) -> Window {
pub(super) fn window(app: VectorcraftApp) -> Window {
let mut fonts = false;
egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 800.0)).build_ui_state(
move |ui, app: &mut VectorcraftApp| {
@@ -64,26 +64,26 @@ fn window(app: VectorcraftApp) -> Window {
}
/// Draws a frame of `h` at `time` seconds.
fn at(h: &mut Window, time: f64) {
pub(super) fn at(h: &mut Window, time: f64) {
h.input_mut().time = Some(time);
h.step();
}
/// Whether the approval's OK is enabled, as a screen reader reads it.
fn ok_enabled(h: &Window) -> bool {
pub(super) fn ok_enabled(h: &Window) -> bool {
use egui_kittest::kittest::{NodeT, Queryable};
!h.get_by_label("OK").accesskit_node().is_disabled()
}
/// Clicks the approval's OK as a person does, on a frame at `time` seconds.
fn click_ok(h: &mut Window, time: f64) {
pub(super) fn click_ok(h: &mut Window, time: f64) {
use egui_kittest::kittest::Queryable;
h.get_by_label("OK").click();
at(h, time);
}
/// The approval is open and plug-in `id` neither installed nor granted.
fn waiting(app: &VectorcraftApp, id: &str) -> bool {
pub(super) fn waiting(app: &VectorcraftApp, id: &str) -> bool {
app.ui.dialog.as_ref().is_some_and(|d| d.kind == dialogs::plugin::INSTALL_KIND)
&& vectorcraft_plugins::registry::get(id).is_none()
&& vectorcraft_plugins::registry::granted(id).is_none()
@@ -184,7 +184,7 @@ fn plugin_effects_open_the_effect_dialog_with_their_schema() {
/// A filter `id` named `name` whose manifest lists `permissions` (a JSON list), at a version with
/// a bidirectional override, a tab and a bell in it.
fn asking(id: &str, name: &str, permissions: &str) -> Vec<u8> {
pub(super) fn asking(id: &str, name: &str, permissions: &str) -> Vec<u8> {
let manifest = json!({"id": id, "name": name, "version": "3.0\u{202E}\t\u{0007}", "kind": "filter"}).to_string();
let manifest = format!(r#"{},"permissions":{permissions}}}"#, manifest.trim_end_matches('}'));
wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap()
@@ -522,9 +522,8 @@ fn the_install_approval_waits_again_when_the_focus_leaves_and_comes_back_between
h.state_mut().run("plugin.remove", json!({"id": id})).unwrap();
}
/// A module with other bytes than the one installed with its id asks before taking its place: in
/// the ordinary confirm when it needs nothing that only a person can approve, and in the approval
/// dialog, with its id and file hash, when it also asks for a heavy budget.
/// A module with other bytes than the one installed with its id asks before taking its place, in
/// the approval dialog, with its id and file hash, also when it asks for a heavy budget.
#[test]
fn installing_a_plugin_over_another_module_asks_first() {
let mut app = app();
@@ -538,10 +537,13 @@ fn installing_a_plugin_over_another_module_asks_first() {
let other = plain(r#"{"objects":[],"error":null}"#);
let other_hash = vectorcraft_plugins::permissions::sha256_hex(&other);
let r = app.run("ui.installPlugin", module(&other)).unwrap();
assert_eq!(r["pending"], dialogs::confirm::KIND);
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!(d.str("message"), "Install “Plain” in place of the plug-in with the same ID?");
assert_eq!(d.str("detail"), format!("ID: {id}\nFile SHA-256: {other_hash}\n\n{takes_place}"));
assert_eq!(r, json!({"pending": dialogs::plugin::INSTALL_KIND}));
let pending = app.pending_plugin_install.as_ref().unwrap();
assert_eq!(pending.message, "Install “Plain” in place of the plug-in with the same ID?");
assert_eq!(pending.detail, format!("ID: {id}\nFile SHA-256: {other_hash}\n\n{takes_place}"));
assert_eq!(pending.params["replace"], json!(true), "OK replaces the one installed");
assert_eq!(dialogs::confirm(&mut app).unwrap()["pending"], dialogs::plugin::INSTALL_KIND, "it waits like any other question");
show_approval(&mut app);
dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.hash().to_string()), Some(other_hash));
+4 -2
View File
@@ -217,7 +217,9 @@ export's own parameters); `ui.dialog.confirm` writes the file. An importer plug-
`pluginImport` dialog when `file.openWith` is given no `params`: one field per parameter, `__plugin`, and `__open`
(`path` and `importer`); `ui.dialog.confirm` opens the file. `ui.installPlugin {path?}` installs a `.wasm` (without
a path it asks for one); one that needs approval opens the `pluginInstall` dialog, which only a person can confirm
(`ui.dialog.confirm` and injected input are refused); see [plugins.md](plugins.md).
(`ui.dialog.confirm` and injected input are refused). When a control request opened it, directly or through a file
dialog, the reply adds `approval: "personMustStartInstall"` and nobody can confirm it: the person chooses Object ›
Plug-ins › Install Plug-in… to install the plug-in; see [plugins.md](plugins.md).
Overprint Black: the Edit → Edit Colors → Overprint Black… menu item opens a `command` parameter dialog for
`edit.colors.overprintBlack` (fields `remove`, `percentage`, `fill`, `stroke`, `includeCmyBlacks`,
@@ -1059,7 +1061,7 @@ app:
| `vectorcraft.docInspect(params)` / `vectorcraft.uiInspect(params)` | `document.inspect` / `ui.inspect` |
| `vectorcraft.inspect()` | `ui.inspect` (the name before `uiInspect`) |
| `vectorcraft.screenshot(params)` | `ui.screenshot` (no `path`: the PNG comes back inline, as `data` and as `pngBase64`) |
| `vectorcraft.openFile(name, bytes)` | opens the bytes the way a dropped file opens (returns `true` once queued) |
| `vectorcraft.openFile(name, bytes)` | opens the bytes the way a dropped file opens, as automation: a plug-in install it asks for has to be started by the person (returns `true` once queued) |
| `vectorcraft.captureDownloads(on)` | while on, a save or export is kept in the page instead of downloaded |
| `vectorcraft.files()` / `vectorcraft.readFile(name)` | the kept files (`{path, size}`) / one of them as a `Uint8Array` |
+4 -1
View File
@@ -8009,7 +8009,10 @@ needs a person's approval too, and `plugin.install` refuses `grant: [permission]
app connected, `ui.installPlugin` opens the approval dialog and returns `{pending: "pluginInstall"}`; `answer_dialog`,
`ui.dialog.confirm` and injected input can't confirm it. Its OK takes input only once the question has been on screen
in the focused window for half a second, counted again for every new question and after the window gets the focus
back. A `vectorcraft-cli mcp` started with `--allow-plugin-grants`
back. A question that a request asked, directly or through a file dialog it opened, can't be approved by the person
either: the reply adds `approval: "personMustStartInstall"`, OK stays disabled, and the person installs the plug-in
with Object › Plug-ins › Install Plug-in… themselves. A request's `replace: true` doesn't skip the question about
taking another module's place. A `vectorcraft-cli mcp` started with `--allow-plugin-grants`
accepts `grant` and `allowHeavy` from any request, in that session only (headless; it can't be combined with
`--connect`, `--automation-read-root` or `--automation-write-root`). A server confined to folders has no plug-in
store, so it reads no `grants.json`, and a module that lists permissions or asks for a heavy budget does not install
+10 -1
View File
@@ -217,7 +217,16 @@ capability model proposed for PhotoCraft plug-ins
https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/security/security-considerations-for-browser-ui.md
gives for dialog boxes), so a click, Enter or Space, or a press begun before then, installs nothing. The half second
starts over for every new question, even one that reads the same, and when the window gets the focus back. Cancel
and Escape work at once.
and Escape work at once. A module that would take the place of the installed plug-in with its id asks in the same
dialog.
- **An install that automation asked for is not approved in its dialog.** When a control request, MCP, injected input,
or a file dialog that one of them opened (even when the person picks the file there) leads to the approval dialog, its
OK stays disabled, a confirm is refused even after the half second, and the dialog says that automation asked for the
install. `ui.installPlugin` returns `{pending: "pluginInstall", approval: "personMustStartInstall"}`. Cancel still
closes it. To install the plug-in, the person chooses Object › Plug-ins › Install Plug-in… and picks the file, or
opens it with File › Open or drops it on the window: the module is inspected again and the new question waits its
half second. A request's `replace: true` does not skip asking about taking another module's place. A module that
needs no approval still installs at once from any request. This is VectorCraft's own rule.
- `plugin.install` refuses the module, naming the permissions it needs, unless this module already has a recorded grant
(`plugin.inspect`'s `needsApproval` tells it beforehand). A request's `grant` is refused too: `plugin.install` takes
it only with the approval the dialog gives. A `vectorcraft-cli mcp` or `run` started with `--allow-plugin-grants`
+1
View File
@@ -1695,6 +1695,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Plug-in approval needs a person (G17):** a plug-in's permissions and heavy budget are approved in a dialog that only a person can confirm: the control channel, MCP and injected input are refused, and `plugin.install` takes `grant` or `allowHeavy` only with that approval. `vectorcraft-cli mcp` and `run` refuse them unless started with `--allow-plugin-grants`. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/automation.rs`, `apps/vectorcraft-cli/tests/it/cli.rs`. Scores and hours unchanged.
- **Only an approval records a plug-in grant (G17):** `vectorcraft_plugins::registry::grant` takes a `GrantToken`, which the engine makes after an approval and when it loads `grants.json`, and `registry::install`, `install_bytes` and folder loads refuse a module that lists permissions no recorded grant gives this very module (`Error::NeedsApproval`), leaving the plug-in under its id and the revision as they were. A module without `permissions`, or with an empty list, installs as before. `docs/plugins.md` says what the approval does not protect against (native code linked into the app, another program running as the same user, the unsigned `grants.json`). Regressions: `crates/plugins/src/registry.rs`, `crates/plugins/tests/grant_sites.rs`, `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged.
- **Plug-in approval waits before OK (G17):** the approval dialog's OK stays disabled until its question has been on screen in the focused window for half a second, VectorCraft's own value; a click, Enter, Space, or a press begun before then installs nothing, and the dialog stays open. The wait starts over for every new question, even one that reads the same, and when the window gets the focus back, so a question swapped in just before a click is not approved by it. Cancel works at once. Regressions: `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged.
- **An install automation asked for needs the person to start it (G17):** an approval question that a control request, MCP, injected input or a file dialog one of them opened leads to keeps OK disabled and refuses every confirm, a person's click included; it 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. Automation's `replace: true` no longer skips the question about taking another module's place, and that question now opens in the approval dialog for everyone. VectorCraft's own rule. Regressions: `crates/ui-egui/src/tests/automation.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged.
- **A plug-in with no permissions ends its id's grant (G17):** installing a module that lists no permissions under a granted id forgets the grant, in the process and in `grants.json`, so the formerly approved bytes need a new approval. Attaching a `Plug-ins` folder whose module for that id lists no permissions forgets it too, and plug-in folders forget the process's grant. A refused install leaves the grant. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_safe_mode.rs`. Scores and hours unchanged.
- **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin.<id>.<command>` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged.
- **Filter plug-ins call commands (G17, #2186):** a filter at plug-in API level 2 with the `commands` permission names the commands it may run in its manifest's `commandAllowlist` and runs them with `env.vc_call`, reading each result with `env.vc_call_result`. Each call sees what the calls before it changed; the calls and the filter's output are one undo step, and a run that fails, is canceled or makes a call that fails leaves every document, its selection and its history as they were. Commands that read or write files, use the clipboard, change preferences or the view, run scripts, actions or plug-ins, switch documents or undo are never called, and a manifest that lists one is refused when the module is installed. The install dialog lists the allowed commands. A run makes at most 1,000 calls, and each call costs 10,000 instructions of its budget. Regressions: `crates/plugins/tests/call.rs`, `crates/engine/tests/plugin_call.rs`, `crates/engine/src/cmd/plugin_call.rs`. Scores and hours unchanged.