mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 23:01:02 +00:00
Merge pull request #6185 from dotCooCoo/feat/plugin-safe-mode
Plug-ins: Safe Mode starts without plug-ins, offered after a launch stops while one loads
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
//! `vectorcraft-cli`: VectorCraft from the command line.
|
||||
//!
|
||||
//! ```text
|
||||
//! vectorcraft-cli --safe-mode SUBCOMMAND ...
|
||||
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]...
|
||||
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}'] | --commands FILE|-]... [--export out.svg]... [--scale 2] [--ppi 300]
|
||||
//! vectorcraft-cli commands
|
||||
@@ -72,6 +73,11 @@ const USAGE: &str = "\
|
||||
vectorcraft-cli — VectorCraft automation
|
||||
|
||||
USAGE:
|
||||
vectorcraft-cli --safe-mode SUBCOMMAND ...
|
||||
Run any subcommand without plug-ins: none loads or runs, and plug-in commands report that
|
||||
Safe Mode is on. Plug-ins stay installed. `--safe-mode mcp` serves a headless session and
|
||||
does not take --connect.
|
||||
|
||||
vectorcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]...
|
||||
Run the MCP server on stdio. Default: connect to a running app at 127.0.0.1:7979
|
||||
(vectorcraft --control 7979), falling back to a headless in-process session.
|
||||
@@ -180,12 +186,18 @@ fn main() -> ExitCode {
|
||||
#[cfg(target_os = "macos")]
|
||||
mac_fonts::install();
|
||||
prefs_dir::install_fonts();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
// Before any subcommand attaches the plug-in folder.
|
||||
let safe_mode = args.first().is_some_and(|a| a == "--safe-mode");
|
||||
if safe_mode {
|
||||
args.remove(0);
|
||||
vectorcraft_engine::cmd::plugin::start_in_safe_mode();
|
||||
}
|
||||
let r = match args.first().map(String::as_str) {
|
||||
// `commands` and `info` take no options, so `--help` or `-h` anywhere after them prints the
|
||||
// usage. The other subcommands check for it themselves.
|
||||
Some("commands" | "info") if help_requested(&args[1..], &[]) => help(),
|
||||
Some("mcp") => mcp(&args[1..]),
|
||||
Some("mcp") => mcp(&args[1..], safe_mode),
|
||||
Some("run") => run(&args[1..]),
|
||||
Some("commands") => commands(),
|
||||
Some("convert") => convert(&args[1..]),
|
||||
@@ -218,7 +230,9 @@ fn with_stores(mut h: Headless) -> Headless {
|
||||
h
|
||||
}
|
||||
|
||||
fn mcp(args: &[String]) -> Result<(), String> {
|
||||
/// `mcp`. With `--safe-mode` (`safe_mode`) it serves a headless session: a running app loads its own
|
||||
/// plug-ins, so `--connect` is refused and the default does not connect.
|
||||
fn mcp(args: &[String], safe_mode: bool) -> Result<(), String> {
|
||||
if help_requested(args, &["--connect", "--automation-read-root", "--automation-write-root"]) {
|
||||
return help();
|
||||
}
|
||||
@@ -245,6 +259,9 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
if headless && connect.is_some() {
|
||||
return Err("use either --connect or --headless".into());
|
||||
}
|
||||
if safe_mode && connect.is_some() {
|
||||
return Err("--safe-mode runs a headless session without plug-ins; a running app loads its own: start it with `vectorcraft --safe-mode --control <port>`".into());
|
||||
}
|
||||
let read: Vec<&Path> = read_roots.iter().map(Path::new).collect();
|
||||
let write: Vec<&Path> = write_roots.iter().map(Path::new).collect();
|
||||
let roots = AutomationRoots::new(&read, &write)?;
|
||||
@@ -257,7 +274,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
file_access::confine_process(roots.clone());
|
||||
eprintln!("vectorcraft-cli: files confined: {}", roots.describe());
|
||||
Box::new(Headless::with_document().with_automation_roots(Some(roots)))
|
||||
} else if headless {
|
||||
} else if headless || safe_mode {
|
||||
Box::new(with_stores(Headless::with_document()))
|
||||
} else if let Some(addr) = connect {
|
||||
// Explicit address: fail loudly if the app isn't there.
|
||||
|
||||
@@ -283,6 +283,22 @@ fn mcp_headless_over_stdio() {
|
||||
assert_eq!(replies[3]["result"]["content"][0]["type"], "image");
|
||||
}
|
||||
|
||||
/// `--safe-mode mcp` serves a headless session without plug-ins: it refuses `--connect`, and by
|
||||
/// default it does not connect to an app listening on the default address, which loads its own
|
||||
/// plug-ins.
|
||||
#[test]
|
||||
fn safe_mode_mcp_does_not_connect_to_a_running_app() {
|
||||
let out = Command::new(BIN).args(["--safe-mode", "mcp", "--connect", "127.0.0.1:9"]).env("VECTORCRAFT_NO_PREFS", "1").output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!out.status.success() && err.contains("--safe-mode runs a headless session"), "{err}");
|
||||
// Something listening on the default address, as a running app does; a running app or another
|
||||
// program already holding it serves as well.
|
||||
let _app = std::net::TcpListener::bind(vectorcraft_mcp::DEFAULT_ADDR);
|
||||
let out = Command::new(BIN).args(["--safe-mode", "mcp"]).env("VECTORCRAFT_NO_PREFS", "1").stdin(Stdio::null()).output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success() && err.contains("MCP server on stdio (headless)"), "{err}");
|
||||
}
|
||||
|
||||
/// `mcp --automation-read-root` / `--automation-write-root` (#832): the flags are checked before the
|
||||
/// server starts, and the process then reads and writes only inside its roots.
|
||||
#[test]
|
||||
|
||||
@@ -8,6 +8,7 @@ use super::windows_stylus;
|
||||
use super::{App, VectorcraftApp, file_access, window};
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::{mac_menu, mac_window, open_documents};
|
||||
use vectorcraft_ui_egui::dialogs::safe_mode;
|
||||
|
||||
/// The parts of [`App::raw_input_hook`] that every window needs, for the OS windows of floating
|
||||
/// panels: a click or Escape that closes the macOS menu bar's open menu, and the pen barrel
|
||||
@@ -113,16 +114,26 @@ impl App {
|
||||
vectorcraft_ui_egui::background::wait_all(&mut self.app);
|
||||
return;
|
||||
}
|
||||
// An app that stays in Safe Mode leaves later launches to start their own app with plug-ins.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
if safe_mode::staying_on(&self.app) {
|
||||
single_instance::resign();
|
||||
}
|
||||
// Files from the Finder and the Dock (macOS) or from later launches (#4892) wait while a file
|
||||
// dialog is open: what asked for it runs again on its document when it answers.
|
||||
if !self.app.file_dialog_open() {
|
||||
// dialog is open: what asked for it runs again on its document when it answers. They also
|
||||
// wait for the choice about a stopped plug-in, so they open after the plug-ins it loads, as
|
||||
// do the command line's files held back for it. Files later launches handed over before the
|
||||
// choice ended in Safe Mode open in a new app with plug-ins.
|
||||
if !self.app.file_dialog_open() && !safe_mode::choosing(&self.app) {
|
||||
let held = std::mem::take(&mut self.held_files);
|
||||
#[cfg(target_os = "macos")]
|
||||
let files = open_documents::take();
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let files = single_instance::take();
|
||||
let files = single_instance::take_for_frame(safe_mode::staying_on(&self.app), single_instance::launch_with_plug_ins);
|
||||
if !files.is_empty() {
|
||||
bring_window_forward(ctx);
|
||||
}
|
||||
open_files(&mut self.app, held);
|
||||
open_files(&mut self.app, files);
|
||||
}
|
||||
let (now, zoom) = (ctx.input(|i| i.time), ctx.zoom_factor());
|
||||
@@ -151,13 +162,15 @@ impl App {
|
||||
/// On macOS with accessibility, end the process once the app has saved what it keeps on quitting,
|
||||
/// before eframe tears the window down (#661). AccessKit then gives the window's content view back
|
||||
/// its own class, which AppKit's Touch Bar support was observing it under, and AppKit aborts the
|
||||
/// app as it quits on Macs with a Touch Bar. Nothing after the window is needed on a normal quit.
|
||||
/// app as it quits on Macs with a Touch Bar. On a normal quit, only the launch marker is needed
|
||||
/// after the window, so it is ended here as `main` ends it after the window on other platforms.
|
||||
/// Not while the window is still starting up (`frames` below [`gpu::STARTUP_FRAMES`]): a graphics
|
||||
/// failure then starts the app again on another adapter (#651).
|
||||
#[allow(unused_variables)]
|
||||
fn end_before_teardown(frames: u64) {
|
||||
#[cfg(all(target_os = "macos", feature = "accessibility", feature = "wgpu"))]
|
||||
if frames >= gpu::STARTUP_FRAMES {
|
||||
safe_mode::end_launch();
|
||||
log::info!("quitting");
|
||||
log::logger().flush();
|
||||
std::process::exit(0);
|
||||
@@ -172,6 +185,16 @@ pub(crate) fn bring_window_forward(ctx: &egui::Context) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
||||
}
|
||||
|
||||
/// Open the command line's `files` now, or, while the choice about a stopped plug-in is up
|
||||
/// ([`safe_mode::choosing`]), return them to open once it is made, with the plug-ins it loads.
|
||||
pub(crate) fn open_or_hold(app: &mut VectorcraftApp, files: Vec<String>) -> Vec<String> {
|
||||
if safe_mode::choosing(app) {
|
||||
return files;
|
||||
}
|
||||
open_files(app, files);
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Open files handed to the app (command line, macOS Finder and Dock, later launches) as documents. A file that
|
||||
/// can't be opened is reported to the user and on stderr; the others still open.
|
||||
pub(crate) fn open_files(app: &mut VectorcraftApp, files: Vec<String>) {
|
||||
|
||||
+102
-38
@@ -1,7 +1,10 @@
|
||||
//! VectorCraft desktop app.
|
||||
//!
|
||||
//! Usage: `vectorcraft [--control <port> [--automation-read-root <dir>]... [--automation-write-root <dir>]...]
|
||||
//! [--in-window-menus] [--new-instance] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`)
|
||||
//! [--in-window-menus] [--new-instance] [--safe-mode] [--] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`)
|
||||
//!
|
||||
//! Any other argument starting with '-' (not `-` alone) is an error; after `--`, every argument is
|
||||
//! a file.
|
||||
//!
|
||||
//! Files given while VectorCraft already runs open in that window, unless `--new-instance` is given
|
||||
//! (Windows and Linux; `single_instance`).
|
||||
@@ -65,6 +68,7 @@ mod xwayland;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_engine::file_access::{self, AutomationRoots};
|
||||
use vectorcraft_ui_egui::dialogs::safe_mode;
|
||||
use vectorcraft_ui_egui::graphics::GraphicsLoss;
|
||||
use vectorcraft_ui_egui::picks::PickRequest;
|
||||
use vectorcraft_ui_egui::{ClipboardProbeFactory, FilePick, Services, VectorcraftApp};
|
||||
@@ -77,7 +81,7 @@ mod titlebar;
|
||||
#[cfg(all(test, feature = "wgpu"))]
|
||||
use app::bring_window_forward;
|
||||
use app::native_input_hook;
|
||||
use app::open_files;
|
||||
use app::open_or_hold;
|
||||
use host_services::services;
|
||||
#[cfg(all(test, feature = "wgpu"))]
|
||||
use prefs::{KEEP_UI_PREFS, decode_saved_prefs, keep_prefs_copy, prefs_bytes};
|
||||
@@ -113,6 +117,8 @@ struct App {
|
||||
pending_maximize: bool,
|
||||
/// Frames left to retry [`window::apply_pending_maximize`].
|
||||
maximize_tries: u8,
|
||||
/// The command line's files, held back until the choice about a stopped plug-in is made.
|
||||
held_files: Vec<String>,
|
||||
/// The time and UI zoom of the last monitor list ([`window::monitors_due`]).
|
||||
monitors_listed: Option<(f64, f32)>,
|
||||
}
|
||||
@@ -134,13 +140,89 @@ Options:
|
||||
(or VECTORCRAFT_IN_WINDOW_MENUS=1)
|
||||
--new-instance Open the files in a new window even when VectorCraft
|
||||
already runs (Windows and Linux)
|
||||
--safe-mode Start without plug-ins: none loads or runs until
|
||||
VectorCraft starts normally (Help › Safe Mode); the
|
||||
files open in a new window, as with --new-instance
|
||||
--version Print the version and exit
|
||||
-h, --help Print this help and exit
|
||||
-- Treat every argument after it as a file
|
||||
|
||||
Any other argument starting with '-' is an error.
|
||||
|
||||
Without a window (converting, scripting, the MCP server), use vectorcraft-cli:
|
||||
vectorcraft-cli --help
|
||||
";
|
||||
|
||||
/// The command line's options other than the automation roots and the control port.
|
||||
struct CommandLine {
|
||||
files: Vec<String>,
|
||||
in_window_menus: bool,
|
||||
new_instance: bool,
|
||||
/// `--safe-mode`: no plug-in loads or runs in this launch.
|
||||
safe_mode: bool,
|
||||
}
|
||||
|
||||
/// Reads the arguments after the program name. The automation roots and the control port go into
|
||||
/// `roots_args` and `control`. `Err` is the exit code when the process ends here: `--version` and
|
||||
/// `--help` print and succeed; a bad automation option or an unknown option prints an error and fails.
|
||||
fn read_command_line(
|
||||
mut args: impl Iterator<Item = String>,
|
||||
control: &mut automation_args::ControlArg,
|
||||
roots_args: &mut automation_args::AutomationArgs,
|
||||
) -> Result<CommandLine, std::process::ExitCode> {
|
||||
let mut files = Vec::new();
|
||||
let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
|
||||
let mut new_instance = false;
|
||||
let mut safe_mode = false;
|
||||
let mut unknown = None;
|
||||
while let Some(a) = args.next() {
|
||||
// After `--`, every argument is a file, even one whose name starts with '-'.
|
||||
if a == "--" {
|
||||
files.extend(args.by_ref());
|
||||
break;
|
||||
}
|
||||
match roots_args.consume(&a, &mut args).and_then(|root| if root { Ok(true) } else { control.consume(&a, &mut args) }) {
|
||||
Ok(true) => continue,
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
eprintln!("vectorcraft: {e}");
|
||||
return Err(std::process::ExitCode::FAILURE);
|
||||
}
|
||||
}
|
||||
match a.as_str() {
|
||||
"--in-window-menus" => in_window_menus = true,
|
||||
"--new-instance" => new_instance = true,
|
||||
"--safe-mode" => safe_mode = true,
|
||||
"--version" => {
|
||||
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Err(std::process::ExitCode::SUCCESS);
|
||||
}
|
||||
// Before anything starts, like `--version`: from a terminal or an agent's shell the app
|
||||
// used to open (or hang without a display) instead (#2110).
|
||||
"--help" | "-h" => {
|
||||
// Not `print!`, which panics when standard output is closed (`| head -0`):
|
||||
// there is nothing left to tell then.
|
||||
let _ = std::io::Write::write_all(&mut std::io::stdout(), USAGE.as_bytes());
|
||||
return Err(std::process::ExitCode::SUCCESS);
|
||||
}
|
||||
// The process serial number older macOS versions pass to an app the Finder starts.
|
||||
#[cfg(target_os = "macos")]
|
||||
_ if a.starts_with("-psn_") => {}
|
||||
// An option this version doesn't know (a typo such as `--safe-mod`) is an error, not a
|
||||
// file to open. Reported after the loop, so a later `--help` or `--version` still wins.
|
||||
_ if a.starts_with('-') && a != "-" => {
|
||||
unknown.get_or_insert(a);
|
||||
}
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
if let Some(a) = unknown {
|
||||
eprintln!("vectorcraft: unknown option {a}; see vectorcraft --help, or put -- before a file whose name starts with '-'");
|
||||
return Err(std::process::ExitCode::FAILURE);
|
||||
}
|
||||
Ok(CommandLine { files, in_window_menus, new_instance, safe_mode })
|
||||
}
|
||||
|
||||
fn main() -> std::process::ExitCode {
|
||||
// Windows: the update helper is a copy of this program started with `--finish-update`.
|
||||
#[cfg(windows)]
|
||||
@@ -156,37 +238,11 @@ fn main() -> std::process::ExitCode {
|
||||
let root_env = |name: &str| std::env::var_os(name).filter(|v| !v.is_empty()).map(std::path::PathBuf::from);
|
||||
let mut roots_args =
|
||||
automation_args::AutomationArgs::new(root_env("VECTORCRAFT_AUTOMATION_READ_ROOT"), root_env("VECTORCRAFT_AUTOMATION_WRITE_ROOT"));
|
||||
let mut files = Vec::new();
|
||||
let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
|
||||
let mut new_instance = false;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match roots_args.consume(&a, &mut args).and_then(|root| if root { Ok(true) } else { control.consume(&a, &mut args) }) {
|
||||
Ok(true) => continue,
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
eprintln!("vectorcraft: {e}");
|
||||
return std::process::ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
match a.as_str() {
|
||||
"--in-window-menus" => in_window_menus = true,
|
||||
"--new-instance" => new_instance = true,
|
||||
"--version" => {
|
||||
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return std::process::ExitCode::SUCCESS;
|
||||
}
|
||||
// Before anything starts, like `--version`: from a terminal or an agent's shell the app
|
||||
// used to open (or hang without a display) instead (#2110).
|
||||
"--help" | "-h" => {
|
||||
// Not `print!`, which panics when standard output is closed (`| head -0`):
|
||||
// there is nothing left to tell then.
|
||||
let _ = std::io::Write::write_all(&mut std::io::stdout(), USAGE.as_bytes());
|
||||
return std::process::ExitCode::SUCCESS;
|
||||
}
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
let CommandLine { files, in_window_menus, new_instance, safe_mode: safe_mode_flag } =
|
||||
match read_command_line(std::env::args().skip(1), &mut control, &mut roots_args) {
|
||||
Ok(command_line) => command_line,
|
||||
Err(code) => return code,
|
||||
};
|
||||
let automation_args::AutomationArgs { read_roots, write_roots, .. } = roots_args;
|
||||
// After the arguments: `--control` and `--version` win over a bad environment value.
|
||||
let control_port = match control.port {
|
||||
@@ -204,10 +260,10 @@ fn main() -> std::process::ExitCode {
|
||||
// Files opened from the file manager while VectorCraft runs open in its window (#4892). Before
|
||||
// the log file is attached: a launch that hands its files over leaves the running app's log be.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let instance = match single_instance::start(control_port.is_some(), new_instance, &files) {
|
||||
match single_instance::start(control_port.is_some(), new_instance, safe_mode_flag, &files) {
|
||||
single_instance::Start::Forwarded => return std::process::ExitCode::SUCCESS,
|
||||
single_instance::Start::Running(primary) => primary,
|
||||
};
|
||||
single_instance::Start::Running(primary) => single_instance::hold(primary),
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
let _ = new_instance;
|
||||
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();
|
||||
@@ -398,10 +454,15 @@ fn main() -> std::process::ExitCode {
|
||||
let mut session = Session::new();
|
||||
// The profiles loaded before come back before the preferences name any of them.
|
||||
prefs_dir::attach_profile_store(&mut session);
|
||||
// Before any plug-in loads: this launch's marker in the Launch folder next to the
|
||||
// preferences (none for runs without preferences), and Safe Mode.
|
||||
let launch_dir = prefs_path().filter(|_| prefs_enabled()).and_then(|p| Some(p.parent()?.join("Launch")));
|
||||
let stopped = safe_mode::begin_launch(launch_dir.as_deref(), safe_mode_flag);
|
||||
prefs_dir::attach_plugin_store(&mut session);
|
||||
let mut app = VectorcraftApp::new(session, services(cc.winit_window().cloned(), cc.egui_ctx.clone()));
|
||||
app.set_startup_update_check(true);
|
||||
load_prefs(&mut app, saved);
|
||||
safe_mode::after_start(&mut app, stopped);
|
||||
// The saved PDF presets live in their own folder (also read by vectorcraft-cli),
|
||||
// which takes over from the copy the preferences file used to hold.
|
||||
prefs_dir::attach_pdf_preset_store(&mut app.session);
|
||||
@@ -495,7 +556,7 @@ fn main() -> std::process::ExitCode {
|
||||
single_instance::set_ui(&cc.egui_ctx);
|
||||
}
|
||||
app.services.native_input_hook = native_input_hook(&app);
|
||||
file_access::unconfined(|| open_files(&mut app, files));
|
||||
let held_files = file_access::unconfined(|| open_or_hold(&mut app, files));
|
||||
// The first frame is due from now (`gpu::watch_first_frame`).
|
||||
#[cfg(feature = "wgpu")]
|
||||
created.ready();
|
||||
@@ -511,6 +572,7 @@ fn main() -> std::process::ExitCode {
|
||||
winit_window,
|
||||
pending_maximize,
|
||||
maximize_tries: 8,
|
||||
held_files,
|
||||
monitors_listed: None,
|
||||
}))
|
||||
}),
|
||||
@@ -519,7 +581,9 @@ fn main() -> std::process::ExitCode {
|
||||
// The window is gone: later launches start their own app, including the one a graphics
|
||||
// failure starts again below.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
drop(instance);
|
||||
single_instance::resign();
|
||||
// The process ends normally from here: this launch didn't stop while a plug-in was loading.
|
||||
safe_mode::end_launch();
|
||||
#[cfg(feature = "wgpu")]
|
||||
let outcome = gpu::finish(outcome, &startup);
|
||||
#[cfg(not(feature = "wgpu"))]
|
||||
|
||||
@@ -12,8 +12,12 @@
|
||||
//! as its own app, which replaces a stale file. Nothing received is ever run.
|
||||
//!
|
||||
//! Runs that start many apps side by side (`--control`, `VECTORCRAFT_NO_PREFS`) neither forward
|
||||
//! nor listen, and `--new-instance` opens a launch's files in an app of its own. macOS hands files
|
||||
//! opened from Finder to the running app itself (`open_documents`), so this is off there.
|
||||
//! nor listen, and neither does a `--safe-mode` launch, which opens its files in an app of its own
|
||||
//! and leaves later launches to start theirs with plug-ins. `--new-instance` opens a launch's files
|
||||
//! in an app of its own. An app that stays in Safe Mode after the choice about a stopped plug-in
|
||||
//! stops listening ([`resign`]), and files later launches handed it before then open in a new app
|
||||
//! with plug-ins ([`take_for_frame`]). macOS hands files opened from Finder to the running app itself
|
||||
//! (`open_documents`), so this is off there.
|
||||
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::hash::{BuildHasher as _, RandomState};
|
||||
@@ -43,6 +47,8 @@ const CLIENT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Paths handed over by later launches that the UI hasn't opened yet.
|
||||
static PENDING: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
/// This app while it is the one later launches hand their files to ([`hold`]).
|
||||
static HELD: Mutex<Option<Primary>> = Mutex::new(None);
|
||||
/// The UI to wake when files arrive (unset until the window exists; the first frame opens them).
|
||||
static UI: OnceLock<egui::Context> = OnceLock::new();
|
||||
|
||||
@@ -56,23 +62,46 @@ pub enum Start {
|
||||
|
||||
/// Hand `files` to the app already running, or become the app later launches hand theirs to.
|
||||
/// `automation`: started with `--control`.
|
||||
pub fn start(automation: bool, new_instance: bool, files: &[String]) -> Start {
|
||||
pub fn start(automation: bool, new_instance: bool, safe_mode: bool, files: &[String]) -> Start {
|
||||
if automation || !prefs_enabled() {
|
||||
return Start::Running(None);
|
||||
}
|
||||
let Some(path) = prefs_path().and_then(|p| Some(p.parent()?.join(FILE_NAME))) else { return Start::Running(None) };
|
||||
start_at(&path, new_instance, safe_mode, files)
|
||||
}
|
||||
|
||||
/// [`start`] with the endpoint file `path`. A launch with `--new-instance` or `--safe-mode` keeps
|
||||
/// its files and runs as its own app: a running app would open them with its plug-ins loaded. A
|
||||
/// `--safe-mode` launch never becomes the app later launches hand their files to, which would open
|
||||
/// them without plug-ins.
|
||||
fn start_at(path: &Path, new_instance: bool, safe_mode: bool, files: &[String]) -> Start {
|
||||
if safe_mode {
|
||||
return Start::Running(None);
|
||||
}
|
||||
// With nothing to hand over, the same exchange only asks whether an app is running.
|
||||
let send: &[String] = if new_instance { &[] } else { files };
|
||||
match forward(&path, send) {
|
||||
match forward(path, send) {
|
||||
Ok(()) if send.is_empty() => Start::Running(None),
|
||||
Ok(()) => Start::Forwarded,
|
||||
Err(e) => {
|
||||
log::debug!("no running VectorCraft to hand files to ({e})");
|
||||
Start::Running(listen(&path, queue))
|
||||
Start::Running(listen(path, queue))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep `primary` ([`start`]'s) until [`resign`].
|
||||
pub fn hold(primary: Option<Primary>) {
|
||||
*HELD.lock().unwrap_or_else(PoisonError::into_inner) = primary;
|
||||
}
|
||||
|
||||
/// Later launches start their own app from now on: the endpoint file goes. The app calls it when
|
||||
/// its window is gone, and when it stays in Safe Mode after the choice about a stopped plug-in.
|
||||
pub fn resign() {
|
||||
let primary = HELD.lock().unwrap_or_else(PoisonError::into_inner).take();
|
||||
drop(primary);
|
||||
}
|
||||
|
||||
/// Wake `ctx` when files arrive from later launches.
|
||||
pub fn set_ui(ctx: &egui::Context) {
|
||||
// Set once; a second call would hand over the same context.
|
||||
@@ -84,6 +113,43 @@ pub fn take() -> Vec<String> {
|
||||
std::mem::take(&mut *PENDING.lock().unwrap_or_else(PoisonError::into_inner))
|
||||
}
|
||||
|
||||
/// The paths later launches handed over since the last call, to open in this app. An app that
|
||||
/// stays in Safe Mode (`safe_mode`) resigns and hands them to a new app with plug-ins (`launch`,
|
||||
/// [`launch_with_plug_ins`] in the app) instead: they arrived while it was the primary, before the
|
||||
/// choice about a stopped plug-in ended in Safe Mode, or just before it resigned. When `launch`
|
||||
/// fails (logged), they open here.
|
||||
pub fn take_for_frame(safe_mode: bool, launch: impl FnOnce(&[String]) -> io::Result<()>) -> Vec<String> {
|
||||
let files = take();
|
||||
if !safe_mode {
|
||||
return files;
|
||||
}
|
||||
// First, so the new app becomes the primary instead of handing the files back.
|
||||
resign();
|
||||
if files.is_empty() {
|
||||
return files;
|
||||
}
|
||||
match launch(&files) {
|
||||
Ok(()) => Vec::new(),
|
||||
Err(e) => {
|
||||
log::warn!("couldn't start VectorCraft with plug-ins for the files another launch opened: {e}");
|
||||
files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start this program again, with plug-ins, on `files`.
|
||||
pub fn launch_with_plug_ins(files: &[String]) -> io::Result<()> {
|
||||
app_with_plug_ins(&std::env::current_exe()?, files).spawn().map(drop)
|
||||
}
|
||||
|
||||
/// The command [`launch_with_plug_ins`] runs: `exe -- files…`, so a file whose name starts with '-'
|
||||
/// stays a file.
|
||||
fn app_with_plug_ins(exe: &Path, files: &[String]) -> std::process::Command {
|
||||
let mut command = std::process::Command::new(exe);
|
||||
command.arg("--").args(files);
|
||||
command
|
||||
}
|
||||
|
||||
fn queue(files: Vec<String>) {
|
||||
PENDING.lock().unwrap_or_else(PoisonError::into_inner).extend(files);
|
||||
if let Some(ctx) = UI.get() {
|
||||
@@ -263,6 +329,13 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Held by the tests that use the process-wide [`HELD`] and [`PENDING`], one at a time.
|
||||
static GLOBALS: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn globals() -> std::sync::MutexGuard<'static, ()> {
|
||||
GLOBALS.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
struct TestDir(tempfile::TempDir);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
@@ -313,6 +386,72 @@ mod tests {
|
||||
assert!(!path.exists(), "the endpoint file goes with the app");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_instance_or_safe_mode_launch_keeps_its_files() {
|
||||
let (path, _dir, file) = folder("own");
|
||||
let (primary, rx) = primary(&path);
|
||||
for (new_instance, safe_mode) in [(true, false), (false, true)] {
|
||||
let start = start_at(&path, new_instance, safe_mode, std::slice::from_ref(&file));
|
||||
assert!(matches!(start, Start::Running(None)), "runs as its own app (new instance {new_instance}, Safe Mode {safe_mode})");
|
||||
}
|
||||
assert!(matches!(start_at(&path, false, false, std::slice::from_ref(&file)), Start::Forwarded));
|
||||
assert_eq!(rx.recv_timeout(CLIENT_TIMEOUT).unwrap(), std::slice::from_ref(&file));
|
||||
assert!(rx.try_recv().is_err(), "only the plain launch handed its file over");
|
||||
drop(primary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_safe_mode_launch_leaves_later_launches_their_own_app() {
|
||||
let _globals = globals();
|
||||
let (path, _dir, file) = folder("safe-mode-alone");
|
||||
// With no app running, a Safe Mode launch runs without listening.
|
||||
assert!(matches!(start_at(&path, false, true, std::slice::from_ref(&file)), Start::Running(None)));
|
||||
assert!(!path.exists(), "a Safe Mode launch records no endpoint");
|
||||
// A plain launch after it starts its own app and takes later launches' files.
|
||||
let Start::Running(Some(primary)) = start_at(&path, false, false, std::slice::from_ref(&file)) else {
|
||||
panic!("the plain launch runs and listens")
|
||||
};
|
||||
assert!(path.exists());
|
||||
// An app that stays in Safe Mode after the choice gives that up.
|
||||
hold(Some(primary));
|
||||
resign();
|
||||
assert!(!path.exists(), "the endpoint file goes with resign");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_handed_to_an_app_staying_in_safe_mode_go_to_a_new_app_with_plug_ins() {
|
||||
let _globals = globals();
|
||||
// Nothing left over from a test that failed.
|
||||
resign();
|
||||
drop(take());
|
||||
let (path, _dir, file) = folder("safe-mode-later");
|
||||
let Start::Running(Some(primary)) = start_at(&path, false, false, &[]) else { panic!("the first launch listens") };
|
||||
hold(Some(primary));
|
||||
// A later launch handed its file over while the choice about a stopped plug-in was up.
|
||||
queue(vec![file.clone()]);
|
||||
let mut launched = Vec::new();
|
||||
let here = take_for_frame(true, |files| {
|
||||
launched = files.to_vec();
|
||||
Ok(())
|
||||
});
|
||||
assert!(here.is_empty(), "nothing opens in the Safe Mode app");
|
||||
assert_eq!(launched, std::slice::from_ref(&file), "a new app with plug-ins opens it");
|
||||
assert!(!path.exists(), "the app resigned before starting the new one");
|
||||
// When the new app can't start, the file opens here rather than being lost.
|
||||
queue(vec![file.clone()]);
|
||||
assert_eq!(take_for_frame(true, |_| Err(io::Error::other("no program"))), std::slice::from_ref(&file));
|
||||
// An app with plug-ins opens what it is handed.
|
||||
queue(vec![file.clone()]);
|
||||
assert_eq!(take_for_frame(false, |_| panic!("no new app")), [file]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_new_app_takes_every_name_as_a_file() {
|
||||
let command = app_with_plug_ins(Path::new("vectorcraft"), &["-a.svg".to_owned(), "b.svg".to_owned()]);
|
||||
let args: Vec<_> = command.get_args().collect();
|
||||
assert_eq!(args, ["--", "-a.svg", "b.svg"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_token_garbage_and_oversized_messages_are_refused() {
|
||||
let (path, _dir, file) = folder("refused");
|
||||
|
||||
@@ -36,6 +36,33 @@ fn shortcut_overrides_from_an_earlier_version_load() {
|
||||
assert!(km.problems().iter().any(|p| format!("{p:?}") == r#"UnknownCommand { key: "test.unknown" }"#), "reported");
|
||||
}
|
||||
|
||||
/// The command line's files wait for the choice about a stopped plug-in, so they open with the
|
||||
/// plug-ins it loads; with no choice up they open at once.
|
||||
#[test]
|
||||
fn command_line_files_wait_for_the_choice_about_a_stopped_plug_in() {
|
||||
let temp = tempfile::Builder::new().prefix("vectorcraft-held-files-").tempdir().unwrap();
|
||||
let file = temp.path().join("a.svg");
|
||||
std::fs::write(&file, r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>"#).unwrap();
|
||||
let files = vec![file.to_string_lossy().into_owned()];
|
||||
// The files the app reads to open them.
|
||||
let read = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
|
||||
let reads = read.clone();
|
||||
let services = vectorcraft_ui_egui::Services {
|
||||
read: Some(Box::new(move |p: &str| {
|
||||
reads.borrow_mut().push(p.to_owned());
|
||||
std::fs::read(p).map_err(|e| e.to_string())
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), services);
|
||||
app.ui.stopped_plugin = Some(serde_json::json!({"id": "test.held", "name": "Held"}));
|
||||
assert_eq!(open_or_hold(&mut app, files.clone()), files, "held while the choice is up");
|
||||
assert!(read.borrow().is_empty(), "nothing opened before the choice");
|
||||
app.ui.stopped_plugin = None;
|
||||
assert!(open_or_hold(&mut app, files.clone()).is_empty());
|
||||
assert_eq!(*read.borrow(), files, "opened with no choice up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_files_unminimizes_and_focuses_the_window() {
|
||||
let ctx = egui::Context::default();
|
||||
|
||||
@@ -54,7 +54,7 @@ fn prints_usage(args: &[&str], env: &[(&str, &str)]) {
|
||||
assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(out.stderr.is_empty(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(stdout.starts_with("Usage: vectorcraft "), "{args:?}: {stdout}");
|
||||
for flag in ["--control", "--automation-read-root", "--automation-write-root", "--in-window-menus", "--version", "--help"] {
|
||||
for flag in ["--control", "--automation-read-root", "--automation-write-root", "--in-window-menus", "--safe-mode", "--version", "--help"] {
|
||||
assert!(stdout.contains(flag), "{args:?}: usage doesn't mention {flag}: {stdout}");
|
||||
}
|
||||
// Headless work is the CLI's: the usage says where to go.
|
||||
@@ -74,3 +74,27 @@ fn help_prints_usage_and_exits_before_startup() {
|
||||
fn help_wins_over_a_bad_control_port_variable() {
|
||||
prints_usage(&["--help"], &[("VECTORCRAFT_CONTROL_PORT", "abc")]);
|
||||
}
|
||||
|
||||
/// An option the app doesn't know (a typo of `--safe-mode`) is an error, not a file to open with
|
||||
/// plug-ins; a later `--help` still wins.
|
||||
#[test]
|
||||
fn an_unknown_option_is_an_error_and_not_a_file() {
|
||||
for args in [&["--safe-mod"][..], &["drawing.svg", "--safe-mod"], &["-x"]] {
|
||||
let out = run(args, &[]);
|
||||
assert!(!out.status.success(), "{args:?}");
|
||||
assert!(out.stdout.is_empty(), "{args:?}");
|
||||
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||
let unknown = args.iter().find(|a| a.starts_with('-')).unwrap();
|
||||
assert_eq!(
|
||||
stderr,
|
||||
format!("vectorcraft: unknown option {unknown}; see vectorcraft --help, or put -- before a file whose name starts with '-'\n"),
|
||||
"{args:?}"
|
||||
);
|
||||
}
|
||||
prints_usage(&["--safe-mod", "--help"], &[]);
|
||||
// After `--`, a name starting with '-' is a file: the run goes on to the control port, which
|
||||
// `run` makes fail.
|
||||
let out = run(&["--", "-x.svg"], &[]);
|
||||
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||
assert!(stderr.contains("VECTORCRAFT_CONTROL_PORT") && !stderr.contains("unknown option"), "{stderr}");
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Effects",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?: object ids (default: the selection), target?: \"object\"|\"contents\"} → {catalog: [{id, label, menu, params, defaults, raster, lengths: {always, absolute (while relative is false)} (distance params Scale Strokes & Effects scales)}] (installed effect plug-ins last: id plugin.<plug-in id>, menu Effect › Plug-ins), applied: [{id, effects, items: [{index, kind: fill|stroke, effects}]}], activeItem} for the selection",
|
||||
"{ids?|id?: object ids (default: the selection), target?: \"object\"|\"contents\"} → {catalog: [{id, label, menu, params, defaults, raster, lengths: {always, absolute (while relative is false)} (distance params Scale Strokes & Effects scales)}] (installed effect plug-ins last: id plugin.<plug-in id>, menu Effect › Plug-ins), applied: [{id, effects, items: [{index, kind: fill|stroke, effects}]}] (an effect has skipped: true while Safe Mode skips it: a plug-in effect, which the object keeps and draws without), activeItem} for the selection",
|
||||
always,
|
||||
list
|
||||
),
|
||||
@@ -71,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand Appearance",
|
||||
["Object"],
|
||||
None,
|
||||
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes, or one selected with the art it measures when that art is replaced, becomes a plain group of its art. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes, or one selected with the art it measures when that art is replaced, becomes a plain group of its art. Hidden fills, strokes and effects are dropped. In Safe Mode, an object with a visible plug-in effect (in it, or in a symbol it places) is an error and nothing changes. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
can_expand,
|
||||
expand_appearance
|
||||
),
|
||||
@@ -307,7 +307,22 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.collect();
|
||||
let mut applied = vec![];
|
||||
if s.active().is_some() {
|
||||
let fx = |e: &[Effect]| serde_json::to_value(e).unwrap_or(Value::Null);
|
||||
// A plug-in effect Safe Mode skips says so (`skipped`): the object keeps it.
|
||||
let fx = |e: &[Effect]| {
|
||||
Value::Array(
|
||||
e.iter()
|
||||
.map(|e| {
|
||||
let mut v = serde_json::to_value(e).unwrap_or(Value::Null);
|
||||
if vectorcraft_plugins::effect::skipped(&e.id)
|
||||
&& let Some(o) = v.as_object_mut()
|
||||
{
|
||||
o.insert("skipped".into(), json!(true));
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
for id in appearance_targets(s, p)? {
|
||||
if let Some(n) = s.doc()?.doc.node(id) {
|
||||
let items: Vec<Value> = n
|
||||
@@ -879,8 +894,40 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br
|
||||
changed.then_some(n)
|
||||
}
|
||||
|
||||
/// Does `n`, an object in it, or the art of a symbol placed in it hold a plug-in effect that
|
||||
/// expanding it draws: a visible one, the object's own or a visible fill's or stroke's, on an
|
||||
/// object that expands ([`expandable`]; in a symbol's art, `in_art`, every object does). A symbol's
|
||||
/// art counts where an instance of it expands. `seen` holds the symbols already looked into.
|
||||
fn holds_plugin_effect(d: &Document, n: &Node, in_art: bool, seen: &mut std::collections::HashSet<String>) -> bool {
|
||||
let plugin = |e: &Effect| e.visible && vectorcraft_plugins::effect::plugin_id(&e.id).is_some();
|
||||
let mut symbols = vec![];
|
||||
let mut holds = false;
|
||||
n.walk(&mut |c| {
|
||||
if !in_art && !expandable(c) {
|
||||
return;
|
||||
}
|
||||
holds |= c.appearance.effects.iter().any(plugin) || c.appearance.items.iter().any(|i| i.visible() && i.effects().iter().any(plugin));
|
||||
if let NodeKind::SymbolInstance { symbol, .. } = &c.kind {
|
||||
symbols.push(symbol.clone());
|
||||
}
|
||||
});
|
||||
holds
|
||||
|| symbols.into_iter().any(|name| {
|
||||
seen.insert(name.clone()) && d.symbols.iter().find(|s| s.name == name).is_some_and(|s| holds_plugin_effect(d, &s.art, true, seen))
|
||||
})
|
||||
}
|
||||
|
||||
fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = appearance_targets(s, p)?;
|
||||
// In Safe Mode a plug-in effect draws nothing: expanding it would drop the effect and keep the
|
||||
// geometry it hasn't changed.
|
||||
if vectorcraft_plugins::safe_mode::active() {
|
||||
let d = &s.doc()?.doc;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
if roots.iter().filter_map(|id| d.node(*id)).any(|n| holds_plugin_effect(d, n, false, &mut seen)) {
|
||||
return Err(EngineError::Other("Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode".into()));
|
||||
}
|
||||
}
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
|
||||
let ids = s.edit("Expand Appearance", |d, _| {
|
||||
|
||||
@@ -30,7 +30,7 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{Effect, Node};
|
||||
use vectorcraft_plugins::{Budget, Kind, Plugin, effect, export, objects, registry};
|
||||
use vectorcraft_plugins::{Budget, Kind, Plugin, effect, export, objects, registry, safe_mode};
|
||||
|
||||
use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, has_doc, ids_param, str_param, switch_or};
|
||||
|
||||
@@ -384,7 +384,21 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.store_grant(&m.id, Some(g));
|
||||
}
|
||||
// Installed plug-ins stay installed after a restart (#2541).
|
||||
s.store_plugin(plugin.id(), &bytes);
|
||||
let stored = s.store_plugin(plugin.id(), &bytes);
|
||||
// Installing a disabled plug-in again (an updated version) turns it on for the next launch too,
|
||||
// also when it is disabled by its stored file alone (it stopped a launch while loading). When
|
||||
// the new version could not replace the stored file, the old one stays disabled.
|
||||
let file = s.stored_plugin_file(plugin.id());
|
||||
let mut changed = false;
|
||||
if file.is_none() || stored {
|
||||
changed = safe_mode::set_enabled(&safe_mode::Item::of(&plugin), true);
|
||||
if let Some(file) = file {
|
||||
changed |= safe_mode::set_enabled(&safe_mode::Item::file(file), true);
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
s.store_disabled_plugins();
|
||||
}
|
||||
Ok(describe(®istry::install(plugin)))
|
||||
}
|
||||
|
||||
@@ -421,7 +435,8 @@ static FOLDER: Mutex<Option<(String, Option<std::path::PathBuf>, Value)>> = Mute
|
||||
/// (startup, `prefs.set`). Native only.
|
||||
pub(crate) fn sync_prefs(s: &Session) {
|
||||
let folder = s.prefs.plugins_folder.trim();
|
||||
if folder.is_empty() {
|
||||
// In Safe Mode nothing loads, and the folder is loaded once it ends ([`load_prefs_folder`]).
|
||||
if folder.is_empty() || safe_mode::active() {
|
||||
return;
|
||||
}
|
||||
let mut last = FOLDER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
@@ -431,8 +446,24 @@ pub(crate) fn sync_prefs(s: &Session) {
|
||||
*last = Some((folder.to_string(), s.plugin_dir.clone(), load_folder(s, folder)));
|
||||
}
|
||||
|
||||
/// Turns Safe Mode on for `--safe-mode`, before the plug-in folder is attached: no plug-in loads or
|
||||
/// runs in this process from then on.
|
||||
pub fn start_in_safe_mode() {
|
||||
safe_mode::set_active(Some(safe_mode::Source::Flag));
|
||||
}
|
||||
|
||||
/// Loads the stored plug-ins and the Additional Plug-ins Folder preference: the app calls it when
|
||||
/// the user leaves the choice after a launch that stopped with Disable or Start Normally, which
|
||||
/// turns Safe Mode off first.
|
||||
pub fn load_prefs_folder(s: &mut Session) {
|
||||
s.load_stored_plugins();
|
||||
*FOLDER.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
sync_prefs(s);
|
||||
}
|
||||
|
||||
/// Loads the plug-ins in `folder`, those that list permissions only when `s` has them granted
|
||||
/// ([`Session::plugin_approved`], with the store's grants read once).
|
||||
/// ([`Session::plugin_approved`], with the store's grants read once) → its report; saves the
|
||||
/// disabled list when loading changed it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_folder(s: &Session, folder: &str) -> Value {
|
||||
if let Err(e) = crate::file_access::check_read(folder) {
|
||||
@@ -447,6 +478,9 @@ fn load_folder(s: &Session, folder: &str) -> Value {
|
||||
};
|
||||
match registry::load_folder_where(std::path::Path::new(folder), allow, s.plugin_approver()) {
|
||||
Ok(r) => {
|
||||
if r.disabled_changed {
|
||||
s.store_disabled_plugins();
|
||||
}
|
||||
json!({"folder": folder, "loaded": r.loaded, "failed": r.failed.iter().map(|(f, e)| json!({"file": f, "error": e})).collect::<Vec<_>>()})
|
||||
}
|
||||
Err(e) => json!({"folder": folder, "error": e.to_string()}),
|
||||
@@ -477,7 +511,59 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn list(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| r.clone());
|
||||
Ok(json!({"plugins": registry::list().iter().map(|p| describe(p)).collect::<Vec<_>>(), "folder": folder}))
|
||||
let disabled: Vec<Value> = safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect();
|
||||
Ok(
|
||||
json!({"plugins": registry::list().iter().map(|p| describe(p)).collect::<Vec<_>>(), "folder": folder, "safeMode": safe_mode::active(), "disabled": disabled}),
|
||||
)
|
||||
}
|
||||
|
||||
/// `plugin.safeMode`: whether Safe Mode is on and why, the plug-ins disabled for the next launch,
|
||||
/// and the plug-in the last launch was loading when it stopped while the user hasn't chosen yet.
|
||||
fn safe_mode_state(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({
|
||||
"active": safe_mode::active(),
|
||||
"source": safe_mode::source().map(safe_mode::Source::name),
|
||||
"disabled": safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect::<Vec<_>>(),
|
||||
"pendingItem": safe_mode::previous().map(|i| i.to_json()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// `plugin.setEnabled`: turns a plug-in on or off for the next launch, by `id` or by `file` (a
|
||||
/// plug-in that stopped a launch while loading may be known by its file alone). It neither loads
|
||||
/// nor unloads anything now, and uninstalls nothing.
|
||||
fn set_enabled(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "plugin.setEnabled";
|
||||
let Some(enabled) = p.get("enabled").and_then(Value::as_bool) else {
|
||||
let key = "enabled";
|
||||
return Err(bad(C, format!("`{key}` must be true or false")));
|
||||
};
|
||||
let text = |key: &str| match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(v)) => Ok(Some(v.trim().to_string()).filter(|v| !v.is_empty())),
|
||||
Some(_) => Err(bad(C, format!("`{key}` must be a string"))),
|
||||
};
|
||||
// Turning on by id also matches the plug-in's stored file, which names it when it stopped a
|
||||
// launch while loading.
|
||||
let (item, stored) = match (text("id")?, text("file")?) {
|
||||
(Some(id), _) => {
|
||||
let stored = if enabled { s.stored_plugin_file(&id).map(safe_mode::Item::file) } else { None };
|
||||
(registry::get(&id).map(|p| safe_mode::Item::of(&p)).unwrap_or(safe_mode::Item { id: Some(id), ..Default::default() }), stored)
|
||||
}
|
||||
(None, Some(file)) => (safe_mode::Item::file(file), None),
|
||||
(None, None) => return Err(bad(C, "give `id` or `file` (see plugin.safeMode)")),
|
||||
};
|
||||
let items: Vec<&safe_mode::Item> = std::iter::once(&item).chain(stored.as_ref()).collect();
|
||||
if enabled && !safe_mode::disabled().iter().any(|i| items.iter().any(|item| i.same(item))) {
|
||||
return Err(bad(C, "that plug-in isn't disabled (see plugin.safeMode)"));
|
||||
}
|
||||
let mut changed = false;
|
||||
for item in items {
|
||||
changed |= safe_mode::set_enabled(item, enabled);
|
||||
}
|
||||
// Without a plug-in folder (a session confined to automation roots), or when the list there
|
||||
// can't take the change, the change stays in this process and goes with the next change that saves.
|
||||
let saved = !changed || s.store_disabled_plugins();
|
||||
Ok(json!({"changed": changed, "saved": saved, "disabled": safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
fn info(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -498,8 +584,8 @@ fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if registry::remove(id) { Ok(json!({"removed": id})) } else { Err(err(vectorcraft_plugins::Error::NotFound(id.into()))) }
|
||||
}
|
||||
|
||||
/// This module's commands: `plugin.list`, `.info`, `.run`, `.install`, `.inspect`, `.remove` and
|
||||
/// `.reload`.
|
||||
/// This module's commands: `plugin.list`, `.info`, `.run`, `.install`, `.inspect`, `.remove`,
|
||||
/// `.reload`, `.safeMode` and `.setEnabled`.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
@@ -507,7 +593,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"List Plug-ins",
|
||||
[],
|
||||
None,
|
||||
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's format id, plugin.<id>, for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}",
|
||||
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's format id, plugin.<id>, for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}",
|
||||
always,
|
||||
list
|
||||
),
|
||||
@@ -553,9 +639,27 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reload Plug-ins",
|
||||
["Object", "Plug-ins"],
|
||||
None,
|
||||
"{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted its id) → {folder, loaded: [id], failed: [{file, error}]}",
|
||||
"{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module, and one disabled with plugin.setEnabled, is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted its id; an error in Safe Mode) → {folder, loaded: [id], failed: [{file, error}]}",
|
||||
always,
|
||||
reload
|
||||
),
|
||||
cmd!(
|
||||
query "plugin.safeMode",
|
||||
"Safe Mode",
|
||||
[],
|
||||
None,
|
||||
"{} → {active: bool (no plug-in loads or runs: plugin.install, .run, .reload and exporters fail, live effects draw their object's geometry unchanged and the objects keep their effects), source: \"flag\" (started with --safe-mode) | \"startup\" (the user chose Safe Mode, or hasn't chosen yet, after a launch that stopped while a plug-in was loading) | null, disabled: [{id, file, name, label}] (turned off for the next launch; id is null for one known only by its file), pendingItem: {id, file, name, label} | null (the plug-in the last launch was loading when it stopped, until the user chooses; it may not be what stopped it)}. Safe Mode is chosen at launch, never by a command",
|
||||
always,
|
||||
safe_mode_state
|
||||
),
|
||||
cmd!(
|
||||
query "plugin.setEnabled",
|
||||
"Enable on Re-launch",
|
||||
[],
|
||||
None,
|
||||
"{id | file: a plug-in as plugin.safeMode's disabled lists it, enabled: bool} turn a plug-in on or off for the next launch (Help › Safe Mode's Enable on Re-launch); nothing loads, unloads or is uninstalled now. Turning on by id also turns on the file the plug-in is stored in. Turning on one that isn't disabled is an error. plugin.install of a disabled plug-in turns it on → {changed, saved: false when the change isn't in the plug-in folder for the next launch (a session without one, or the list there can't be read or written or is full; it is saved with the next change that saves), disabled}",
|
||||
always,
|
||||
set_enabled
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
+98
-10
@@ -13,7 +13,9 @@
|
||||
//! move or go away). Attaching the folder registers them again.
|
||||
//! * **Plug-ins**: the module of each plug-in `plugin.install` installs, as a `.wasm` file named
|
||||
//! after its id (as Illustrator keeps plug-ins in its Plug-ins folder); `plugin.remove` deletes it.
|
||||
//! Attaching the folder installs them again. The Additional Plug-ins Folder preference is not
|
||||
//! Attaching the folder installs them again, except in Safe Mode and except the ones the user
|
||||
//! disabled, which `disabled.json` in the same folder lists (`plugin.setEnabled`; a window locks
|
||||
//! `disabled.lock` while it writes the list). The Additional Plug-ins Folder preference is not
|
||||
//! copied: it is loaded from where it is. `grants.json` holds what the user granted the plug-ins
|
||||
//! stored there that list permissions (id, module hash, permissions); a stored plug-in whose
|
||||
//! permissions it doesn't grant is skipped with a log record, and stays in the folder.
|
||||
@@ -26,6 +28,7 @@ use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use vectorcraft_pdf::{PdfPreset, builtin_preset};
|
||||
use vectorcraft_plugins::safe_mode;
|
||||
|
||||
use crate::Session;
|
||||
use crate::cmd::pdfcmds::{PRESET_FORMAT, PresetFile};
|
||||
@@ -44,6 +47,10 @@ const PROFILE_EXT: &str = "icc";
|
||||
pub const MAX_PLUGIN_STORE: u64 = 512 << 20;
|
||||
/// The extension of a stored plug-in.
|
||||
const PLUGIN_EXT: &str = "wasm";
|
||||
/// The file in the plug-in folder that lists the plug-ins the user disabled.
|
||||
pub const DISABLED_FILE: &str = "disabled.json";
|
||||
/// The file in the plug-in folder that a window locks while it reads and writes [`DISABLED_FILE`].
|
||||
pub const DISABLED_LOCK: &str = "disabled.lock";
|
||||
/// The file in the plug-in store that holds the permissions the user granted.
|
||||
const GRANTS_FILE: &str = "grants.json";
|
||||
/// The largest grants file read.
|
||||
@@ -114,6 +121,25 @@ fn write_atomic(_: &Path, _: &[u8]) -> std::io::Result<()> {
|
||||
Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "no file system"))
|
||||
}
|
||||
|
||||
/// [`DISABLED_LOCK`] in `dir`, locked until the file is dropped, waiting while another window holds
|
||||
/// it; `None` when it can't be opened or locked.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn lock_disabled(dir: &Path) -> Option<std::fs::File> {
|
||||
let opened = std::fs::create_dir_all(dir)
|
||||
.and_then(|()| std::fs::File::options().create(true).truncate(false).read(true).write(true).open(dir.join(DISABLED_LOCK)));
|
||||
let file = opened.and_then(|f| f.lock().map(|()| f));
|
||||
if let Err(e) = &file {
|
||||
log::warn!("can't lock {}: {e}", dir.join(DISABLED_LOCK).display());
|
||||
}
|
||||
file.ok()
|
||||
}
|
||||
|
||||
/// The browser has no folders to keep anything in.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn lock_disabled(_: &Path) -> Option<std::fs::File> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Remove `dir/file`; a file that isn't there is what was wanted.
|
||||
fn take(dir: &Path, file: &str) {
|
||||
let path = dir.join(file);
|
||||
@@ -223,10 +249,25 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the plug-ins this session installs in `dir`, and install the ones already there (a
|
||||
/// module that doesn't load, and one that lists permissions the grants there don't grant it,
|
||||
/// is skipped with a log record).
|
||||
/// Keep the plug-ins this session installs in `dir`, take the disabled plug-ins saved there
|
||||
/// ([`DISABLED_FILE`]), and install the stored ones ([`Session::load_stored_plugins`]).
|
||||
pub fn attach_plugin_store(&mut self, dir: PathBuf) {
|
||||
let disabled =
|
||||
read_capped(&dir.join(DISABLED_FILE), safe_mode::MAX_DISABLED_FILE_BYTES).map(|b| safe_mode::parse_disabled_file(&b)).unwrap_or_default();
|
||||
safe_mode::set_disabled(disabled);
|
||||
self.plugin_dir = Some(dir);
|
||||
self.load_stored_plugins();
|
||||
}
|
||||
|
||||
/// Install the plug-ins kept in the plug-in folder: none in Safe Mode, and none the user
|
||||
/// disabled (matched by file name, which is made from the id). The launch marker names each
|
||||
/// file while it loads. A module that doesn't load, and one that lists permissions the grants
|
||||
/// there don't grant it, is skipped with a log record.
|
||||
pub fn load_stored_plugins(&mut self) {
|
||||
let Some(dir) = self.plugin_dir.clone() else { return };
|
||||
if safe_mode::active() {
|
||||
return;
|
||||
}
|
||||
let grants = stored_grants(&dir);
|
||||
let limits = vectorcraft_plugins::Limits::default();
|
||||
let mut left = MAX_PLUGIN_STORE;
|
||||
@@ -235,10 +276,19 @@ impl Session {
|
||||
log::warn!("skipping the other stored plug-ins: they hold more than {MAX_PLUGIN_STORE} bytes together");
|
||||
break;
|
||||
}
|
||||
let item = safe_mode::Item::file(path.display().to_string());
|
||||
let stem = path.file_stem().map(|s| s.to_string_lossy().to_string());
|
||||
let by_id = safe_mode::skipped_ids().iter().any(|id| stem.as_deref() == Some(file_stem(id).as_str()));
|
||||
if safe_mode::skipped(&item) || by_id {
|
||||
log::info!("skipping the stored plug-in {}: disabled", path.display());
|
||||
continue;
|
||||
}
|
||||
let token = safe_mode::loading(&item);
|
||||
let loaded = read_capped(&path, (limits.max_module_bytes as u64).min(left)).and_then(|bytes| {
|
||||
left -= bytes.len() as u64;
|
||||
vectorcraft_plugins::Plugin::load(&bytes, limits.clone()).map_err(|e| e.to_string())
|
||||
});
|
||||
safe_mode::loaded(token);
|
||||
match loaded {
|
||||
// Only this store's grants count: one given elsewhere in the process doesn't let a
|
||||
// module stored here load.
|
||||
@@ -258,7 +308,40 @@ impl Session {
|
||||
Err(e) => log::warn!("skipping the stored plug-in {}: {e}", path.display()),
|
||||
}
|
||||
}
|
||||
self.plugin_dir = Some(dir);
|
||||
}
|
||||
|
||||
/// Write the disabled plug-ins saved for the next launch to the plug-in folder → whether they
|
||||
/// went there, every change included (`false` without a folder, and when a plug-in turned off
|
||||
/// found the file's list full: [`safe_mode::Merged::complete`]). The changes this launch made are applied to the list
|
||||
/// the file holds now ([`safe_mode::merge_saved`]), so a change another window saved to the same
|
||||
/// folder meanwhile stays. Windows that save at the same time take turns on [`DISABLED_LOCK`];
|
||||
/// when it can't be locked, nothing is written, and the next change saves this one too. So is
|
||||
/// a file that can't be read (too large, say) or that a later version wrote
|
||||
/// ([`safe_mode::read_disabled_file`]): it is kept as it is.
|
||||
pub fn store_disabled_plugins(&self) -> bool {
|
||||
let Some(dir) = self.plugin_dir.as_deref() else { return false };
|
||||
let Some(_turn) = lock_disabled(dir) else { return false };
|
||||
let path = dir.join(DISABLED_FILE);
|
||||
let theirs = match read_capped(&path, safe_mode::MAX_DISABLED_FILE_BYTES) {
|
||||
Ok(bytes) => match safe_mode::read_disabled_file(&bytes) {
|
||||
Some(items) => items,
|
||||
None => {
|
||||
log::warn!("can't save {}: it holds another format or version of the list", path.display());
|
||||
return false;
|
||||
}
|
||||
},
|
||||
Err(_) if !path.exists() => Vec::new(),
|
||||
Err(e) => {
|
||||
log::warn!("can't save {}: {e}", path.display());
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let merged = safe_mode::merge_saved(Some(theirs));
|
||||
let written = put(dir, DISABLED_FILE, &merged.bytes);
|
||||
if written {
|
||||
safe_mode::saved_written(&merged);
|
||||
}
|
||||
written && merged.complete()
|
||||
}
|
||||
|
||||
/// Whether `plugin` installs without asking the user: it lists no permissions, or its id was
|
||||
@@ -281,11 +364,16 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the module `bytes` of the plug-in `id` to the store.
|
||||
pub(crate) fn store_plugin(&self, id: &str, bytes: &[u8]) {
|
||||
if let Some(dir) = &self.plugin_dir {
|
||||
put(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id)), bytes);
|
||||
}
|
||||
/// Write the module `bytes` of the plug-in `id` to the store → whether they went there (`false`
|
||||
/// without a folder).
|
||||
pub(crate) fn store_plugin(&self, id: &str, bytes: &[u8]) -> bool {
|
||||
self.plugin_dir.as_deref().is_some_and(|dir| put(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id)), bytes))
|
||||
}
|
||||
|
||||
/// The file the plug-in `id` is stored in, spelled as [`Session::load_stored_plugins`] names it
|
||||
/// (`None` without a plug-in folder).
|
||||
pub(crate) fn stored_plugin_file(&self, id: &str) -> Option<String> {
|
||||
self.plugin_dir.as_ref().map(|dir| dir.join(format!("{}.{PLUGIN_EXT}", file_stem(id))).display().to_string())
|
||||
}
|
||||
|
||||
/// Record in the store what the user granted plug-in `id` (`None`: nothing), leaving the store's
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
//! Safe Mode and disabled plug-ins through the engine: `plugin.safeMode`, `plugin.setEnabled`, the
|
||||
//! plug-in folder skipping disabled plug-ins at the next launch, and the `plugin.*` commands in Safe
|
||||
//! Mode.
|
||||
//!
|
||||
//! A test binary of its own: Safe Mode and installed plug-ins are process-wide, so these tests run
|
||||
//! one at a time and no other test sees them.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_plugins::safe_mode;
|
||||
use vectorcraft_plugins::wat as tpl;
|
||||
|
||||
static LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Safe Mode off, nothing disabled or installed.
|
||||
fn reset() {
|
||||
safe_mode::set_active(None);
|
||||
safe_mode::set_disabled(vec![]);
|
||||
for p in vectorcraft_plugins::registry::list() {
|
||||
vectorcraft_plugins::registry::remove(p.id());
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds the lock for one test and resets the process-wide state when the test starts and when it
|
||||
/// ends, so a test that fails leaves the next one as it would find a new process.
|
||||
struct Fresh {
|
||||
_guard: std::sync::MutexGuard<'static, ()>,
|
||||
}
|
||||
|
||||
fn fresh() -> Fresh {
|
||||
let guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
reset();
|
||||
Fresh { _guard: guard }
|
||||
}
|
||||
|
||||
impl Drop for Fresh {
|
||||
fn drop(&mut self) {
|
||||
reset();
|
||||
}
|
||||
}
|
||||
|
||||
fn filter(id: &str) -> Vec<u8> {
|
||||
wat::parse_str(tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#)).unwrap()
|
||||
}
|
||||
|
||||
fn tmp_dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("vc-plugin-safe-mode-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn installed(id: &str) -> bool {
|
||||
vectorcraft_plugins::registry::get(id).is_some()
|
||||
}
|
||||
|
||||
/// A new launch: nothing installed, the plug-in folder `dir` attached.
|
||||
fn launch(dir: &std::path::Path) -> Session {
|
||||
for p in vectorcraft_plugins::registry::list() {
|
||||
vectorcraft_plugins::registry::remove(p.id());
|
||||
}
|
||||
let mut s = Session::new();
|
||||
s.attach_plugin_store(dir.to_path_buf());
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_disabled_plug_in_stays_installed_and_loads_again_once_enabled_at_the_next_launch() {
|
||||
let _f = fresh();
|
||||
let dir = tmp_dir("enable");
|
||||
let mut s = launch(&dir);
|
||||
for id in ["test.sm.x", "test.sm.y"] {
|
||||
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&filter(id))})).unwrap();
|
||||
}
|
||||
let r = s.execute("plugin.setEnabled", &json!({"id": "test.sm.x", "enabled": false})).unwrap();
|
||||
assert_eq!(r["changed"], true);
|
||||
assert_eq!(r["disabled"][0]["id"], "test.sm.x");
|
||||
assert!(installed("test.sm.x"), "disabling waits for the next launch");
|
||||
let again = s.execute("plugin.setEnabled", &json!({"id": "test.sm.x", "enabled": false})).unwrap();
|
||||
assert_eq!(again["changed"], false, "already disabled: nothing changes");
|
||||
|
||||
let mut s = launch(&dir);
|
||||
assert!(!installed("test.sm.x") && installed("test.sm.y"), "only the disabled one is skipped");
|
||||
assert!(dir.read_dir().unwrap().flatten().filter(|e| e.path().extension().is_some_and(|x| x == "wasm")).count() == 2, "nothing uninstalled");
|
||||
let list = s.execute("plugin.list", &json!({})).unwrap();
|
||||
assert_eq!((list["safeMode"].clone(), list["disabled"][0]["id"].clone()), (json!(false), json!("test.sm.x")));
|
||||
let state = s.execute("plugin.safeMode", &json!({})).unwrap();
|
||||
assert_eq!(state, json!({"active": false, "source": null, "pendingItem": null, "disabled": state["disabled"].clone()}));
|
||||
assert_eq!(state["disabled"].as_array().map(Vec::len), Some(1));
|
||||
|
||||
s.execute("plugin.setEnabled", &json!({"id": "test.sm.x", "enabled": true})).unwrap();
|
||||
assert!(!installed("test.sm.x"), "enabling waits for the next launch");
|
||||
launch(&dir);
|
||||
assert!(installed("test.sm.x") && installed("test.sm.y"));
|
||||
safe_mode::set_disabled(vec![]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Two windows share a plug-in folder: a plug-in the other one disabled after this one started stays
|
||||
/// disabled when this one saves its own changes.
|
||||
#[test]
|
||||
fn saving_the_disabled_list_keeps_what_another_window_saved_meanwhile() {
|
||||
let _f = fresh();
|
||||
let dir = tmp_dir("two-windows");
|
||||
let mut s = launch(&dir);
|
||||
s.execute("plugin.setEnabled", &json!({"id": "test.sm.a", "enabled": false})).unwrap();
|
||||
// The other window disabled b and saved its list.
|
||||
let other = |ids: &[&str]| {
|
||||
let items: Vec<Value> = ids.iter().map(|id| json!({"id": id})).collect();
|
||||
let file = json!({"format": "vectorcraft-disabled-plugins", "version": 1, "items": items});
|
||||
std::fs::write(dir.join(vectorcraft_engine::store::DISABLED_FILE), serde_json::to_vec(&file).unwrap()).unwrap();
|
||||
};
|
||||
other(&["test.sm.a", "test.sm.b"]);
|
||||
s.execute("plugin.setEnabled", &json!({"id": "test.sm.c", "enabled": false})).unwrap();
|
||||
s.execute("plugin.setEnabled", &json!({"id": "test.sm.a", "enabled": true})).unwrap();
|
||||
let saved = |s: &mut Session| -> Vec<String> {
|
||||
let state = s.execute("plugin.safeMode", &json!({})).unwrap();
|
||||
state["disabled"].as_array().unwrap().iter().map(|i| i["id"].as_str().unwrap().to_string()).collect()
|
||||
};
|
||||
assert_eq!(saved(&mut s), ["test.sm.b", "test.sm.c"], "b is kept, c added, and a turned on again");
|
||||
// The other window turns b back on; this window's next change keeps that too.
|
||||
other(&["test.sm.c"]);
|
||||
s.execute("plugin.setEnabled", &json!({"id": "test.sm.d", "enabled": false})).unwrap();
|
||||
assert_eq!(saved(&mut s), ["test.sm.c", "test.sm.d"]);
|
||||
let mut next = launch(&dir);
|
||||
assert_eq!(saved(&mut next), ["test.sm.c", "test.sm.d"], "the file holds the merged list");
|
||||
// While the lock file can't be locked (here a folder stands in its place), nothing is written;
|
||||
// the next change that is written carries this one too.
|
||||
let lock = dir.join(vectorcraft_engine::store::DISABLED_LOCK);
|
||||
std::fs::remove_file(&lock).unwrap();
|
||||
std::fs::create_dir_all(&lock).unwrap();
|
||||
let in_file = || -> Vec<String> {
|
||||
let bytes = std::fs::read(dir.join(vectorcraft_engine::store::DISABLED_FILE)).unwrap();
|
||||
safe_mode::parse_disabled_file(&bytes).into_iter().filter_map(|i| i.id).collect()
|
||||
};
|
||||
next.execute("plugin.setEnabled", &json!({"id": "test.sm.e", "enabled": false})).unwrap();
|
||||
assert_eq!(in_file(), ["test.sm.c", "test.sm.d"]);
|
||||
std::fs::remove_dir(&lock).unwrap();
|
||||
next.execute("plugin.setEnabled", &json!({"id": "test.sm.f", "enabled": false})).unwrap();
|
||||
assert_eq!(in_file(), ["test.sm.c", "test.sm.d", "test.sm.e", "test.sm.f"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_list_of_long_disabled_items_is_read_back_at_the_next_launch() {
|
||||
let _f = fresh();
|
||||
let dir = tmp_dir("full-list");
|
||||
let field = |i: usize, tag: &str| format!("{tag}{i:04}{}", "\u{1}".repeat(1024));
|
||||
let items: Vec<safe_mode::Item> = (0..safe_mode::MAX_DISABLED)
|
||||
.map(|i| safe_mode::Item { id: Some(field(i, "i")), file: Some(field(i, "f")), name: Some(field(i, "n")) })
|
||||
.collect();
|
||||
safe_mode::set_disabled(items);
|
||||
let saved = safe_mode::disabled();
|
||||
std::fs::write(dir.join(vectorcraft_engine::store::DISABLED_FILE), safe_mode::disabled_file()).unwrap();
|
||||
safe_mode::set_disabled(vec![]);
|
||||
launch(&dir);
|
||||
assert_eq!(safe_mode::disabled(), saved, "every disabled item stays disabled");
|
||||
safe_mode::set_disabled(vec![]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_plug_in_disabled_by_id_is_saved_with_its_file() {
|
||||
let _f = fresh();
|
||||
let dir = tmp_dir("by-id");
|
||||
let extra = tmp_dir("by-id-extra");
|
||||
let f = extra.join("f.wasm");
|
||||
std::fs::write(&f, filter("test.sm.f")).unwrap();
|
||||
let mut s = launch(&dir);
|
||||
s.execute("plugin.setEnabled", &json!({"id": "test.sm.f", "enabled": false})).unwrap();
|
||||
let mut s = launch(&dir);
|
||||
let r = s.execute("plugin.reload", &json!({"path": extra.to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["failed"][0]["error"], vectorcraft_plugins::registry::DISABLED);
|
||||
safe_mode::set_disabled(vec![]);
|
||||
launch(&dir);
|
||||
assert_eq!(safe_mode::disabled()[0].file.as_deref(), Some(f.display().to_string().as_str()), "the next launch skips the file before it loads");
|
||||
safe_mode::set_disabled(vec![]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _ = std::fs::remove_dir_all(&extra);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installing_a_disabled_plug_in_again_turns_it_on() {
|
||||
let _f = fresh();
|
||||
let dir = tmp_dir("reinstall");
|
||||
let mut s = launch(&dir);
|
||||
s.execute("plugin.setEnabled", &json!({"id": "test.sm.r", "enabled": false})).unwrap();
|
||||
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&filter("test.sm.r"))})).unwrap();
|
||||
assert!(safe_mode::disabled().is_empty());
|
||||
launch(&dir);
|
||||
assert!(installed("test.sm.r"));
|
||||
safe_mode::set_disabled(vec![]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A launch where the stored plug-in `id` stopped while loading: it is installed, then disabled by
|
||||
/// its stored file alone (as Disable and Continue saves it), and the next launch skips it.
|
||||
fn stored_plug_in_disabled_by_file(dir: &std::path::Path, id: &str) -> Session {
|
||||
let mut s = launch(dir);
|
||||
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&filter(id))})).unwrap();
|
||||
let stored = dir.read_dir().unwrap().flatten().map(|e| e.path()).find(|p| p.extension().is_some_and(|x| x == "wasm")).unwrap();
|
||||
s.execute("plugin.setEnabled", &json!({"file": stored.display().to_string(), "enabled": false})).unwrap();
|
||||
let s = launch(dir);
|
||||
assert!(!installed(id), "the next launch skips the file");
|
||||
assert_eq!(safe_mode::disabled()[0].id, None);
|
||||
s
|
||||
}
|
||||
|
||||
fn nothing_disabled(s: &mut Session, dir: &std::path::Path) {
|
||||
assert_eq!(s.execute("plugin.safeMode", &json!({})).unwrap()["disabled"], json!([]));
|
||||
let saved = std::fs::read(dir.join(vectorcraft_engine::store::DISABLED_FILE)).unwrap();
|
||||
assert!(safe_mode::parse_disabled_file(&saved).is_empty(), "nothing disabled is saved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installing_a_stored_plug_in_disabled_by_its_file_turns_it_on() {
|
||||
let _f = fresh();
|
||||
let dir = tmp_dir("reinstall-file");
|
||||
let mut s = stored_plug_in_disabled_by_file(&dir, "test.sm.p");
|
||||
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&filter("test.sm.p"))})).unwrap();
|
||||
nothing_disabled(&mut s, &dir);
|
||||
launch(&dir);
|
||||
assert!(installed("test.sm.p"));
|
||||
safe_mode::set_disabled(vec![]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stored_plug_in_disabled_by_its_file_stays_disabled_when_its_update_cannot_be_saved() {
|
||||
let _f = fresh();
|
||||
let dir = tmp_dir("reinstall-unsaved");
|
||||
let mut s = stored_plug_in_disabled_by_file(&dir, "test.sm.u");
|
||||
let stored = PathBuf::from(safe_mode::disabled()[0].file.clone().unwrap());
|
||||
std::fs::remove_file(&stored).unwrap();
|
||||
std::fs::create_dir_all(stored.join("block")).unwrap();
|
||||
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&filter("test.sm.u"))})).unwrap();
|
||||
assert_eq!(safe_mode::disabled().len(), 1, "the file the update could not replace stays disabled");
|
||||
safe_mode::set_disabled(vec![]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabling_a_stored_plug_in_disabled_by_its_file_by_its_id_turns_it_on() {
|
||||
let _f = fresh();
|
||||
let dir = tmp_dir("enable-file");
|
||||
let mut s = stored_plug_in_disabled_by_file(&dir, "test.sm.q");
|
||||
let r = s.execute("plugin.setEnabled", &json!({"id": "test.sm.q", "enabled": true})).unwrap();
|
||||
assert_eq!(r["changed"], true);
|
||||
nothing_disabled(&mut s, &dir);
|
||||
launch(&dir);
|
||||
assert!(installed("test.sm.q"));
|
||||
safe_mode::set_disabled(vec![]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_enabled_rejects_bad_parameters() {
|
||||
let _f = fresh();
|
||||
safe_mode::set_disabled(vec![]);
|
||||
let mut s = Session::new();
|
||||
let err = |s: &mut Session, p: Value| s.execute("plugin.setEnabled", &p).unwrap_err().to_string();
|
||||
assert!(err(&mut s, json!({"id": "a"})).contains("`enabled` must be true or false"));
|
||||
assert!(err(&mut s, json!({"id": "a", "enabled": "yes"})).contains("`enabled` must be true or false"));
|
||||
assert!(err(&mut s, json!({"id": 3, "enabled": false})).contains("`id` must be a string"));
|
||||
assert!(err(&mut s, json!({"enabled": false})).contains("give `id` or `file`"));
|
||||
assert!(err(&mut s, json!({"id": " ", "enabled": false})).contains("give `id` or `file`"));
|
||||
assert!(err(&mut s, json!({"id": "a", "enabled": true})).contains("isn't disabled"));
|
||||
assert!(safe_mode::disabled().is_empty(), "nothing changed");
|
||||
let r = s.execute("plugin.setEnabled", &json!({"file": "/p/a.wasm", "enabled": false})).unwrap();
|
||||
assert_eq!(
|
||||
(r["changed"].clone(), r["disabled"][0]["file"].clone(), r["disabled"][0]["label"].clone()),
|
||||
(json!(true), json!("/p/a.wasm"), json!("a.wasm"))
|
||||
);
|
||||
assert_eq!(r["saved"], false, "a session without a plug-in folder can't keep the change for the next launch");
|
||||
safe_mode::set_disabled(vec![]);
|
||||
}
|
||||
|
||||
/// A disabled list too large to read, or one of a later version, stays as it is: the changes wait
|
||||
/// for a save that can merge them. A file that isn't JSON holds nothing to keep and is replaced.
|
||||
#[test]
|
||||
fn a_disabled_list_this_version_cannot_read_is_kept() {
|
||||
let _f = fresh();
|
||||
let dir = tmp_dir("kept");
|
||||
let file = dir.join(vectorcraft_engine::store::DISABLED_FILE);
|
||||
let mut s = launch(&dir);
|
||||
let later = serde_json::to_vec(&json!({"format": "vectorcraft-disabled-plugins", "version": 2, "items": [{"id": "test.sm.k"}]})).unwrap();
|
||||
let large = vec![b' '; safe_mode::MAX_DISABLED_FILE_BYTES as usize + 1];
|
||||
for (kept, id) in [(later, "test.sm.a"), (large, "test.sm.b")] {
|
||||
std::fs::write(&file, &kept).unwrap();
|
||||
let r = s.execute("plugin.setEnabled", &json!({"id": id, "enabled": false})).unwrap();
|
||||
assert_eq!((r["changed"].clone(), r["saved"].clone()), (json!(true), json!(false)), "{id}");
|
||||
assert!(std::fs::read(&file).unwrap() == kept, "{id}: the file is kept");
|
||||
}
|
||||
std::fs::write(&file, b"{\"format\": \"vectorcraft-disab").unwrap();
|
||||
let r = s.execute("plugin.setEnabled", &json!({"id": "test.sm.c", "enabled": false})).unwrap();
|
||||
assert_eq!(r["saved"], true);
|
||||
let ids: Vec<String> = safe_mode::parse_disabled_file(&std::fs::read(&file).unwrap()).into_iter().filter_map(|i| i.id).collect();
|
||||
assert_eq!(ids, ["test.sm.a", "test.sm.b", "test.sm.c"], "the waiting changes are saved too");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Another window filled the list after this one read it: a plug-in turned off here has no room in
|
||||
/// the file, and `saved` says so.
|
||||
#[test]
|
||||
fn a_disable_that_finds_the_saved_list_full_is_not_saved() {
|
||||
let _f = fresh();
|
||||
let dir = tmp_dir("full-merge");
|
||||
let mut s = launch(&dir);
|
||||
let items: Vec<Value> = (0..safe_mode::MAX_DISABLED).map(|i| json!({"id": format!("test.sm.full{i}")})).collect();
|
||||
let file = json!({"format": "vectorcraft-disabled-plugins", "version": 1, "items": items});
|
||||
std::fs::write(dir.join(vectorcraft_engine::store::DISABLED_FILE), serde_json::to_vec(&file).unwrap()).unwrap();
|
||||
let r = s.execute("plugin.setEnabled", &json!({"id": "test.sm.late", "enabled": false})).unwrap();
|
||||
assert_eq!((r["changed"].clone(), r["saved"].clone()), (json!(true), json!(false)));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_safe_mode_the_plug_in_folder_and_commands_load_and_run_nothing() {
|
||||
let _f = fresh();
|
||||
let dir = tmp_dir("safe");
|
||||
let mut s = launch(&dir);
|
||||
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&filter("test.sm.s"))})).unwrap();
|
||||
let extra = tmp_dir("safe-extra");
|
||||
std::fs::write(extra.join("e.wasm"), filter("test.sm.e")).unwrap();
|
||||
|
||||
vectorcraft_engine::cmd::plugin::start_in_safe_mode();
|
||||
let mut s = launch(&dir);
|
||||
assert!(!installed("test.sm.s"), "stored plug-ins don't load");
|
||||
let safe = "Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally";
|
||||
let install = s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&filter("test.sm.t"))}));
|
||||
assert!(install.unwrap_err().to_string().contains(safe));
|
||||
let reload = s.execute("plugin.reload", &json!({"path": extra.to_string_lossy()}));
|
||||
assert!(reload.unwrap_err().to_string().contains(safe));
|
||||
assert!(!installed("test.sm.e") && !installed("test.sm.t"));
|
||||
let state = s.execute("plugin.safeMode", &json!({})).unwrap();
|
||||
assert_eq!((state["active"].clone(), state["source"].clone()), (json!(true), json!("flag")));
|
||||
assert_eq!(s.execute("plugin.list", &json!({})).unwrap()["safeMode"], true);
|
||||
|
||||
// Leaving the choice after a launch that stopped with Start Normally loads them.
|
||||
safe_mode::set_active(None);
|
||||
vectorcraft_engine::cmd::plugin::load_prefs_folder(&mut s);
|
||||
assert!(installed("test.sm.s"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _ = std::fs::remove_dir_all(&extra);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_effect_skipped_in_safe_mode_stays_on_its_object_and_says_so() {
|
||||
let _f = fresh();
|
||||
let mut s = Session::new();
|
||||
let fx = wat::parse_str(tpl::returning(&tpl::manifest("test.sm.fx", "effect", "{}"), r#"{"objects":[]}"#)).unwrap();
|
||||
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&fx)})).unwrap();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "plugin.test.sm.fx"})).unwrap();
|
||||
let effect = |s: &mut Session| s.execute("effect.list", &json!({})).unwrap()["applied"][0]["effects"][0].clone();
|
||||
assert_eq!((effect(&mut s)["id"].clone(), effect(&mut s).get("skipped").cloned()), (json!("plugin.test.sm.fx"), None));
|
||||
|
||||
safe_mode::set_active(Some(safe_mode::Source::Flag));
|
||||
let skipped = effect(&mut s);
|
||||
assert_eq!((skipped["id"].clone(), skipped["skipped"].clone()), (json!("plugin.test.sm.fx"), json!(true)), "the object keeps it");
|
||||
|
||||
safe_mode::set_active(None);
|
||||
assert_eq!(effect(&mut s).get("skipped"), None);
|
||||
}
|
||||
|
||||
/// In Safe Mode, Expand Appearance refuses an object whose plug-in effect it would drop, its own or
|
||||
/// in a symbol's art it places, and changes nothing.
|
||||
#[test]
|
||||
fn expand_appearance_keeps_a_plug_in_effect_safe_mode_skips() {
|
||||
let _f = fresh();
|
||||
let mut s = Session::new();
|
||||
let fx = wat::parse_str(tpl::returning(&tpl::manifest("test.sm.xp", "effect", "{}"), r#"{"objects":[]}"#)).unwrap();
|
||||
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&fx)})).unwrap();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "plugin.test.sm.xp"})).unwrap();
|
||||
let doc = |s: &mut Session| s.execute("document.inspect", &json!({})).unwrap();
|
||||
let refused = |s: &mut Session| {
|
||||
let before = doc(s);
|
||||
let err = s.execute("effect.expandAppearance", &json!({})).unwrap_err().to_string();
|
||||
assert!(err.contains("a plug-in effect in the selection can't be expanded in Safe Mode"), "{err}");
|
||||
assert_eq!(doc(s), before, "nothing changes");
|
||||
};
|
||||
safe_mode::set_active(Some(safe_mode::Source::Flag));
|
||||
refused(&mut s);
|
||||
// An instance, with an effect of its own, of a symbol whose art holds the plug-in effect.
|
||||
safe_mode::set_active(None);
|
||||
s.execute("symbol.new", &json!({"name": "fx"})).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "distort.puckerBloat"})).unwrap();
|
||||
safe_mode::set_active(Some(safe_mode::Source::Flag));
|
||||
refused(&mut s);
|
||||
safe_mode::set_active(None);
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
// A plain instance of that symbol doesn't expand: selected with an object that does, they expand.
|
||||
let plain = s.execute("symbol.place", &json!({"name": "fx", "x": 150, "y": 150})).unwrap()["id"].clone();
|
||||
let other = s.execute("shape.rectangle", &json!({"x": 10, "y": 120, "width": 30, "height": 30})).unwrap()["id"].clone();
|
||||
s.execute("effect.apply", &json!({"effect": "distort.puckerBloat"})).unwrap();
|
||||
safe_mode::set_active(Some(safe_mode::Source::Flag));
|
||||
s.execute("effect.expandAppearance", &json!({"ids": [plain, other]})).unwrap();
|
||||
safe_mode::set_active(None);
|
||||
// A plug-in effect on a hidden fill: Expand Appearance drops that fill, so it expands.
|
||||
s.execute("shape.rectangle", &json!({"x": 100, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "plugin.test.sm.xp", "item": 0})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"index": 0, "visible": false})).unwrap();
|
||||
safe_mode::set_active(Some(safe_mode::Source::Flag));
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
}
|
||||
@@ -44,6 +44,12 @@ pub fn effect_id(plugin_id: &str) -> String {
|
||||
format!("{PREFIX}{plugin_id}")
|
||||
}
|
||||
|
||||
/// Does Safe Mode skip effect `effect_id` (a plug-in effect while Safe Mode is on)? Its object then
|
||||
/// draws its geometry without it, and keeps the effect.
|
||||
pub fn skipped(effect_id: &str) -> bool {
|
||||
plugin_id(effect_id).is_some() && crate::safe_mode::active()
|
||||
}
|
||||
|
||||
/// An installed live-effect plug-in by its effect id.
|
||||
pub fn installed(effect_id: &str) -> Option<std::sync::Arc<crate::Plugin>> {
|
||||
registry::get(plugin_id(effect_id)?).filter(|p| p.manifest().kind == Kind::Effect)
|
||||
@@ -132,6 +138,57 @@ fn weight(key: &Key, out: Option<&PathData>) -> usize {
|
||||
}
|
||||
static ERRORS: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
|
||||
|
||||
/// Where an installation ([`crate::Plugin::serial`]) stands as a live effect in this process.
|
||||
enum Runs {
|
||||
/// Its first run is in progress, or a run that started while a guarded run was in progress:
|
||||
/// how many of them are running, whether one has finished (wasn't cancelled), and the launch
|
||||
/// marker's entry naming the plug-in until none is running.
|
||||
Guarded { running: usize, finished: bool, loading: crate::safe_mode::Loading },
|
||||
/// A guarded run finished and none is running: later runs aren't guarded.
|
||||
Done,
|
||||
}
|
||||
|
||||
static RUNS: Mutex<Option<HashMap<u64, Runs>>> = Mutex::new(None);
|
||||
|
||||
/// A run of `plugin` starts → is it guarded (the launch marker names the plug-in while it runs)?
|
||||
/// The first run in this process is, and so is every run that starts while a guarded one is still
|
||||
/// running. Give a guarded run to [`end_run`] when it ends.
|
||||
fn begin_run(plugin: &crate::Plugin) -> bool {
|
||||
let mut runs = RUNS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let runs = runs.get_or_insert_with(HashMap::new);
|
||||
match runs.get_mut(&plugin.serial()) {
|
||||
Some(Runs::Done) => false,
|
||||
Some(Runs::Guarded { running, .. }) => {
|
||||
*running += 1;
|
||||
true
|
||||
}
|
||||
None => {
|
||||
let loading = crate::safe_mode::loading(&crate::safe_mode::Item::of(plugin));
|
||||
runs.insert(plugin.serial(), Runs::Guarded { running: 1, finished: false, loading });
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A guarded run of installation `serial` ended; `cancelled`: a drawing stopped it (an export
|
||||
/// stopped half way), which says nothing about the plug-in. When it was the last guarded run still
|
||||
/// running, the launch marker stops naming the plug-in, and when none of the guarded runs finished,
|
||||
/// the next run is guarded as the first.
|
||||
fn end_run(serial: u64, cancelled: bool) {
|
||||
let mut runs = RUNS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let Some(runs) = runs.as_mut() else { return };
|
||||
let Some(Runs::Guarded { running, finished, .. }) = runs.get_mut(&serial) else { return };
|
||||
*running = running.saturating_sub(1);
|
||||
*finished |= !cancelled;
|
||||
if *running > 0 {
|
||||
return;
|
||||
}
|
||||
let ended = if *finished { runs.insert(serial, Runs::Done) } else { runs.remove(&serial) };
|
||||
if let Some(Runs::Guarded { loading, .. }) = ended {
|
||||
crate::safe_mode::loaded(loading);
|
||||
}
|
||||
}
|
||||
|
||||
/// The last error of live-effect plug-in `id` (cleared when it next succeeds).
|
||||
pub fn last_error(id: &str) -> Option<String> {
|
||||
ERRORS.lock().unwrap_or_else(|e| e.into_inner()).as_ref()?.get(id).cloned()
|
||||
@@ -147,9 +204,14 @@ fn note(id: &str, r: &Result<PathData>) {
|
||||
}
|
||||
|
||||
/// Runs live-effect plug-in `id` on `path` (whose reference box is `bounds`), of an object painted
|
||||
/// with `paints`: the new geometry, or `None` when the plug-in isn't installed, isn't an effect, or
|
||||
/// fails (the caller then keeps the geometry).
|
||||
/// with `paints`: the new geometry, or `None` when the plug-in isn't installed, isn't an effect,
|
||||
/// fails, or Safe Mode is on (the caller then keeps the geometry, and the object keeps its effect).
|
||||
/// The launch marker names the plug-in during its first run in this process, and until every run
|
||||
/// that started while it ran has ended too ([`crate::safe_mode::loading`]).
|
||||
pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillRule, paints: Paints) -> Option<PathData> {
|
||||
if crate::safe_mode::active() {
|
||||
return None;
|
||||
}
|
||||
let plugin = registry::get(id).filter(|p| p.manifest().kind == Kind::Effect)?;
|
||||
let resolved = Value::Object(plugin.manifest().resolve_params(params).ok()?);
|
||||
let given = paints.given();
|
||||
@@ -167,10 +229,14 @@ pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: Fill
|
||||
{
|
||||
return out.clone();
|
||||
}
|
||||
let guarded = begin_run(&plugin);
|
||||
let r = run(&plugin, resolved, path, bounds, rule, given);
|
||||
// A drawing that was cancelled (an export stopped half way) says nothing about the plug-in:
|
||||
// the next drawing runs it again.
|
||||
if matches!(r, Err(Error::Cancelled)) {
|
||||
let cancelled = matches!(r, Err(Error::Cancelled));
|
||||
if guarded {
|
||||
end_run(plugin.serial(), cancelled);
|
||||
}
|
||||
// A drawing that was cancelled says nothing about the plug-in: the next drawing runs it again.
|
||||
if cancelled {
|
||||
return None;
|
||||
}
|
||||
note(id, &r);
|
||||
@@ -232,4 +298,66 @@ mod tests {
|
||||
let none = Paints::default().given();
|
||||
assert_eq!((none.fills, none.strokes, none.item), (vec![], vec![], json!("object")));
|
||||
}
|
||||
|
||||
/// A first run that is cancelled leaves the next run to be the first: the launch marker names
|
||||
/// the plug-in during that one.
|
||||
#[test]
|
||||
fn a_cancelled_first_run_leaves_the_next_run_first() {
|
||||
let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::safe_mode::set_active(None);
|
||||
let src = crate::wat::spinning(&crate::wat::manifest("test.effect.cancelled", "effect", "{}"), 1_000_000_000, r#"{"objects":[]}"#);
|
||||
let plugin = registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap());
|
||||
let ran_first =
|
||||
|| RUNS.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|r| matches!(r.get(&plugin.serial()), Some(Runs::Done)));
|
||||
let draw =
|
||||
|| apply("test.effect.cancelled", &json!({}), &PathData::default(), Rect::new(0.0, 0.0, 1.0, 1.0), FillRule::NonZero, Paints::default());
|
||||
let token = vectorcraft_doc::cancel::CancelToken::new();
|
||||
token.cancel();
|
||||
assert_eq!(vectorcraft_doc::cancel::scope(&token, None, draw), None);
|
||||
assert!(!ran_first(), "the cancelled run doesn't count as the first");
|
||||
// Runs to its instruction budget and fails: that run was the first.
|
||||
assert_eq!(draw(), None);
|
||||
assert!(ran_first());
|
||||
registry::remove("test.effect.cancelled");
|
||||
}
|
||||
|
||||
/// Two runs overlap during a plug-in's first run in this process: when the first ends while the
|
||||
/// second is still running, the launch marker keeps naming the plug-in until the second ends.
|
||||
#[test]
|
||||
fn the_marker_stays_while_an_overlapping_run_is_running() {
|
||||
let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::safe_mode::set_active(None);
|
||||
let dir = std::env::temp_dir().join(format!("vc-effect-overlap-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert_eq!(crate::safe_mode::begin_launch(&dir), None);
|
||||
let marker = || {
|
||||
let files = std::fs::read_dir(&dir).unwrap().filter_map(|e| Some(e.ok()?.path()));
|
||||
let json = files.filter(|p| p.extension().is_some_and(|x| x == "json")).collect::<Vec<_>>();
|
||||
assert_eq!(json.len(), 1, "one marker for this launch");
|
||||
crate::safe_mode::parse_marker(&std::fs::read(&json[0]).unwrap())
|
||||
};
|
||||
let src = crate::wat::returning(&crate::wat::manifest("test.effect.overlap", "effect", "{}"), r#"{"objects":[]}"#);
|
||||
let plugin = registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap());
|
||||
let named = Some(crate::safe_mode::Item::of(&plugin));
|
||||
assert_eq!(marker(), None);
|
||||
|
||||
assert!(begin_run(&plugin), "the first run is guarded");
|
||||
assert!(begin_run(&plugin), "a run that starts while the first is running is guarded");
|
||||
assert_eq!(marker(), named);
|
||||
end_run(plugin.serial(), false);
|
||||
assert_eq!(marker(), named, "the second run is still running");
|
||||
// A third run starts before the second ends, and the second is cancelled.
|
||||
assert!(begin_run(&plugin));
|
||||
end_run(plugin.serial(), true);
|
||||
assert_eq!(marker(), named, "the third run is still running");
|
||||
end_run(plugin.serial(), false);
|
||||
assert_eq!(marker(), None, "no run is running");
|
||||
assert!(!begin_run(&plugin), "later runs aren't guarded");
|
||||
assert_eq!(marker(), None);
|
||||
|
||||
crate::safe_mode::end_launch();
|
||||
crate::safe_mode::forget_previous();
|
||||
registry::remove("test.effect.overlap");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
//! ([`objects::apply_output`]); a **live effect** ([`effect`]) rewrites one object's geometry each
|
||||
//! time it is drawn, from an appearance stack; an **exporter** ([`export`]) writes the whole
|
||||
//! document in a file format of its own. The process-wide [`registry`] holds the installed
|
||||
//! plug-ins. A plug-in's parameters ([`manifest`]) come with what the host's dialogs need to show
|
||||
//! plug-ins; in [`safe_mode`] none of them loads or runs. A plug-in's parameters ([`manifest`]) come with what the host's dialogs need to show
|
||||
//! them well: labels, units, groups, help and conditions ([`dialog`]). What a plug-in may do is
|
||||
//! the set of [`permissions`] its manifest lists, which the user approves when it is installed.
|
||||
#![forbid(unsafe_code)]
|
||||
@@ -35,6 +35,7 @@ pub mod objects;
|
||||
pub mod permissions;
|
||||
pub mod registry;
|
||||
mod runtime;
|
||||
pub mod safe_mode;
|
||||
pub mod wat;
|
||||
|
||||
pub use dialog::ParamUi;
|
||||
@@ -71,6 +72,9 @@ pub enum Error {
|
||||
/// its instruction budget.
|
||||
#[error("cancelled")]
|
||||
Cancelled,
|
||||
/// Safe Mode is on ([`safe_mode`]): no plug-in loads or runs.
|
||||
#[error("Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally")]
|
||||
SafeMode,
|
||||
#[error("{0}")]
|
||||
Io(String),
|
||||
}
|
||||
|
||||
@@ -113,6 +113,9 @@ pub fn load_file(path: &std::path::Path, limits: Limits) -> Result<Plugin> {
|
||||
Ok(Plugin::load(&bytes, limits)?.with_source(path.display().to_string()))
|
||||
}
|
||||
|
||||
/// What [`load_folder`] reports for a plug-in the user disabled.
|
||||
pub const DISABLED: &str = "disabled: turn it on again in Help › Safe Mode, for the next launch";
|
||||
|
||||
/// What [`load_folder`] did.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct FolderReport {
|
||||
@@ -120,6 +123,10 @@ pub struct FolderReport {
|
||||
pub loaded: Vec<String>,
|
||||
/// `(file, error)` for each `.wasm` that failed to load.
|
||||
pub failed: Vec<(String, String)>,
|
||||
/// The disabled items saved for the next launch changed: a plug-in disabled by its id alone was
|
||||
/// found in a file, which is now disabled too ([`crate::safe_mode::disable_file_of`]). The app
|
||||
/// saves the list again.
|
||||
pub disabled_changed: bool,
|
||||
}
|
||||
|
||||
/// Installs every `*.wasm` directly in `dir` (not recursive), in file-name order. A bad module is
|
||||
@@ -132,13 +139,18 @@ pub fn load_folder(dir: &std::path::Path) -> Result<FolderReport> {
|
||||
}
|
||||
|
||||
/// [`load_folder`], loading only the files `allow` accepts, and of the plug-ins that list
|
||||
/// permissions only those `approve` accepts; the others are reported as failed with the reason.
|
||||
/// permissions only those `approve` accepts; the others are reported as failed with the reason. A plug-in the user disabled ([`crate::safe_mode::skipped`]) is reported and
|
||||
/// skipped, by its file before it loads or by its id after (its file is then disabled too, so the
|
||||
/// next load skips it before it runs); the launch marker names each file while it loads
|
||||
/// ([`crate::safe_mode::loading`]). In Safe Mode nothing loads ([`Error::SafeMode`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn load_folder_where(
|
||||
dir: &std::path::Path,
|
||||
allow: impl Fn(&std::path::Path) -> std::result::Result<(), String>,
|
||||
approve: impl Fn(&Plugin) -> bool,
|
||||
) -> Result<FolderReport> {
|
||||
use crate::safe_mode::{self, Item};
|
||||
safe_mode::check()?;
|
||||
let rd = std::fs::read_dir(dir).map_err(|e| Error::Io(format!("{}: {e}", dir.display())))?;
|
||||
let mut files: Vec<std::path::PathBuf> =
|
||||
rd.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case("wasm"))).collect();
|
||||
@@ -150,7 +162,19 @@ pub fn load_folder_where(
|
||||
report.failed.push((f.display().to_string(), why));
|
||||
continue;
|
||||
}
|
||||
match load_file(&f, Limits::default()) {
|
||||
let item = Item::file(f.display().to_string());
|
||||
if safe_mode::skipped(&item) {
|
||||
report.failed.push((f.display().to_string(), DISABLED.into()));
|
||||
continue;
|
||||
}
|
||||
let token = safe_mode::loading(&item);
|
||||
let loaded = load_file(&f, Limits::default());
|
||||
safe_mode::loaded(token);
|
||||
match loaded {
|
||||
Ok(p) if safe_mode::skipped(&Item::of(&p)) => {
|
||||
report.disabled_changed |= safe_mode::disable_file_of(&Item::of(&p));
|
||||
report.failed.push((f.display().to_string(), DISABLED.into()));
|
||||
}
|
||||
// A heavy plug-in installs once the user agrees, which loading a folder doesn't ask.
|
||||
Ok(p) if p.manifest().budget == crate::Budget::Heavy => {
|
||||
report.failed.push((f.display().to_string(), "asks for a heavy budget, which the user allows when installing it on its own".into()));
|
||||
|
||||
@@ -133,8 +133,9 @@ type RunArgs = (i32, i32, i32, i32);
|
||||
impl Plugin {
|
||||
/// Validates and loads a module: checks the ABI version, the exports and reads the manifest, and
|
||||
/// checks that each host import it asks for is one the host offers ([`crate::host::IMPORTS`])
|
||||
/// and its manifest allows.
|
||||
/// and its manifest allows. In Safe Mode nothing is loaded ([`Error::SafeMode`]).
|
||||
pub fn load(bytes: &[u8], limits: Limits) -> Result<Plugin> {
|
||||
crate::safe_mode::check()?;
|
||||
if bytes.len() > limits.max_module_bytes {
|
||||
return Err(Error::Module(format!("module is {} bytes (limit {})", bytes.len(), limits.max_module_bytes)));
|
||||
}
|
||||
@@ -279,7 +280,8 @@ impl Plugin {
|
||||
self.run_limited(input, params, &self.limits)
|
||||
}
|
||||
|
||||
/// [`Plugin::run`] with other time and instruction budgets (live effects get a shorter one).
|
||||
/// [`Plugin::run`] with other time and instruction budgets (live effects get a shorter one). In
|
||||
/// Safe Mode nothing runs ([`Error::SafeMode`]), even a plug-in installed before it was on.
|
||||
pub fn run_limited(&self, input: &[u8], params: &[u8], limits: &Limits) -> Result<Vec<u8>> {
|
||||
self.run_reporting(input, params, limits).0
|
||||
}
|
||||
@@ -290,6 +292,9 @@ impl Plugin {
|
||||
/// doesn't) and `vc_cancelled` sees the scope's Cancel.
|
||||
pub fn run_reporting(&self, input: &[u8], params: &[u8], limits: &Limits) -> (Result<Vec<u8>>, Report) {
|
||||
let mut report = Report::default();
|
||||
if let Err(e) = crate::safe_mode::check() {
|
||||
return (Err(e), report);
|
||||
}
|
||||
// wasmi reports misbehaving modules as errors; a panic would be a bug in it, and must
|
||||
// still not take the document down (native builds; wasm aborts on panic anyway).
|
||||
let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.run_inner(input, params, limits, &mut report)));
|
||||
|
||||
@@ -0,0 +1,927 @@
|
||||
//! Safe Mode, disabled plug-ins and the launch marker.
|
||||
//!
|
||||
//! **Safe Mode**: while it is on, no plug-in code runs in this process. [`crate::Plugin::load`] and
|
||||
//! every run of an installed plug-in fail with [`Error::SafeMode`], so a plug-in installed before
|
||||
//! Safe Mode was turned on can't run either, and live effects draw their object's geometry
|
||||
//! unchanged. The apps turn it on at launch (`--safe-mode`, or the choice after a launch that
|
||||
//! stopped while a plug-in was loading) and off only when the user starts normally from that
|
||||
//! choice.
|
||||
//!
|
||||
//! **Disabled plug-ins**: the items the user turned off. Loading skips them by file or by id
|
||||
//! ([`skipped`]); nothing is uninstalled. [`set_disabled`] sets both the list this launch skips and
|
||||
//! the list saved for the next one; [`set_enabled`] changes only the saved list, so turning an item
|
||||
//! back on takes effect at the next launch.
|
||||
//!
|
||||
//! **Launch marker** (native builds): a small file in a folder the app picks, written before the
|
||||
//! stored plug-ins load, updated before and after each module load and each live effect's first
|
||||
//! evaluation (and each evaluation that starts while one of those is running) to name the oldest
|
||||
//! item still loading (several threads can load at once), and removed
|
||||
//! when the app quits ([`begin_launch`], [`loading`], [`loaded`], [`end_launch`]). A launch that
|
||||
//! ends while an item is still loading (a panic the app caught while a plug-in loaded) keeps its
|
||||
//! marker naming that item. A marker left behind by a
|
||||
//! launch that is no longer running names the item that was loading when it stopped, which may or
|
||||
//! may not be what stopped it. Until the user
|
||||
//! chooses what to do about that item ([`forget_previous`]), the next launch's marker names it too
|
||||
//! and stays when that launch quits, so a launch that asks nothing (`--safe-mode`) or one closed in
|
||||
//! Safe Mode leaves the choice to the launch after it. Each launch has
|
||||
//! its own marker and holds a lock on a file beside it, so a launch that is still running (a second
|
||||
//! window) is never taken for one that stopped. A marker that is missing, empty, unreadable,
|
||||
//! malformed, larger than [`MAX_MARKER_BYTES`] or of another version names nothing.
|
||||
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use std::sync::{Mutex, RwLock};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// The longest id, file or name a stored item keeps, in characters.
|
||||
const MAX_FIELD_CHARS: usize = 1024;
|
||||
/// The most bytes one stored item takes in JSON: three fields of [`MAX_FIELD_CHARS`] characters,
|
||||
/// each written in at most 6 bytes (`\u001f`), plus keys, quotes and indentation.
|
||||
const MAX_ITEM_JSON_BYTES: usize = 3 * MAX_FIELD_CHARS * 6 + 256;
|
||||
/// The largest launch marker read, in bytes: room for one stored item.
|
||||
pub const MAX_MARKER_BYTES: u64 = (MAX_ITEM_JSON_BYTES + 256) as u64;
|
||||
/// The longest name an item is shown with, in characters (longer ones end with "…").
|
||||
pub const MAX_NAME_CHARS: usize = 64;
|
||||
/// The most disabled items kept.
|
||||
pub const MAX_DISABLED: usize = 1000;
|
||||
/// The largest disabled-items file read, in bytes: room for [`MAX_DISABLED`] stored items.
|
||||
pub const MAX_DISABLED_FILE_BYTES: u64 = (MAX_DISABLED * MAX_ITEM_JSON_BYTES + 256) as u64;
|
||||
/// The `format` of a launch marker, and its version.
|
||||
const MARKER_FORMAT: &str = "vectorcraft-launch";
|
||||
const MARKER_VERSION: u64 = 1;
|
||||
|
||||
/// Why Safe Mode is on.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Source {
|
||||
/// The `--safe-mode` option.
|
||||
Flag,
|
||||
/// The launch after one that stopped while a plug-in was loading, until the user chooses.
|
||||
Startup,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
/// Its name in `plugin.safeMode`.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Source::Flag => "flag",
|
||||
Source::Startup => "startup",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 0: off; otherwise a [`Source`].
|
||||
static ACTIVE: AtomicU8 = AtomicU8::new(0);
|
||||
|
||||
/// Turns Safe Mode on for `source`, or off with `None`.
|
||||
pub fn set_active(source: Option<Source>) {
|
||||
ACTIVE.store(
|
||||
match source {
|
||||
None => 0,
|
||||
Some(Source::Flag) => 1,
|
||||
Some(Source::Startup) => 2,
|
||||
},
|
||||
Ordering::SeqCst,
|
||||
);
|
||||
}
|
||||
|
||||
/// Why Safe Mode is on; `None` while it is off.
|
||||
pub fn source() -> Option<Source> {
|
||||
match ACTIVE.load(Ordering::SeqCst) {
|
||||
1 => Some(Source::Flag),
|
||||
2 => Some(Source::Startup),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Is Safe Mode on?
|
||||
pub fn active() -> bool {
|
||||
source().is_some()
|
||||
}
|
||||
|
||||
/// [`Error::SafeMode`] while Safe Mode is on.
|
||||
pub fn check() -> Result<()> {
|
||||
if active() { Err(Error::SafeMode) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// A plug-in as the launch marker and the disabled list name it: its id when known, the file it
|
||||
/// was loaded from, and its name. Two items are the same plug-in when their ids match, or, when
|
||||
/// either has no id, their files match. An id or file longer than [`MAX_FIELD_CHARS`] characters
|
||||
/// is kept, and compared, as its start followed by a hash of the whole ([`bound`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Item {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub file: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl Item {
|
||||
/// The item of a module loaded from `file`, before its id is known.
|
||||
pub fn file(file: impl Into<String>) -> Self {
|
||||
Item { file: Some(file.into()), ..Item::default() }
|
||||
}
|
||||
|
||||
/// The item of an installed plug-in.
|
||||
pub fn of(plugin: &crate::Plugin) -> Self {
|
||||
Item { id: Some(plugin.id().to_string()), file: plugin.source().map(str::to_string), name: Some(plugin.manifest().name.clone()) }
|
||||
}
|
||||
|
||||
/// Is `other` the same plug-in?
|
||||
pub fn same(&self, other: &Item) -> bool {
|
||||
match (&self.id, &other.id) {
|
||||
(Some(a), Some(b)) => bound(a) == bound(b),
|
||||
_ => match (&self.file, &other.file) {
|
||||
(Some(a), Some(b)) => bound(a) == bound(b),
|
||||
_ => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The name it is shown with: its name, else its id, else its file's name, through
|
||||
/// [`display_name`].
|
||||
pub fn label(&self) -> String {
|
||||
let file_name = || self.file.as_deref().map(|f| f.rsplit(['/', '\\']).next().unwrap_or(f));
|
||||
display_name(self.name.as_deref().filter(|n| !n.trim().is_empty()).or(self.id.as_deref()).or_else(file_name).unwrap_or(""))
|
||||
}
|
||||
|
||||
/// Its fields, each at most [`MAX_FIELD_CHARS`] characters (the id and the file through
|
||||
/// [`bound`], the name cut); `None` when it has neither an id nor a file.
|
||||
fn bounded(self) -> Option<Item> {
|
||||
let keep = |s: Option<String>| s.filter(|s| !s.is_empty()).map(|s| bound(&s).into_owned());
|
||||
let name = self.name.filter(|s| !s.is_empty()).map(|s| s.chars().take(MAX_FIELD_CHARS).collect::<String>());
|
||||
let item = Item { id: keep(self.id), file: keep(self.file), name };
|
||||
(item.id.is_some() || item.file.is_some()).then_some(item)
|
||||
}
|
||||
|
||||
/// As `plugin.safeMode` lists it: `{id, file, name, label}`.
|
||||
pub fn to_json(&self) -> Value {
|
||||
json!({"id": self.id, "file": self.file, "name": self.name, "label": self.label()})
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` when it has at most [`MAX_FIELD_CHARS`] characters; else its start, `#` and a hash of the
|
||||
/// whole of it, [`MAX_FIELD_CHARS`] characters in all, so two long values that share a start stay
|
||||
/// apart. A value made this way is its own bound.
|
||||
fn bound(s: &str) -> std::borrow::Cow<'_, str> {
|
||||
if s.chars().nth(MAX_FIELD_CHARS).is_none() {
|
||||
return std::borrow::Cow::Borrowed(s);
|
||||
}
|
||||
// FNV-1a: stable across versions and platforms.
|
||||
let hash = s.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3));
|
||||
let start: String = s.chars().take(MAX_FIELD_CHARS - 17).collect();
|
||||
std::borrow::Cow::Owned(format!("{start}#{hash:016x}"))
|
||||
}
|
||||
|
||||
/// `s` safe to show in a sentence: control characters and the characters that reorder text
|
||||
/// (U+061C, U+200E, U+200F, U+202A to U+202E, U+2066 to U+2069) removed, at most
|
||||
/// [`MAX_NAME_CHARS`] characters (a longer name ends with "…").
|
||||
pub fn display_name(s: &str) -> String {
|
||||
let reorders = |c: char| matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}');
|
||||
let clean: Vec<char> = s.trim().chars().filter(|&c| !c.is_control() && !reorders(c)).collect();
|
||||
if clean.len() > MAX_NAME_CHARS {
|
||||
clean[..MAX_NAME_CHARS - 1].iter().chain(std::iter::once(&'…')).collect()
|
||||
} else {
|
||||
clean.into_iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The disabled items this launch skips, the ones saved for the next launch, and the changes made
|
||||
/// to the saved list since this launch last read or wrote the file the apps keep it in.
|
||||
struct Disabled {
|
||||
skipped: Vec<Item>,
|
||||
saved: Vec<Item>,
|
||||
changes: Vec<Change>,
|
||||
/// Counts the [`set_disabled`] calls.
|
||||
read: u64,
|
||||
}
|
||||
|
||||
/// One change to the saved list, made again to the file's list when it is saved ([`merge_saved`]).
|
||||
#[derive(Clone)]
|
||||
enum Change {
|
||||
/// [`set_enabled`] (or [`disable`], turning it off).
|
||||
Enabled(Item, bool),
|
||||
/// [`disable_file_of`].
|
||||
FileOf(Item),
|
||||
}
|
||||
|
||||
impl Change {
|
||||
/// Makes the change to `list` → whether it changed.
|
||||
fn apply(&self, list: &mut Vec<Item>) -> bool {
|
||||
match self {
|
||||
Change::Enabled(item, enabled) => enable_in(list, item, *enabled),
|
||||
Change::FileOf(plugin) => file_of_in(list, plugin),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static DISABLED: RwLock<Disabled> = RwLock::new(Disabled { skipped: Vec::new(), saved: Vec::new(), changes: Vec::new(), read: 0 });
|
||||
|
||||
/// Sets the disabled items, for this launch and the next, as read from the file the apps keep them
|
||||
/// in (at most [`MAX_DISABLED`]; one without an id or a file is dropped).
|
||||
pub fn set_disabled(items: Vec<Item>) {
|
||||
let items: Vec<Item> = items.into_iter().filter_map(Item::bounded).take(MAX_DISABLED).collect();
|
||||
let mut d = DISABLED.write().unwrap_or_else(|e| e.into_inner());
|
||||
d.skipped = items.clone();
|
||||
d.saved = items;
|
||||
d.changes.clear();
|
||||
d.read += 1;
|
||||
}
|
||||
|
||||
/// What [`merge_saved`] made: the content to write to the file, and which changes it holds.
|
||||
#[derive(Debug)]
|
||||
pub struct Merged {
|
||||
/// The file's new content.
|
||||
pub bytes: Vec<u8>,
|
||||
/// The changes it holds: the first `made` of them.
|
||||
made: usize,
|
||||
/// The [`set_disabled`] they follow.
|
||||
read: u64,
|
||||
/// Of those, the ones turning a plug-in off that a full list (another window's) had no room
|
||||
/// for: they stay to be made at the next save.
|
||||
full: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Before the saved list is written: takes in `theirs`, the list the file holds now (another
|
||||
/// window sharing the preferences may have saved it since this launch read it; `None` when it
|
||||
/// can't be read, and the saved list stays as it is). The changes this launch made to the saved
|
||||
/// list since it last read or wrote the file ([`saved_written`]) are made again, in order, to
|
||||
/// `theirs`, and the result becomes the saved list. This launch's skipped list stays as it is.
|
||||
pub fn merge_saved(theirs: Option<Vec<Item>>) -> Merged {
|
||||
let mut d = DISABLED.write().unwrap_or_else(|e| e.into_inner());
|
||||
let made = d.changes.len();
|
||||
let mut full = Vec::new();
|
||||
if let Some(theirs) = theirs {
|
||||
let mut merged: Vec<Item> = theirs.into_iter().filter_map(Item::bounded).take(MAX_DISABLED).collect();
|
||||
for (i, change) in d.changes.iter().enumerate() {
|
||||
if !change.apply(&mut merged)
|
||||
&& let Change::Enabled(item, false) = change
|
||||
&& !merged.iter().any(|m| m.same(item))
|
||||
{
|
||||
full.push(i);
|
||||
}
|
||||
}
|
||||
d.saved = merged;
|
||||
}
|
||||
Merged { bytes: file_bytes(&d.saved), made, read: d.read, full }
|
||||
}
|
||||
|
||||
impl Merged {
|
||||
/// Does the content hold every change? A plug-in turned off that a full list had no room for
|
||||
/// is not in it (it waits for the next save).
|
||||
pub fn complete(&self) -> bool {
|
||||
self.full.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// `merged`'s content went to the file: later saves make again only the changes made after
|
||||
/// [`merge_saved`], and the ones it had no room for ([`merge_saved`]).
|
||||
pub fn saved_written(merged: &Merged) {
|
||||
let mut d = DISABLED.write().unwrap_or_else(|e| e.into_inner());
|
||||
if d.read != merged.read {
|
||||
return;
|
||||
}
|
||||
let made = merged.made.min(d.changes.len());
|
||||
let kept: Vec<Change> = merged.full.iter().filter_map(|&i| d.changes.get(i).filter(|_| i < made).cloned()).collect();
|
||||
d.changes.drain(..made);
|
||||
d.changes.splice(0..0, kept);
|
||||
}
|
||||
|
||||
/// Adds `item` to the disabled items, for this launch and the next → whether the list saved for
|
||||
/// the next launch has it (`false` when that list is full). This launch skips it either way.
|
||||
pub fn disable(item: Item) -> bool {
|
||||
let Some(item) = item.bounded() else { return false };
|
||||
let mut d = DISABLED.write().unwrap_or_else(|e| e.into_inner());
|
||||
let d = &mut *d;
|
||||
if !d.skipped.iter().any(|s| s.same(&item)) {
|
||||
d.skipped.push(item.clone());
|
||||
}
|
||||
if enable_in(&mut d.saved, &item, false) {
|
||||
d.changes.push(Change::Enabled(item, false));
|
||||
return true;
|
||||
}
|
||||
d.saved.iter().any(|s| s.same(&item))
|
||||
}
|
||||
|
||||
/// Turns `item` on or off for the next launch, leaving this launch as it is → whether the saved
|
||||
/// list changed.
|
||||
pub fn set_enabled(item: &Item, enabled: bool) -> bool {
|
||||
let mut d = DISABLED.write().unwrap_or_else(|e| e.into_inner());
|
||||
let changed = enable_in(&mut d.saved, item, enabled);
|
||||
if changed {
|
||||
d.changes.push(Change::Enabled(item.clone(), enabled));
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Turns `item` on (removes every entry for it) or off (adds it unless an entry has it, within
|
||||
/// [`MAX_DISABLED`]) in `list` → whether it changed.
|
||||
fn enable_in(list: &mut Vec<Item>, item: &Item, enabled: bool) -> bool {
|
||||
let before = list.len();
|
||||
if enabled {
|
||||
list.retain(|i| !i.same(item));
|
||||
list.len() != before
|
||||
} else if let Some(item) = item.clone().bounded().filter(|i| !list.iter().any(|s| s.same(i)) && before < MAX_DISABLED) {
|
||||
list.push(item);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// `plugin`, loaded from its file and then skipped by its id: each list that disables it gets its
|
||||
/// file too (the entry naming its id without a file takes it, or a new entry is added), so a later
|
||||
/// load skips that file before running any of its code → whether the saved list changed.
|
||||
pub fn disable_file_of(plugin: &Item) -> bool {
|
||||
let mut d = DISABLED.write().unwrap_or_else(|e| e.into_inner());
|
||||
let d = &mut *d;
|
||||
file_of_in(&mut d.skipped, plugin);
|
||||
let changed = file_of_in(&mut d.saved, plugin);
|
||||
if changed {
|
||||
d.changes.push(Change::FileOf(plugin.clone()));
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// [`disable_file_of`] for one list → whether it changed.
|
||||
fn file_of_in(list: &mut Vec<Item>, plugin: &Item) -> bool {
|
||||
let Some(file) = plugin.file.as_deref().filter(|f| !f.is_empty()) else { return false };
|
||||
let by_file = Item::file(file);
|
||||
if list.iter().any(|i| i.same(&by_file)) || !list.iter().any(|i| i.same(plugin)) {
|
||||
return false;
|
||||
}
|
||||
if let Some(entry) = list.iter_mut().find(|i| i.same(plugin) && i.file.is_none()) {
|
||||
entry.file = Some(bound(file).into_owned());
|
||||
} else if let Some(item) = plugin.clone().bounded().filter(|_| list.len() < MAX_DISABLED) {
|
||||
list.push(item);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The disabled items saved for the next launch.
|
||||
pub fn disabled() -> Vec<Item> {
|
||||
DISABLED.read().unwrap_or_else(|e| e.into_inner()).saved.clone()
|
||||
}
|
||||
|
||||
/// Does this launch skip `item` (a module file before it loads, or a plug-in once its id is known)?
|
||||
pub fn skipped(item: &Item) -> bool {
|
||||
DISABLED.read().unwrap_or_else(|e| e.into_inner()).skipped.iter().any(|i| i.same(item))
|
||||
}
|
||||
|
||||
/// The ids of the disabled items this launch skips.
|
||||
pub fn skipped_ids() -> Vec<String> {
|
||||
DISABLED.read().unwrap_or_else(|e| e.into_inner()).skipped.iter().filter_map(|i| i.id.clone()).collect()
|
||||
}
|
||||
|
||||
/// The disabled items saved for the next launch, as the file the apps keep them in.
|
||||
pub fn disabled_file() -> Vec<u8> {
|
||||
file_bytes(&disabled())
|
||||
}
|
||||
|
||||
/// `items` as the file the apps keep the disabled items in.
|
||||
fn file_bytes(items: &[Item]) -> Vec<u8> {
|
||||
serde_json::to_vec_pretty(&json!({"format": "vectorcraft-disabled-plugins", "version": 1, "items": items})).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The items of a file [`disabled_file`] wrote; none when it isn't one.
|
||||
pub fn parse_disabled_file(bytes: &[u8]) -> Vec<Item> {
|
||||
read_disabled_file(bytes).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The items of a file [`disabled_file`] wrote, `None` when the file is JSON of another format or
|
||||
/// version (a later version's list, which a save must not replace). Bytes that aren't JSON hold no
|
||||
/// items any launch can read: none.
|
||||
pub fn read_disabled_file(bytes: &[u8]) -> Option<Vec<Item>> {
|
||||
let Ok(v) = serde_json::from_slice::<Value>(bytes) else { return Some(vec![]) };
|
||||
if v["format"] != "vectorcraft-disabled-plugins" || v["version"] != 1 {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
v["items"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|i| serde_json::from_value::<Item>(i.clone()).ok()?.bounded())
|
||||
.take(MAX_DISABLED)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The item the launch before this one was loading when it stopped, once [`begin_launch`] read it.
|
||||
static PREVIOUS: Mutex<Option<Item>> = Mutex::new(None);
|
||||
|
||||
/// The item the launch before this one was loading when it stopped ([`begin_launch`]); `None` once
|
||||
/// the user chose what to do about it ([`forget_previous`]).
|
||||
pub fn previous() -> Option<Item> {
|
||||
PREVIOUS.lock().unwrap_or_else(|e| e.into_inner()).clone()
|
||||
}
|
||||
|
||||
/// The user chose what to do about [`previous`]: this launch's marker stops naming it.
|
||||
pub fn forget_previous() {
|
||||
*PREVIOUS.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
refresh();
|
||||
}
|
||||
|
||||
/// The items still loading, oldest first: one entry for each [`loading`] call, until the [`loaded`]
|
||||
/// given its [`Loading`].
|
||||
struct Running {
|
||||
next: u64,
|
||||
items: Vec<(u64, Item)>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl Running {
|
||||
/// The item this launch's marker names: the oldest item still loading, else [`previous`].
|
||||
fn pending(&self) -> Option<Item> {
|
||||
self.items.first().map(|(_, i)| i.clone()).or_else(previous)
|
||||
}
|
||||
}
|
||||
|
||||
static LOADING: Mutex<Running> = Mutex::new(Running { next: 0, items: Vec::new() });
|
||||
|
||||
/// One item [`loading`] named, until it is given to [`loaded`].
|
||||
#[must_use]
|
||||
#[derive(Debug)]
|
||||
pub struct Loading(u64);
|
||||
|
||||
/// Writes this launch's marker naming the item [`Running::pending`] gives, while holding the lock
|
||||
/// on the items still loading, so two threads' writes land in the order of their changes.
|
||||
fn refresh() {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
marker::write(LOADING.lock().unwrap_or_else(|e| e.into_inner()).pending().as_ref());
|
||||
}
|
||||
|
||||
/// The marker's content naming `item`, cut as a stored item is (none: nothing is loading).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn marker_bytes(item: Option<&Item>) -> Vec<u8> {
|
||||
let item = item.cloned().and_then(Item::bounded);
|
||||
serde_json::to_vec(&json!({"format": MARKER_FORMAT, "version": MARKER_VERSION, "item": item})).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The item a marker's content names; `None` when it names none or isn't a marker of this version.
|
||||
pub fn parse_marker(bytes: &[u8]) -> Option<Item> {
|
||||
if bytes.len() as u64 > MAX_MARKER_BYTES {
|
||||
return None;
|
||||
}
|
||||
let v: Value = serde_json::from_slice(bytes).ok()?;
|
||||
if v["format"] != MARKER_FORMAT || v["version"] != MARKER_VERSION {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_value::<Item>(v.get("item").filter(|i| i.is_object())?.clone()).ok()?.bounded()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use marker::{begin_launch, end_launch};
|
||||
|
||||
/// Adds `item` to the items still loading and writes this launch's marker naming the oldest of
|
||||
/// them (no marker: only the list changes) → the [`Loading`] to give to [`loaded`] when it is done.
|
||||
pub fn loading(item: &Item) -> Loading {
|
||||
let mut running = LOADING.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let token = running.next;
|
||||
running.next = running.next.wrapping_add(1);
|
||||
running.items.push((token, item.clone()));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
marker::write(running.pending().as_ref());
|
||||
Loading(token)
|
||||
}
|
||||
|
||||
/// Removes the item `token` names from the items still loading and writes this launch's marker
|
||||
/// naming the oldest one left; with none left, the item of [`previous`] or nothing.
|
||||
pub fn loaded(token: Loading) {
|
||||
let mut running = LOADING.lock().unwrap_or_else(|e| e.into_inner());
|
||||
running.items.retain(|(t, _)| *t != token.0);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
marker::write(running.pending().as_ref());
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod marker {
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use super::{Item, LOADING, MAX_MARKER_BYTES, PREVIOUS, marker_bytes, parse_marker};
|
||||
|
||||
/// The most marker files looked at in the folder.
|
||||
const MAX_MARKERS: usize = 100;
|
||||
const PREFIX: &str = "launch-";
|
||||
|
||||
/// This launch's marker: its folder, its name and the open lock file.
|
||||
struct Current {
|
||||
dir: PathBuf,
|
||||
token: String,
|
||||
_lock: File,
|
||||
}
|
||||
|
||||
static CURRENT: Mutex<Option<Current>> = Mutex::new(None);
|
||||
|
||||
fn json_path(dir: &Path, token: &str) -> PathBuf {
|
||||
dir.join(format!("{PREFIX}{token}.json"))
|
||||
}
|
||||
fn lock_path(dir: &Path, token: &str) -> PathBuf {
|
||||
dir.join(format!("{PREFIX}{token}.lock"))
|
||||
}
|
||||
fn tmp_path(dir: &Path, token: &str) -> PathBuf {
|
||||
dir.join(format!("{PREFIX}{token}.json.tmp"))
|
||||
}
|
||||
|
||||
/// Is the launch `token` still running (does it hold its lock)?
|
||||
fn running(dir: &Path, token: &str) -> bool {
|
||||
let Ok(f) = File::options().read(true).write(true).open(lock_path(dir, token)) else { return false };
|
||||
match f.try_lock() {
|
||||
Ok(()) => {
|
||||
let _ = f.unlock();
|
||||
false
|
||||
}
|
||||
Err(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The content of `path` up to [`MAX_MARKER_BYTES`]; `None` when it can't be read or is larger.
|
||||
fn read(path: &Path) -> Option<Vec<u8>> {
|
||||
let mut bytes = Vec::new();
|
||||
File::open(path).ok()?.take(MAX_MARKER_BYTES + 1).read_to_end(&mut bytes).ok()?;
|
||||
(bytes.len() as u64 <= MAX_MARKER_BYTES).then_some(bytes)
|
||||
}
|
||||
|
||||
/// Starts this launch's marker in `dir` → the item a launch that is no longer running was
|
||||
/// loading when it stopped, if one was (its marker is then removed, and this launch's marker
|
||||
/// names the item until [`super::forget_previous`]). A folder that can't be written leaves this
|
||||
/// launch without a marker; it still starts.
|
||||
pub fn begin_launch(dir: &Path) -> Option<Item> {
|
||||
let mut previous = None;
|
||||
let mut tokens: Vec<String> = std::fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| e.ok()?.file_name().into_string().ok())
|
||||
.filter_map(|n| Some(n.strip_prefix(PREFIX)?.strip_suffix(".json")?.to_string()))
|
||||
.collect();
|
||||
tokens.sort();
|
||||
tokens.truncate(MAX_MARKERS);
|
||||
for token in tokens {
|
||||
if running(dir, &token) {
|
||||
continue;
|
||||
}
|
||||
if previous.is_none() {
|
||||
previous = read(&json_path(dir, &token)).as_deref().and_then(parse_marker);
|
||||
}
|
||||
for path in [json_path(dir, &token), tmp_path(dir, &token), lock_path(dir, &token)] {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
*PREVIOUS.lock().unwrap_or_else(|e| e.into_inner()) = previous.clone();
|
||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
|
||||
let token = format!("{}-{nanos:x}", std::process::id());
|
||||
let lock = std::fs::create_dir_all(dir)
|
||||
.and_then(|()| File::options().create(true).truncate(false).read(true).write(true).open(lock_path(dir, &token)));
|
||||
if let Ok(lock) = lock.and_then(|f| f.try_lock().map(|()| f).map_err(std::io::Error::other)) {
|
||||
*CURRENT.lock().unwrap_or_else(|e| e.into_inner()) = Some(Current { dir: dir.to_path_buf(), token, _lock: lock });
|
||||
super::refresh();
|
||||
}
|
||||
previous
|
||||
}
|
||||
|
||||
/// Replaces this launch's marker with one naming `item`: written to a temporary file first,
|
||||
/// then renamed over the marker, so a reader finds the old marker or the new one. A write that
|
||||
/// fails leaves the marker as it was.
|
||||
pub(super) fn write(item: Option<&Item>) {
|
||||
let current = CURRENT.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let Some(c) = current.as_ref() else { return };
|
||||
let tmp = tmp_path(&c.dir, &c.token);
|
||||
let _ = File::create(&tmp)
|
||||
.and_then(|mut f| f.write_all(&marker_bytes(item)).and_then(|()| f.sync_all()))
|
||||
.and_then(|()| std::fs::rename(&tmp, json_path(&c.dir, &c.token)));
|
||||
}
|
||||
|
||||
/// Removes this launch's marker: the app is quitting normally. While an item is still loading
|
||||
/// ([`super::loading`] without [`super::loaded`]), the marker stays, naming the oldest one;
|
||||
/// otherwise, while the user has not chosen what to do about [`super::previous`], the marker
|
||||
/// stays, naming that, so the next launch asks.
|
||||
pub fn end_launch() {
|
||||
let running = LOADING.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let pending = running.pending();
|
||||
if pending.is_some() {
|
||||
write(pending.as_ref());
|
||||
}
|
||||
let Some(c) = CURRENT.lock().unwrap_or_else(|e| e.into_inner()).take() else { return };
|
||||
let Current { dir, token, _lock } = c;
|
||||
drop(_lock);
|
||||
let marker = pending.is_none().then(|| json_path(&dir, &token));
|
||||
for path in marker.into_iter().chain([tmp_path(&dir, &token), lock_path(&dir, &token)]) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn path_of_current() -> Option<PathBuf> {
|
||||
CURRENT.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|c| json_path(&c.dir, &c.token))
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes the tests that change this module's process-wide state.
|
||||
#[cfg(test)]
|
||||
pub(crate) static TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Safe Mode off, no launch marker, nothing loading, pending or disabled.
|
||||
fn reset() {
|
||||
LOADING.lock().unwrap_or_else(|e| e.into_inner()).items.clear();
|
||||
end_launch();
|
||||
set_active(None);
|
||||
forget_previous();
|
||||
set_disabled(vec![]);
|
||||
}
|
||||
|
||||
/// Holds [`TEST_LOCK`] for one test and resets the module's state when the test starts and when
|
||||
/// it ends, so a test that fails leaves the next one as it would find a new process.
|
||||
struct Fresh {
|
||||
_guard: std::sync::MutexGuard<'static, ()>,
|
||||
}
|
||||
|
||||
fn fresh() -> Fresh {
|
||||
let guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
reset();
|
||||
Fresh { _guard: guard }
|
||||
}
|
||||
|
||||
impl Drop for Fresh {
|
||||
fn drop(&mut self) {
|
||||
reset();
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("vc-safe-mode-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_are_bounded_and_cannot_reorder_or_hide_text() {
|
||||
assert_eq!(display_name("Good\u{202E}gnp.exe"), "Goodgnp.exe");
|
||||
assert_eq!(display_name("a\u{0007}b\nc\u{2067}d\u{200F}"), "abcd");
|
||||
let long = "x".repeat(200);
|
||||
let shown = display_name(&long);
|
||||
assert_eq!(shown.chars().count(), MAX_NAME_CHARS);
|
||||
assert!(shown.ends_with('…'));
|
||||
// The label shows the name, but the id still picks the plug-in.
|
||||
let item = Item { id: Some("org.example.a".into()), file: None, name: Some("\u{202E}evil".into()) };
|
||||
assert_eq!(item.label(), "evil");
|
||||
assert!(item.same(&Item { id: Some("org.example.a".into()), file: None, name: Some("other".into()) }));
|
||||
assert!(!item.same(&Item { id: Some("org.example.b".into()), file: None, name: Some("\u{202E}evil".into()) }));
|
||||
assert_eq!(Item::file("C:\\Plug-ins\\x.wasm").label(), "x.wasm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_marker_of_this_version_names_an_item() {
|
||||
let item = Item { id: Some("org.example.x".into()), file: Some("/p/x.wasm".into()), name: None };
|
||||
assert_eq!(parse_marker(&marker_bytes(Some(&item))), Some(item));
|
||||
assert_eq!(parse_marker(&marker_bytes(None)), None, "nothing was loading");
|
||||
for bad in [
|
||||
&b""[..],
|
||||
b"{",
|
||||
b"[]",
|
||||
br#"{"format":"vectorcraft-launch","version":2,"item":{"id":"x"}}"#,
|
||||
br#"{"format":"other","version":1,"item":{"id":"x"}}"#,
|
||||
br#"{"format":"vectorcraft-launch","version":1,"item":"x"}"#,
|
||||
br#"{"format":"vectorcraft-launch","version":1,"item":{}}"#,
|
||||
br#"{"format":"vectorcraft-launch","version":1,"item":{"id":"x""#,
|
||||
] {
|
||||
assert_eq!(parse_marker(bad), None, "{}", String::from_utf8_lossy(bad));
|
||||
}
|
||||
let mut big =
|
||||
format!(r#"{{"format":"vectorcraft-launch","version":1,"item":{{"id":"{}"}}}}"#, "y".repeat(MAX_MARKER_BYTES as usize)).into_bytes();
|
||||
assert_eq!(parse_marker(&big), None, "larger than the limit");
|
||||
big.truncate(10);
|
||||
assert_eq!(parse_marker(&big), None, "cut short");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_launch_that_stopped_names_its_item_and_a_running_one_does_not() {
|
||||
let _f = fresh();
|
||||
let dir = temp_dir("marker");
|
||||
// A launch that stopped while loading x: its marker without a held lock, and a stray
|
||||
// temporary file from a write it didn't finish.
|
||||
let x = Item { id: Some("org.example.x".into()), file: None, name: Some("X".into()) };
|
||||
std::fs::write(dir.join("launch-1-a.json"), marker_bytes(Some(&x))).unwrap();
|
||||
std::fs::write(dir.join("launch-1-a.json.tmp"), b"{\"format\":\"vectorcr").unwrap();
|
||||
assert_eq!(begin_launch(&dir), Some(x.clone()));
|
||||
assert_eq!(previous(), Some(x.clone()));
|
||||
assert!(!dir.join("launch-1-a.json").exists() && !dir.join("launch-1-a.json.tmp").exists(), "read once");
|
||||
let mine = marker::path_of_current().unwrap();
|
||||
assert_eq!(std::fs::read(&mine).ok().as_deref().and_then(parse_marker), Some(x.clone()), "named until the user chooses");
|
||||
forget_previous();
|
||||
assert_eq!(std::fs::read(&mine).ok().as_deref().and_then(parse_marker), None);
|
||||
let token = loading(&x);
|
||||
assert_eq!(std::fs::read(&mine).ok().as_deref().and_then(parse_marker), Some(x.clone()));
|
||||
assert!(!mine.with_extension("json.tmp").exists(), "the temporary file is renamed over the marker");
|
||||
loaded(token);
|
||||
assert_eq!(std::fs::read(&mine).ok().as_deref().and_then(parse_marker), None);
|
||||
end_launch();
|
||||
assert!(!mine.exists(), "a normal quit removes the marker");
|
||||
assert_eq!(begin_launch(&dir), None, "nothing left behind");
|
||||
end_launch();
|
||||
forget_previous();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_launch_that_quits_before_the_user_chooses_leaves_the_choice_to_the_next() {
|
||||
let _f = fresh();
|
||||
let dir = temp_dir("pending");
|
||||
let x = Item { id: Some("org.example.x".into()), file: None, name: Some("X".into()) };
|
||||
std::fs::write(dir.join("launch-1-a.json"), marker_bytes(Some(&x))).unwrap();
|
||||
assert_eq!(begin_launch(&dir), Some(x.clone()));
|
||||
end_launch();
|
||||
assert_eq!(begin_launch(&dir), Some(x.clone()), "asked again");
|
||||
forget_previous();
|
||||
end_launch();
|
||||
assert_eq!(begin_launch(&dir), None, "once the user chose");
|
||||
end_launch();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_launch_that_ends_while_a_plug_in_loads_names_it_at_the_next_launch() {
|
||||
let _f = fresh();
|
||||
let dir = temp_dir("ends-loading");
|
||||
let x = Item { id: Some("org.example.x".into()), file: None, name: Some("X".into()) };
|
||||
assert_eq!(begin_launch(&dir), None);
|
||||
let token = loading(&x);
|
||||
// A panic the app caught while x loaded: the app quits without `loaded`.
|
||||
end_launch();
|
||||
loaded(token);
|
||||
assert_eq!(begin_launch(&dir), Some(x.clone()), "the next launch asks");
|
||||
forget_previous();
|
||||
end_launch();
|
||||
assert_eq!(begin_launch(&dir), None);
|
||||
end_launch();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_marker_names_a_plug_in_still_loading_when_another_one_is_done() {
|
||||
let _f = fresh();
|
||||
let dir = temp_dir("two-loading");
|
||||
let a = Item { id: Some("org.example.a".into()), file: None, name: Some("A".into()) };
|
||||
let b = Item { id: Some("org.example.b".into()), file: None, name: Some("B".into()) };
|
||||
assert_eq!(begin_launch(&dir), None);
|
||||
let mine = marker::path_of_current().unwrap();
|
||||
let marker = || std::fs::read(&mine).ok().as_deref().and_then(parse_marker);
|
||||
// Two threads run their plug-ins' first evaluations at once.
|
||||
let ta = loading(&a);
|
||||
let tb = loading(&b);
|
||||
assert_eq!(marker(), Some(a.clone()), "the oldest item still loading");
|
||||
loaded(ta);
|
||||
assert_eq!(marker(), Some(b.clone()), "b is still loading");
|
||||
// The app stops while b loads.
|
||||
end_launch();
|
||||
loaded(tb);
|
||||
assert_eq!(begin_launch(&dir), Some(b.clone()), "the next launch offers b");
|
||||
forget_previous();
|
||||
end_launch();
|
||||
assert_eq!(begin_launch(&dir), None);
|
||||
end_launch();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_file_path_names_and_matches_its_plug_in() {
|
||||
let _f = fresh();
|
||||
let dir = temp_dir("long-path");
|
||||
// Each character written as 6 or 2 bytes, so the item is far larger in JSON than in characters.
|
||||
let long = format!("C:\\{}\\x.wasm", "\u{1}é".repeat(700));
|
||||
assert_eq!(begin_launch(&dir), None);
|
||||
let token = loading(&Item::file(long.clone()));
|
||||
end_launch();
|
||||
loaded(token);
|
||||
let stopped = begin_launch(&dir).expect("the marker names the module");
|
||||
assert!(stopped.same(&Item::file(long.clone())), "the stored item matches the full path");
|
||||
disable(stopped);
|
||||
assert!(skipped(&Item::file(long.clone())), "Disable and Continue skips it");
|
||||
assert!(!skipped(&Item::file(format!("D{}", &long[1..]))), "a path that differs within the kept part is another plug-in");
|
||||
let other = long.replace("x.wasm", "y.wasm");
|
||||
assert!(!skipped(&Item::file(other.clone())), "a path that differs after the kept part is another plug-in");
|
||||
assert!(!Item::file(long.clone()).same(&Item::file(other)));
|
||||
forget_previous();
|
||||
end_launch();
|
||||
set_disabled(vec![]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_list_of_long_items_fits_the_file_the_apps_read() {
|
||||
let _f = fresh();
|
||||
let field = |i: usize, tag: &str| format!("{tag}{i:04}{}", "\u{1}".repeat(MAX_FIELD_CHARS));
|
||||
set_disabled((0..MAX_DISABLED).map(|i| Item { id: Some(field(i, "i")), file: Some(field(i, "f")), name: Some(field(i, "n")) }).collect());
|
||||
let file = disabled_file();
|
||||
assert!(file.len() as u64 <= MAX_DISABLED_FILE_BYTES, "{} bytes", file.len());
|
||||
assert_eq!(parse_disabled_file(&file).len(), MAX_DISABLED);
|
||||
// A full list has no room for one more: this launch skips it, and the caller is told.
|
||||
let late = Item::file("late.wasm");
|
||||
assert!(!disable(late.clone()));
|
||||
assert!(skipped(&late) && !disabled().contains(&late));
|
||||
assert!(disable(Item { id: Some(field(0, "i")), ..Item::default() }), "one the list has");
|
||||
set_disabled(vec![]);
|
||||
}
|
||||
|
||||
/// A plug-in turned on here is removed from the file's list even when another window saved its
|
||||
/// entry with more details. A change whose save failed, one made while the file was written and
|
||||
/// one that a full list had no room for are made again at the next save.
|
||||
#[test]
|
||||
fn merging_the_saved_list_matches_plug_ins_and_keeps_unsaved_changes() {
|
||||
let _f = fresh();
|
||||
let id = |s: &str| Item { id: Some(s.into()), ..Item::default() };
|
||||
set_disabled(vec![id("x")]);
|
||||
assert!(set_enabled(&id("x"), true));
|
||||
let theirs_x = Item { id: Some("x".into()), file: Some("/p/x.wasm".into()), name: Some("X".into()) };
|
||||
let merged = merge_saved(Some(vec![theirs_x, id("y")]));
|
||||
assert_eq!(disabled(), [id("y")], "x is turned on, whatever details the other window saved");
|
||||
assert_eq!(parse_disabled_file(&merged.bytes), [id("y")]);
|
||||
saved_written(&merged);
|
||||
|
||||
assert!(set_enabled(&id("a"), false));
|
||||
// Another window has turned c off.
|
||||
let _ = merge_saved(Some(vec![id("y"), id("c")]));
|
||||
// The write failed: no saved_written. The other window turns c on again.
|
||||
assert!(set_enabled(&id("b"), false));
|
||||
let merged = merge_saved(Some(vec![id("y")]));
|
||||
assert_eq!(disabled(), [id("y"), id("a"), id("b")], "a is still saved, and c stays on");
|
||||
// A change made while the file is written is made again at the next save.
|
||||
assert!(set_enabled(&id("d"), false));
|
||||
saved_written(&merged);
|
||||
let _ = merge_saved(Some(vec![id("y"), id("a"), id("b")]));
|
||||
assert_eq!(disabled(), [id("y"), id("a"), id("b"), id("d")]);
|
||||
set_disabled(vec![]);
|
||||
|
||||
// Another window filled the list: the plug-in turned off here waits for room.
|
||||
let full: Vec<Item> = (0..MAX_DISABLED).map(|i| id(&format!("full{i}"))).collect();
|
||||
assert!(set_enabled(&id("e"), false));
|
||||
let merged = merge_saved(Some(full.clone()));
|
||||
assert_eq!(disabled(), full);
|
||||
saved_written(&merged);
|
||||
let _ = merge_saved(Some(full[1..].to_vec()));
|
||||
assert_eq!(disabled().last(), Some(&id("e")), "made once there is room");
|
||||
|
||||
// This launch adds x's file to its entry: the file's entry for x takes it, unless another
|
||||
// window turned x on meanwhile.
|
||||
let x_file = Item { id: Some("x".into()), file: Some("/p/x.wasm".into()), name: Some("X".into()) };
|
||||
let x_entry = Item { name: None, ..x_file.clone() };
|
||||
for (theirs, expected) in [(vec![id("x"), id("y")], vec![x_entry, id("y")]), (vec![id("y")], vec![id("y")])] {
|
||||
set_disabled(vec![id("x"), id("y")]);
|
||||
assert!(disable_file_of(&x_file));
|
||||
let _ = merge_saved(Some(theirs));
|
||||
assert_eq!(disabled(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_marker_whose_launch_holds_its_lock_is_left_alone() {
|
||||
let _f = fresh();
|
||||
let dir = temp_dir("running");
|
||||
let x = Item { id: Some("org.example.x".into()), file: None, name: None };
|
||||
std::fs::write(dir.join("launch-9-b.json"), marker_bytes(Some(&x))).unwrap();
|
||||
let lock = std::fs::File::create(dir.join("launch-9-b.lock")).unwrap();
|
||||
lock.try_lock().unwrap();
|
||||
assert_eq!(begin_launch(&dir), None, "that launch is still running");
|
||||
assert!(dir.join("launch-9-b.json").exists());
|
||||
end_launch();
|
||||
drop(lock);
|
||||
assert_eq!(begin_launch(&dir), Some(x), "once it stopped");
|
||||
end_launch();
|
||||
forget_previous();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabling_takes_effect_at_the_next_launch() {
|
||||
let _f = fresh();
|
||||
let x = Item { id: Some("org.example.x".into()), file: Some("/p/x.wasm".into()), name: None };
|
||||
let y = Item::file("/p/y.wasm");
|
||||
set_disabled(vec![x.clone(), Item::default()]);
|
||||
assert_eq!(disabled(), std::slice::from_ref(&x), "an item that names nothing is dropped");
|
||||
assert!(skipped(&Item { id: Some("org.example.x".into()), ..Item::default() }));
|
||||
assert!(!skipped(&y));
|
||||
assert!(set_enabled(&x, true));
|
||||
assert!(disabled().is_empty());
|
||||
assert!(skipped(&x), "still skipped until the next launch");
|
||||
assert!(set_enabled(&y, false) && !set_enabled(&y, false));
|
||||
assert!(!skipped(&y), "turning one off waits for the next launch too");
|
||||
disable(y.clone());
|
||||
assert!(skipped(&y));
|
||||
assert_eq!(parse_disabled_file(&disabled_file()), disabled());
|
||||
assert!(parse_disabled_file(b"{\"format\":\"other\",\"version\":1,\"items\":[{\"id\":\"x\"}]}").is_empty());
|
||||
set_disabled(vec![]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Safe Mode and disabled plug-ins: in Safe Mode no plug-in loads or runs (one installed before it
|
||||
//! was turned on included), and loading a folder skips the plug-ins the user disabled. Safe Mode is
|
||||
//! process-wide, so these tests run in their own test binary, one at a time.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_geom::{FillRule, PathData, Rect};
|
||||
use vectorcraft_plugins::safe_mode::{self, Item, Source};
|
||||
use vectorcraft_plugins::{Error, Limits, Plugin, effect, registry, wat as tpl};
|
||||
|
||||
static LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Safe Mode off, nothing disabled or installed.
|
||||
fn reset() {
|
||||
safe_mode::set_active(None);
|
||||
safe_mode::set_disabled(vec![]);
|
||||
for p in registry::list() {
|
||||
registry::remove(p.id());
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds the lock for one test and resets the process-wide state when the test starts and when it
|
||||
/// ends, so a test that fails leaves the next one as it would find a new process.
|
||||
struct Fresh {
|
||||
_guard: std::sync::MutexGuard<'static, ()>,
|
||||
}
|
||||
|
||||
fn fresh() -> Fresh {
|
||||
let guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
reset();
|
||||
Fresh { _guard: guard }
|
||||
}
|
||||
|
||||
impl Drop for Fresh {
|
||||
fn drop(&mut self) {
|
||||
reset();
|
||||
}
|
||||
}
|
||||
|
||||
fn build(src: &str) -> Vec<u8> {
|
||||
wat::parse_str(src).unwrap_or_else(|e| panic!("bad test WAT: {e}\n{src}"))
|
||||
}
|
||||
|
||||
fn filter(id: &str) -> Vec<u8> {
|
||||
build(&tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#))
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("vc-safe-mode-it-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_safe_mode_nothing_loads_or_runs() {
|
||||
let _f = fresh();
|
||||
// Installed before Safe Mode was turned on.
|
||||
let before = registry::install(Plugin::load(&filter("test.safe.before"), Limits::default()).unwrap());
|
||||
let fx = build(&tpl::returning(&tpl::manifest("test.safe.effect", "effect", "{}"), r#"{"objects":[]}"#));
|
||||
registry::install(Plugin::load(&fx, Limits::default()).unwrap());
|
||||
let dir = temp_dir("gate");
|
||||
std::fs::write(dir.join("a.wasm"), filter("test.safe.folder")).unwrap();
|
||||
|
||||
safe_mode::set_active(Some(Source::Flag));
|
||||
assert_eq!((safe_mode::active(), safe_mode::source()), (true, Some(Source::Flag)));
|
||||
assert_eq!(Plugin::load(&filter("test.safe.new"), Limits::default()).err(), Some(Error::SafeMode));
|
||||
assert_eq!(before.run(br#"{"objects":[]}"#, b"{}"), Err(Error::SafeMode), "an installed plug-in can't run either");
|
||||
assert_eq!(registry::load_folder(&dir), Err(Error::SafeMode));
|
||||
assert!(registry::get("test.safe.folder").is_none());
|
||||
let path = PathData::default();
|
||||
let drawn = effect::apply("test.safe.effect", &json!({}), &path, Rect::new(0.0, 0.0, 1.0, 1.0), FillRule::NonZero, effect::Paints::default());
|
||||
assert_eq!(drawn, None, "the geometry is drawn unchanged");
|
||||
assert_eq!(effect::last_error("test.safe.effect"), None, "not a failure of the plug-in");
|
||||
assert_eq!(Error::SafeMode.to_string(), "Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally");
|
||||
|
||||
safe_mode::set_active(None);
|
||||
assert!(before.run(br#"{"objects":[]}"#, b"{}").is_ok(), "nothing was uninstalled");
|
||||
assert_eq!(registry::load_folder(&dir).unwrap().loaded, ["test.safe.folder"]);
|
||||
registry::remove("test.safe.before");
|
||||
registry::remove("test.safe.effect");
|
||||
registry::remove("test.safe.folder");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loading_a_folder_skips_disabled_plug_ins_by_file_or_id() {
|
||||
let _f = fresh();
|
||||
let dir = temp_dir("disabled");
|
||||
let (x, y, z) = (dir.join("x.wasm"), dir.join("y.wasm"), dir.join("z.wasm"));
|
||||
std::fs::write(&x, filter("test.disabled.x")).unwrap();
|
||||
std::fs::write(&y, filter("test.disabled.y")).unwrap();
|
||||
std::fs::write(&z, filter("test.disabled.z")).unwrap();
|
||||
safe_mode::set_disabled(vec![Item::file(x.display().to_string()), Item { id: Some("test.disabled.z".into()), ..Item::default() }]);
|
||||
let report = registry::load_folder(&dir).unwrap();
|
||||
assert_eq!(report.loaded, ["test.disabled.y"]);
|
||||
let failed: Vec<(&str, &str)> = report.failed.iter().map(|(f, e)| (f.as_str(), e.as_str())).collect();
|
||||
assert_eq!(failed, [(x.display().to_string().as_str(), registry::DISABLED), (z.display().to_string().as_str(), registry::DISABLED)]);
|
||||
assert!(registry::get("test.disabled.x").is_none() && registry::get("test.disabled.z").is_none());
|
||||
safe_mode::set_disabled(vec![]);
|
||||
registry::remove("test.disabled.y");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_module_disabled_by_id_alone_runs_no_code_on_the_next_folder_load() {
|
||||
let _f = fresh();
|
||||
let dir = temp_dir("by-id");
|
||||
let w = dir.join("w.wasm");
|
||||
std::fs::write(&w, filter("test.byid.w")).unwrap();
|
||||
let by_id = Item { id: Some("test.byid.w".into()), name: Some("W".into()), ..Item::default() };
|
||||
safe_mode::set_disabled(vec![by_id]);
|
||||
let first = registry::load_folder(&dir).unwrap();
|
||||
assert_eq!(first.failed, [(w.display().to_string(), registry::DISABLED.to_string())]);
|
||||
assert!(first.disabled_changed, "the app saves the list with the file");
|
||||
assert_eq!(safe_mode::disabled().len(), 1, "the entry takes the file; no second entry");
|
||||
assert_eq!(safe_mode::disabled()[0].file.as_deref(), Some(w.display().to_string().as_str()));
|
||||
// A module that fails as soon as it loads: the file is skipped before any of it runs.
|
||||
std::fs::write(&w, b"not a module").unwrap();
|
||||
let second = registry::load_folder(&dir).unwrap();
|
||||
assert_eq!(second.failed, [(w.display().to_string(), registry::DISABLED.to_string())]);
|
||||
assert!(!second.disabled_changed);
|
||||
// Turned back on for the next launch: loading doesn't disable its file again for that launch.
|
||||
std::fs::write(&w, filter("test.byid.w")).unwrap();
|
||||
safe_mode::set_disabled(vec![Item { id: Some("test.byid.w".into()), ..Item::default() }]);
|
||||
assert!(safe_mode::set_enabled(&Item { id: Some("test.byid.w".into()), ..Item::default() }, true));
|
||||
let third = registry::load_folder(&dir).unwrap();
|
||||
assert!(!third.disabled_changed && safe_mode::disabled().is_empty());
|
||||
assert!(safe_mode::skipped(&Item::file(w.display().to_string())), "this launch still skips it");
|
||||
safe_mode::set_disabled(vec![]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -77,7 +77,16 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let full = ui.max_rect();
|
||||
let right_edge = if custom { full.right() - titlebar::WIDTH - 10.0 } else { full.right() };
|
||||
let right = egui::Rect::from_min_max(egui::pos2(menus_end + 8.0, full.top()), egui::pos2(right_edge, full.bottom()));
|
||||
let room = right.width();
|
||||
// In Safe Mode, a label that stays in the bar whatever document is open, and opens
|
||||
// Help › Safe Mode; the other controls fit in the room it leaves, and the workspace
|
||||
// switcher (also under Window › Workspace) goes when a minimal one doesn't fit.
|
||||
let safe = vectorcraft_plugins::safe_mode::active().then(|| egui::RichText::new(tl!("Safe Mode")).color(t.warning).size(12.0));
|
||||
let safe_w = safe.as_ref().map_or(0.0, |label| {
|
||||
let text = ui.painter().layout_no_wrap(label.text().to_string(), egui::FontId::proportional(12.0), t.warning);
|
||||
text.size().x + 2.0 * ui.spacing().button_padding.x + 8.0 + ui.spacing().item_spacing.x
|
||||
});
|
||||
let room = (right.width() - safe_w).max(0.0);
|
||||
let switcher = safe.is_none() || room >= 64.0 + 8.0;
|
||||
// Built-in workspace names translate; the user's own stay as typed.
|
||||
let ws_name =
|
||||
if crate::workspaces::is_builtin(&app.ui.workspace) { tl!(&app.ui.workspace).to_string() } else { app.ui.workspace.clone() };
|
||||
@@ -86,19 +95,27 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let gap = ui.spacing().item_spacing.x;
|
||||
// The search icon, as in PhotoCraft: shown while a minimal switcher fits beside it.
|
||||
let icon = 28.0;
|
||||
let search = room >= 64.0 + 8.0 + gap + icon;
|
||||
let search = switcher && room >= 64.0 + 8.0 + gap + icon;
|
||||
let ws_w = ws_w.min(room - 8.0 - gap - if search { icon } else { 0.0 }).max(64.0);
|
||||
let discord = search && room >= ws_w + 8.0 + gap + icon + 10.0 + gap + crate::community::discord_width(ui, false);
|
||||
let mut rui = ui.new_child(egui::UiBuilder::new().max_rect(right).layout(egui::Layout::right_to_left(egui::Align::Center)));
|
||||
let ui = &mut rui;
|
||||
// Workspace switcher: shows the current workspace, opens Window → Workspace.
|
||||
let (wr, wresp) = ui.allocate_exact_size(vec2(ws_w, WIDGET_H), Sense::click());
|
||||
ui.painter().rect_filled(wr, CornerRadius::same(4), if wresp.hovered() { t.hover } else { t.panel });
|
||||
ui.painter().with_clip_rect(wr.shrink2(vec2(4.0, 0.0))).galley(wr.left_center() + vec2(10.0, -ws.size().y / 2.0), ws, t.text);
|
||||
icons::paint(ui, "chevron-down", egui::Rect::from_center_size(wr.right_center() - vec2(12.0, 0.0), vec2(12.0, 12.0)), t.text_dim);
|
||||
let wresp = wresp.on_hover_text(tl!("Switch workspace"));
|
||||
egui::Popup::menu(&wresp).show(|ui| crate::workspaces::popup(app, ui));
|
||||
ui.add_space(8.0);
|
||||
if switcher {
|
||||
let (wr, wresp) = ui.allocate_exact_size(vec2(ws_w, WIDGET_H), Sense::click());
|
||||
ui.painter().rect_filled(wr, CornerRadius::same(4), if wresp.hovered() { t.hover } else { t.panel });
|
||||
ui.painter().with_clip_rect(wr.shrink2(vec2(4.0, 0.0))).galley(wr.left_center() + vec2(10.0, -ws.size().y / 2.0), ws, t.text);
|
||||
icons::paint(ui, "chevron-down", egui::Rect::from_center_size(wr.right_center() - vec2(12.0, 0.0), vec2(12.0, 12.0)), t.text_dim);
|
||||
let wresp = wresp.on_hover_text(tl!("Switch workspace"));
|
||||
egui::Popup::menu(&wresp).show(|ui| crate::workspaces::popup(app, ui));
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
if let Some(label) = safe {
|
||||
if ui.add(egui::Button::new(label).frame(false)).on_hover_text(tl!("Safe Mode…")).clicked() {
|
||||
app.run("help.safeMode", json!({})).ok();
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
// Search → command palette (its shortcut opens it too).
|
||||
if search {
|
||||
let tip = match menus::shortcut_of("help.commandPalette") {
|
||||
@@ -142,12 +159,14 @@ pub fn system_title_bar(app: &VectorcraftApp) -> bool {
|
||||
|
||||
/// The OS window title: the active document's name suffixed with the app name (a `*` prefix
|
||||
/// marks unsaved changes, like the `*` in the document tabs), or just the app name with no
|
||||
/// document open.
|
||||
/// document open; in Safe Mode, followed by "(Safe Mode)".
|
||||
pub fn window_title(app: &VectorcraftApp) -> String {
|
||||
app.session
|
||||
let title = app
|
||||
.session
|
||||
.active()
|
||||
.map(|d| format!("{}{} \u{2014} VectorCraft", if d.is_dirty() { "*" } else { "" }, d.title()))
|
||||
.unwrap_or_else(|| "VectorCraft".into())
|
||||
.unwrap_or_else(|| "VectorCraft".into());
|
||||
if vectorcraft_plugins::safe_mode::active() { format!("{title} ({})", tl!("Safe Mode")) } else { title }
|
||||
}
|
||||
|
||||
/// Keep the OS window title (and the taskbar / Alt-Tab entry) on the active file. Sends
|
||||
|
||||
@@ -96,6 +96,7 @@ pub mod relink;
|
||||
pub mod rename_artboards;
|
||||
pub(crate) mod revolve;
|
||||
pub(crate) mod revolve_gizmo;
|
||||
pub mod safe_mode;
|
||||
pub mod saturate;
|
||||
pub mod save_brush_library;
|
||||
mod save_changes;
|
||||
@@ -381,6 +382,8 @@ registry! {
|
||||
DivideSlices: [slices::DIVIDE] => slices::DIVIDE_SPEC,
|
||||
EpsOptions: [eps_options::KIND] => eps_options::SPEC,
|
||||
Recovery: [crate::recovery::KIND] => recovery::SPEC,
|
||||
PluginStopped: [safe_mode::STOPPED] => safe_mode::STOPPED_SPEC,
|
||||
SafeMode: [safe_mode::KIND] => safe_mode::SPEC,
|
||||
DxfImport: [dxf_import::KIND] => dxf_import::SPEC,
|
||||
RasterFormatOptions: ["tiffOptions", "bmpOptions", "tgaOptions"] => png_options::SPEC,
|
||||
SaveForWeb: [save_for_web::KIND] => save_for_web::SPEC,
|
||||
@@ -551,6 +554,9 @@ pub fn cancel(app: &mut VectorcraftApp) {
|
||||
if d.kind == swatch_conflict::KIND {
|
||||
crate::placing::cancel_swatch_paste(app);
|
||||
}
|
||||
if d.kind == safe_mode::STOPPED {
|
||||
safe_mode::cancel_stopped(app);
|
||||
}
|
||||
// Toolbar-only Flare options start no preview and must not cancel a gesture
|
||||
// that was already active before opening them. The generic parameter dialog has a spec
|
||||
// without a preview, yet previews some commands (Grid Repeat Options) when asked to.
|
||||
@@ -592,13 +598,15 @@ pub fn confirm(app: &mut VectorcraftApp) -> DialogResult {
|
||||
|
||||
/// After a dialog closes, and each frame: a Missing Fonts dialog that another dialog replaced waits
|
||||
/// for its turn again, the search a Missing Fonts or Find Linked Files dialog started stops once
|
||||
/// that dialog is gone, and with no dialog open the next document's Missing Fonts dialog opens
|
||||
/// (one dialog at a time).
|
||||
/// that dialog is gone, and with no dialog open the choice about a stopped plug-in that another
|
||||
/// dialog replaced opens again, else the next document's Missing Fonts dialog (one dialog at a
|
||||
/// time).
|
||||
pub(crate) fn settle(app: &mut VectorcraftApp) {
|
||||
crate::placing::settle_swatch_paste(app);
|
||||
missing_fonts::requeue_replaced(app);
|
||||
missing_fonts::stop_when_closed(app);
|
||||
find_links::stop_when_closed(app);
|
||||
safe_mode::reopen(app);
|
||||
missing_fonts::open_next(app);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
//! Safe Mode in the app: the choice after a launch that stopped while a plug-in was loading
|
||||
//! ([`ask_about_stopped`]: Disable and Continue, Start Normally, or Start in Safe Mode), and Help ›
|
||||
//! Safe Mode ([`open`]), which lists the disabled plug-ins with Enable on Re-launch. Safe Mode
|
||||
//! itself, the disabled list and the launch marker are `vectorcraft_plugins::safe_mode`'s.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_plugins::safe_mode::{self, Item};
|
||||
|
||||
use super::DialogSpec;
|
||||
use crate::VectorcraftApp;
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// Dialog kind of the choice after a launch that stopped. Fields: `item` (`{id, file, name, label}`)
|
||||
/// and `normal` (Start Normally instead of Disable and Continue). Cancel starts in Safe Mode.
|
||||
pub const STOPPED: &str = "pluginStopped";
|
||||
|
||||
/// Dialog kind of Help › Safe Mode. Fields: `items` (`[{id, file, name, label, enable}]`, the
|
||||
/// disabled plug-ins, `enable` being Enable on Re-launch).
|
||||
pub const KIND: &str = "safeMode";
|
||||
|
||||
/// Before any plug-in loads (desktop): starts this launch's marker in `marker_dir` (none: no
|
||||
/// marker), and turns Safe Mode on for `--safe-mode` (`flag`), or until the user chooses after a
|
||||
/// launch that stopped while a plug-in was loading → that plug-in, for [`after_start`]. With `flag`
|
||||
/// nothing is asked, and the plug-in stays pending (`plugin.safeMode`'s `pendingItem`): the next
|
||||
/// launch asks about it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn begin_launch(marker_dir: Option<&std::path::Path>, flag: bool) -> Option<Item> {
|
||||
let stopped = marker_dir.and_then(safe_mode::begin_launch);
|
||||
if flag {
|
||||
safe_mode::set_active(Some(safe_mode::Source::Flag));
|
||||
return None;
|
||||
}
|
||||
if stopped.is_some() {
|
||||
safe_mode::set_active(Some(safe_mode::Source::Startup));
|
||||
}
|
||||
stopped
|
||||
}
|
||||
|
||||
/// The app is quitting normally: its launch marker goes.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn end_launch() {
|
||||
safe_mode::end_launch();
|
||||
}
|
||||
|
||||
/// Once the app is made: the choice about `stopped` ([`begin_launch`]), or in Safe Mode a status
|
||||
/// line saying so.
|
||||
pub fn after_start(app: &mut VectorcraftApp, stopped: Option<Item>) {
|
||||
match stopped {
|
||||
Some(item) => ask_about_stopped(app, &item),
|
||||
None if safe_mode::active() => app.status(vectorcraft_plugins::Error::SafeMode.to_string()),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask what to do about `item`, the plug-in the last launch was loading when it stopped. Safe Mode
|
||||
/// is on until the user chooses. A dialog that takes the choice's place leaves it waiting: it opens
|
||||
/// again once no dialog is open ([`reopen`]).
|
||||
pub fn ask_about_stopped(app: &mut VectorcraftApp, item: &Item) {
|
||||
let item = item.to_json();
|
||||
app.ui.stopped_plugin = Some(item.clone());
|
||||
app.ui.dialog = Some(Dialog::new(STOPPED, json!({ "item": item })));
|
||||
}
|
||||
|
||||
/// With no dialog open, the choice about a stopped plug-in that another dialog replaced opens again.
|
||||
pub(super) fn reopen(app: &mut VectorcraftApp) {
|
||||
if app.ui.dialog.is_none()
|
||||
&& let Some(item) = app.ui.stopped_plugin.clone()
|
||||
{
|
||||
app.ui.dialog = Some(Dialog::new(STOPPED, json!({ "item": item })));
|
||||
}
|
||||
}
|
||||
|
||||
/// The choice's Cancel, Start in Safe Mode: Safe Mode stays on for this launch, and the plug-in
|
||||
/// stays pending for the next one.
|
||||
pub(super) fn cancel_stopped(app: &mut VectorcraftApp) {
|
||||
app.ui.stopped_plugin = None;
|
||||
}
|
||||
|
||||
/// The choice about a stopped plug-in is waiting for the user (shown, or replaced by another
|
||||
/// dialog): Safe Mode is on, and nothing has loaded yet.
|
||||
pub fn choosing(app: &VectorcraftApp) -> bool {
|
||||
app.ui.stopped_plugin.is_some()
|
||||
}
|
||||
|
||||
/// Safe Mode is on for the rest of this launch: `--safe-mode`, or Start in Safe Mode chosen.
|
||||
pub fn staying_on(app: &VectorcraftApp) -> bool {
|
||||
safe_mode::active() && !choosing(app)
|
||||
}
|
||||
|
||||
/// The item a dialog field holds.
|
||||
fn item_of(v: &Value) -> Item {
|
||||
let s = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string);
|
||||
Item { id: s("id"), file: s("file"), name: s("name") }
|
||||
}
|
||||
|
||||
/// The choice's Disable and Continue (OK) or Start Normally: Safe Mode ends and the plug-ins load,
|
||||
/// without the stopped one for Disable and Continue (kept off for later launches too).
|
||||
fn confirm_stopped(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
app.ui.dialog = None;
|
||||
app.ui.stopped_plugin = None;
|
||||
let item = item_of(d.fields.get("item").unwrap_or(&Value::Null));
|
||||
// When the disabled plug-in can't be saved for the next launch, that launch asks again.
|
||||
let saved = d.bool("normal") || (safe_mode::disable(item) && app.session.store_disabled_plugins());
|
||||
if saved {
|
||||
safe_mode::forget_previous();
|
||||
} else {
|
||||
not_saved(app);
|
||||
}
|
||||
safe_mode::set_active(None);
|
||||
vectorcraft_engine::cmd::plugin::load_prefs_folder(&mut app.session);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Help › Safe Mode: the disabled plug-ins, none ticked for Enable on Re-launch.
|
||||
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
let items: Vec<Value> = safe_mode::disabled()
|
||||
.iter()
|
||||
.map(|i| {
|
||||
let mut v = i.to_json();
|
||||
v["enable"] = json!(false);
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
app.ui.dialog = Some(Dialog::new(KIND, json!({ "items": items })));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Help › Safe Mode's OK: each ticked plug-in loads again from the next launch
|
||||
/// (`plugin.setEnabled`).
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
app.ui.dialog = None;
|
||||
let mut errors = vec![];
|
||||
let mut unsaved = false;
|
||||
for v in d.fields.get("items").and_then(Value::as_array).into_iter().flatten().filter(|v| v["enable"] == true) {
|
||||
let p = match (v["id"].as_str(), v["file"].as_str()) {
|
||||
(Some(id), _) => json!({"id": id, "enabled": true}),
|
||||
(None, Some(file)) => json!({"file": file, "enabled": true}),
|
||||
(None, None) => continue,
|
||||
};
|
||||
match app.run("plugin.setEnabled", p) {
|
||||
Ok(r) => unsaved |= r["saved"] == false,
|
||||
Err(e) => errors.push(e),
|
||||
}
|
||||
}
|
||||
if unsaved {
|
||||
not_saved(app);
|
||||
}
|
||||
if errors.is_empty() { Ok(Value::Null) } else { Err(errors.join("; ")) }
|
||||
}
|
||||
|
||||
/// The disabled plug-ins couldn't be written to the plug-in folder: the next launch doesn't have
|
||||
/// the change, which goes with the next change that saves.
|
||||
fn not_saved(app: &mut VectorcraftApp) {
|
||||
app.status("Couldn't save the disabled plug-ins for the next launch");
|
||||
}
|
||||
|
||||
pub(super) const STOPPED_SPEC: DialogSpec = DialogSpec {
|
||||
heading: |_| tl!("Safe Mode").into(),
|
||||
body: |_, ui, d| {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let name = item_of(d.fields.get("item").unwrap_or(&Value::Null)).label();
|
||||
let text = crate::i18n::fmt(
|
||||
tl!("The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it."),
|
||||
&[("name", &name)],
|
||||
);
|
||||
ui.label(egui::RichText::new(text).color(t.text));
|
||||
false
|
||||
},
|
||||
confirm: confirm_stopped,
|
||||
ok: Some("Disable and Continue"),
|
||||
cancel: Some("Start in Safe Mode"),
|
||||
alternate: Some(("Start Normally", "normal")),
|
||||
min_width: 380.0,
|
||||
max_width: Some(520.0),
|
||||
..DialogSpec::FORM
|
||||
};
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
heading: |_| tl!("Safe Mode").into(),
|
||||
body: |_, ui, d| {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
if safe_mode::active() {
|
||||
ui.label(egui::RichText::new(tl!("VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally.")).color(t.text));
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
ui.label(egui::RichText::new(tl!("Disabled plug-ins")).color(t.text_dim));
|
||||
let Some(items) = d.fields.get_mut("items").and_then(Value::as_array_mut).filter(|i| !i.is_empty()) else {
|
||||
ui.label(egui::RichText::new(tl!("No plug-ins are disabled.")).color(t.text_dim));
|
||||
return false;
|
||||
};
|
||||
egui::ScrollArea::vertical().max_height(240.0).show(ui, |ui| {
|
||||
for (k, v) in items.iter_mut().enumerate() {
|
||||
ui.horizontal(|ui| {
|
||||
// A name made safe to show: control and reordering characters removed, bounded.
|
||||
let label = safe_mode::display_name(v["label"].as_str().unwrap_or_default());
|
||||
ui.label(egui::RichText::new(label).color(t.text));
|
||||
let mut on = v["enable"] == true;
|
||||
if ui.push_id(k, |ui| ui.checkbox(&mut on, tl!("Enable on Re-launch"))).inner.changed() {
|
||||
v["enable"] = json!(on);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
false
|
||||
},
|
||||
confirm,
|
||||
min_width: 380.0,
|
||||
max_width: Some(520.0),
|
||||
..DialogSpec::FORM
|
||||
};
|
||||
@@ -6337,5 +6337,23 @@ breakText Glyphs Глифи
|
||||
3D Box 3D кутия
|
||||
3D Box Tool Инструмент за 3D кутия
|
||||
|
||||
# Safe Mode
|
||||
Safe Mode… Безопасен режим…
|
||||
Safe Mode Безопасен режим
|
||||
Help › Safe Mode… Помощ › Безопасен режим…
|
||||
Enable on Re-launch Включване при следващо стартиране
|
||||
The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. Последното стартиране на VectorCraft спря, докато се зареждаше добавката {name}. Възможно е тя да не е причината.
|
||||
Disable and Continue Изключване и продължаване
|
||||
Start Normally Нормално стартиране
|
||||
Start in Safe Mode Стартиране в безопасен режим
|
||||
VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft е в безопасен режим: добавките не се зареждат и не се изпълняват, докато не стартира нормално.
|
||||
Disabled plug-ins Изключени добавки
|
||||
No plug-ins are disabled. Няма изключени добавки.
|
||||
@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Безопасният режим е включен: добавките не се зареждат и не се изпълняват, докато VectorCraft не стартира нормално
|
||||
@msg give `id` or `file` (see plugin.safeMode) задайте `id` или `file` (вижте plugin.safeMode)
|
||||
@msg that plug-in isn't disabled (see plugin.safeMode) тази добавка не е изключена (вижте plugin.safeMode)
|
||||
@msg Couldn't save the disabled plug-ins for the next launch Изключените добавки не можаха да бъдат запазени за следващото стартиране
|
||||
@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Разширяване на облика: ефект на добавка в селекцията не може да бъде разширен в безопасен режим
|
||||
|
||||
# Align to Pixel Grid
|
||||
Align to Pixel Grid Подравняване към пикселната мрежа
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6387,5 +6387,23 @@ shape Open Otvoren
|
||||
3D Box 3D kutija
|
||||
3D Box Tool Alat za 3D kutiju
|
||||
|
||||
# Safe Mode
|
||||
Safe Mode… Sigurni način rada…
|
||||
Safe Mode Sigurni način rada
|
||||
Help › Safe Mode… Pomoć › Sigurni način rada…
|
||||
Enable on Re-launch Omogući pri sljedećem pokretanju
|
||||
The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. Posljednje pokretanje programa VectorCraft zaustavilo se dok se učitavao dodatak {name}. Možda on nije uzrok zaustavljanja.
|
||||
Disable and Continue Onemogući i nastavi
|
||||
Start Normally Pokreni normalno
|
||||
Start in Safe Mode Pokreni u sigurnom načinu rada
|
||||
VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft je u sigurnom načinu rada: dodaci se ne učitavaju niti pokreću dok se program ne pokrene normalno.
|
||||
Disabled plug-ins Onemogućeni dodaci
|
||||
No plug-ins are disabled. Nijedan dodatak nije onemogućen.
|
||||
@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Sigurni način rada je uključen: dodaci se ne učitavaju niti pokreću dok se VectorCraft ne pokrene normalno
|
||||
@msg give `id` or `file` (see plugin.safeMode) navedite `id` ili `file` (vidi plugin.safeMode)
|
||||
@msg that plug-in isn't disabled (see plugin.safeMode) taj dodatak nije onemogućen (vidi plugin.safeMode)
|
||||
@msg Couldn't save the disabled plug-ins for the next launch Onemogućeni dodaci nisu mogli biti sačuvani za sljedeće pokretanje
|
||||
@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Proširi izgled: efekat dodatka u odabiru ne može se proširiti u sigurnom načinu rada
|
||||
|
||||
# Align to Pixel Grid
|
||||
Align to Pixel Grid Poravnaj s mrežom piksela
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6502,6 +6502,24 @@ breakText Glyphs Glifs
|
||||
3D Box Caixa 3D
|
||||
3D Box Tool Eina Caixa 3D
|
||||
|
||||
# Safe Mode
|
||||
Safe Mode… Mode segur…
|
||||
Safe Mode Mode segur
|
||||
Help › Safe Mode… Ajuda › Mode segur…
|
||||
Enable on Re-launch Activa en el proper inici
|
||||
The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. L'últim inici de VectorCraft es va aturar mentre es carregava el complement {name}. Pot ser que no en sigui la causa.
|
||||
Disable and Continue Desactiva i continua
|
||||
Start Normally Inicia normalment
|
||||
Start in Safe Mode Inicia en mode segur
|
||||
VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft és en mode segur: cap complement no es carrega ni s'executa fins que s'iniciï normalment.
|
||||
Disabled plug-ins Complements desactivats
|
||||
No plug-ins are disabled. No hi ha cap complement desactivat.
|
||||
@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally El mode segur és actiu: els complements no es carreguen ni s'executen fins que VectorCraft s'iniciï normalment
|
||||
@msg give `id` or `file` (see plugin.safeMode) indiqueu `id` o `file` (vegeu plugin.safeMode)
|
||||
@msg that plug-in isn't disabled (see plugin.safeMode) aquest complement no està desactivat (vegeu plugin.safeMode)
|
||||
@msg Couldn't save the disabled plug-ins for the next launch No s’han pogut desar els complements desactivats per al proper inici
|
||||
@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Expandeix l’aparença: un efecte d’un complement de la selecció no es pot expandir en mode segur
|
||||
|
||||
# Align to Pixel Grid
|
||||
Align to Pixel Grid Alinea a la quadrícula de píxels
|
||||
@msg only paths and compound paths can be aligned to the pixel grid només els traçats i els traçats compostos es poden alinear a la quadrícula de píxels
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6519,6 +6519,24 @@ breakText Glyphs Glyfy
|
||||
3D Box 3D kvádr
|
||||
3D Box Tool Nástroj 3D kvádr
|
||||
|
||||
# Safe Mode
|
||||
Safe Mode… Nouzový režim…
|
||||
Safe Mode Nouzový režim
|
||||
Help › Safe Mode… Nápověda › Nouzový režim…
|
||||
Enable on Re-launch Povolit při příštím spuštění
|
||||
The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. Poslední spuštění aplikace VectorCraft se zastavilo během načítání zásuvného modulu {name}. Tento modul nemusí být příčinou.
|
||||
Disable and Continue Zakázat a pokračovat
|
||||
Start Normally Spustit normálně
|
||||
Start in Safe Mode Spustit v nouzovém režimu
|
||||
VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft je v nouzovém režimu: zásuvné moduly se nenačítají ani nespouštějí, dokud se aplikace nespustí normálně.
|
||||
Disabled plug-ins Zakázané zásuvné moduly
|
||||
No plug-ins are disabled. Žádné zásuvné moduly nejsou zakázány.
|
||||
@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Nouzový režim je zapnutý: zásuvné moduly se nenačítají ani nespouštějí, dokud se VectorCraft nespustí normálně
|
||||
@msg give `id` or `file` (see plugin.safeMode) zadejte `id` nebo `file` (viz plugin.safeMode)
|
||||
@msg that plug-in isn't disabled (see plugin.safeMode) tento zásuvný modul není zakázán (viz plugin.safeMode)
|
||||
@msg Couldn't save the disabled plug-ins for the next launch Zakázané zásuvné moduly nebylo možné uložit pro příští spuštění
|
||||
@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Rozložit vzhled: efekt zásuvného modulu ve výběru nelze v nouzovém režimu rozložit
|
||||
|
||||
# Align to Pixel Grid
|
||||
Align to Pixel Grid Zarovnat na mřížku obrazových bodů
|
||||
@msg only paths and compound paths can be aligned to the pixel grid na mřížku obrazových bodů lze zarovnat jen cesty a složené cesty
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6351,5 +6351,23 @@ breakText Glyphs Glyffer
|
||||
3D Box 3D-boks
|
||||
3D Box Tool 3D-boksværktøj
|
||||
|
||||
# Safe Mode
|
||||
Safe Mode… Sikker tilstand…
|
||||
Safe Mode Sikker tilstand
|
||||
Help › Safe Mode… Hjælp › Sikker tilstand…
|
||||
Enable on Re-launch Aktivér ved næste start
|
||||
The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. Sidste start af VectorCraft stoppede, mens plug-in'et {name} blev indlæst. Det er måske ikke årsagen.
|
||||
Disable and Continue Deaktiver og fortsæt
|
||||
Start Normally Start normalt
|
||||
Start in Safe Mode Start i sikker tilstand
|
||||
VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft er i sikker tilstand: ingen plug-ins indlæses eller køres, før programmet startes normalt.
|
||||
Disabled plug-ins Deaktiverede plug-ins
|
||||
No plug-ins are disabled. Ingen plug-ins er deaktiveret.
|
||||
@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Sikker tilstand er slået til: plug-ins indlæses og køres ikke, før VectorCraft startes normalt
|
||||
@msg give `id` or `file` (see plugin.safeMode) angiv `id` eller `file` (se plugin.safeMode)
|
||||
@msg that plug-in isn't disabled (see plugin.safeMode) det plug-in er ikke deaktiveret (se plugin.safeMode)
|
||||
@msg Couldn't save the disabled plug-ins for the next launch De deaktiverede plug-ins kunne ikke gemmes til næste start
|
||||
@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Udvid udseende: en plug-in-effekt i markeringen kan ikke udvides i sikker tilstand
|
||||
|
||||
# Align to Pixel Grid
|
||||
Align to Pixel Grid Juster efter pixelgitter
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6105,6 +6105,24 @@ breakText Glyphs Glyphen
|
||||
3D Box 3D-Quader
|
||||
3D Box Tool 3D-Quader-Werkzeug
|
||||
|
||||
# Safe Mode
|
||||
Safe Mode… Abgesicherter Modus…
|
||||
Safe Mode Abgesicherter Modus
|
||||
Help › Safe Mode… Hilfe › Abgesicherter Modus…
|
||||
Enable on Re-launch Beim nächsten Start aktivieren
|
||||
The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. Der letzte Start von VectorCraft wurde beendet, während das Plug-in {name} geladen wurde. Es ist möglicherweise nicht die Ursache.
|
||||
Disable and Continue Deaktivieren und fortfahren
|
||||
Start Normally Normal starten
|
||||
Start in Safe Mode Im abgesicherten Modus starten
|
||||
VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft läuft im abgesicherten Modus: Plug-ins werden weder geladen noch ausgeführt, bis VectorCraft normal startet.
|
||||
Disabled plug-ins Deaktivierte Plug-ins
|
||||
No plug-ins are disabled. Keine Plug-ins sind deaktiviert.
|
||||
@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Der abgesicherte Modus ist aktiv: Plug-ins werden weder geladen noch ausgeführt, bis VectorCraft normal startet
|
||||
@msg give `id` or `file` (see plugin.safeMode) `id` oder `file` angeben (siehe plugin.safeMode)
|
||||
@msg that plug-in isn't disabled (see plugin.safeMode) dieses Plug-in ist nicht deaktiviert (siehe plugin.safeMode)
|
||||
@msg Couldn't save the disabled plug-ins for the next launch Die deaktivierten Plug-ins konnten nicht für den nächsten Start gespeichert werden
|
||||
@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Aussehen umwandeln: Ein Plug-in-Effekt in der Auswahl kann im abgesicherten Modus nicht umgewandelt werden
|
||||
|
||||
# Align to Pixel Grid
|
||||
Align to Pixel Grid Am Pixelraster ausrichten
|
||||
@msg only paths and compound paths can be aligned to the pixel grid nur Pfade und zusammengesetzte Pfade können am Pixelraster ausgerichtet werden
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6471,6 +6471,24 @@ breakText Glyphs Γλύφοι
|
||||
3D Box Κουτί 3D
|
||||
3D Box Tool Εργαλείο κουτιού 3D
|
||||
|
||||
# Safe Mode
|
||||
Safe Mode… Ασφαλής λειτουργία…
|
||||
Safe Mode Ασφαλής λειτουργία
|
||||
Help › Safe Mode… Βοήθεια › Ασφαλής λειτουργία…
|
||||
Enable on Re-launch Ενεργοποίηση στην επόμενη εκκίνηση
|
||||
The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. Η τελευταία εκκίνηση του VectorCraft σταμάτησε ενώ φορτωνόταν το πρόσθετο {name}. Ίσως δεν είναι αυτό η αιτία.
|
||||
Disable and Continue Απενεργοποίηση και συνέχεια
|
||||
Start Normally Κανονική εκκίνηση
|
||||
Start in Safe Mode Εκκίνηση σε ασφαλή λειτουργία
|
||||
VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. Το VectorCraft είναι σε ασφαλή λειτουργία: κανένα πρόσθετο δεν φορτώνεται ούτε εκτελείται μέχρι να ξεκινήσει κανονικά.
|
||||
Disabled plug-ins Απενεργοποιημένα πρόσθετα
|
||||
No plug-ins are disabled. Δεν υπάρχουν απενεργοποιημένα πρόσθετα.
|
||||
@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Η ασφαλής λειτουργία είναι ενεργή: τα πρόσθετα δεν φορτώνονται ούτε εκτελούνται μέχρι το VectorCraft να ξεκινήσει κανονικά
|
||||
@msg give `id` or `file` (see plugin.safeMode) δώστε `id` ή `file` (δείτε plugin.safeMode)
|
||||
@msg that plug-in isn't disabled (see plugin.safeMode) αυτό το πρόσθετο δεν είναι απενεργοποιημένο (δείτε plugin.safeMode)
|
||||
@msg Couldn't save the disabled plug-ins for the next launch Δεν ήταν δυνατή η αποθήκευση των απενεργοποιημένων πρόσθετων για την επόμενη εκκίνηση
|
||||
@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Επέκταση εμφάνισης: ένα εφέ πρόσθετου στην επιλογή δεν μπορεί να επεκταθεί σε ασφαλή λειτουργία
|
||||
|
||||
# Align to Pixel Grid
|
||||
Align to Pixel Grid Στοίχιση στο πλέγμα εικονοστοιχείων
|
||||
@msg only paths and compound paths can be aligned to the pixel grid μόνο διαδρομές και σύνθετες διαδρομές μπορούν να στοιχιστούν στο πλέγμα εικονοστοιχείων
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 168 and column 5.
|
@@ -6461,6 +6461,24 @@ breakText Glyphs Glifos
|
||||
3D Box Caja 3D
|
||||
3D Box Tool Herramienta Caja 3D
|
||||
|
||||
# Safe Mode
|
||||
Safe Mode… Modo seguro…
|
||||
Safe Mode Modo seguro
|
||||
Help › Safe Mode… Ayuda › Modo seguro…
|
||||
Enable on Re-launch Activar en el próximo inicio
|
||||
The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. El último inicio de VectorCraft se detuvo mientras se cargaba el plugin {name}. Puede que no sea la causa.
|
||||
Disable and Continue Desactivar y continuar
|
||||
Start Normally Iniciar normalmente
|
||||
Start in Safe Mode Iniciar en modo seguro
|
||||
VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft está en modo seguro: ningún plugin se carga ni se ejecuta hasta que se inicie normalmente.
|
||||
Disabled plug-ins Plugins desactivados
|
||||
No plug-ins are disabled. No hay plugins desactivados.
|
||||
@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally El modo seguro está activado: los plugins no se cargan ni se ejecutan hasta que VectorCraft se inicie normalmente
|
||||
@msg give `id` or `file` (see plugin.safeMode) indica `id` o `file` (consulta plugin.safeMode)
|
||||
@msg that plug-in isn't disabled (see plugin.safeMode) ese plugin no está desactivado (consulta plugin.safeMode)
|
||||
@msg Couldn't save the disabled plug-ins for the next launch No se pudieron guardar los plugins desactivados para el próximo inicio
|
||||
@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Expandir apariencia: un efecto de plugin de la selección no se puede expandir en modo seguro
|
||||
|
||||
# Align to Pixel Grid
|
||||
Align to Pixel Grid Alinear con cuadrícula de píxeles
|
||||
@msg only paths and compound paths can be aligned to the pixel grid solo los trazados y los trazados compuestos se pueden alinear con la cuadrícula de píxeles
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6554,6 +6554,24 @@ breakText Glyphs Merkit
|
||||
3D Box 3D-laatikko
|
||||
3D Box Tool 3D-laatikkotyökalu
|
||||
|
||||
# Safe Mode
|
||||
Safe Mode… Vikasietotila…
|
||||
Safe Mode Vikasietotila
|
||||
Help › Safe Mode… Ohje › Vikasietotila…
|
||||
Enable on Re-launch Ota käyttöön seuraavassa käynnistyksessä
|
||||
The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. VectorCraftin edellinen käynnistys pysähtyi, kun laajennusta {name} ladattiin. Se ei välttämättä ole syy.
|
||||
Disable and Continue Poista käytöstä ja jatka
|
||||
Start Normally Käynnistä normaalisti
|
||||
Start in Safe Mode Käynnistä vikasietotilassa
|
||||
VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft on vikasietotilassa: laajennuksia ei ladata eikä suoriteta ennen kuin se käynnistetään normaalisti.
|
||||
Disabled plug-ins Käytöstä poistetut laajennukset
|
||||
No plug-ins are disabled. Yhtään laajennusta ei ole poistettu käytöstä.
|
||||
@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Vikasietotila on käytössä: laajennuksia ei ladata eikä suoriteta ennen kuin VectorCraft käynnistetään normaalisti
|
||||
@msg give `id` or `file` (see plugin.safeMode) anna `id` tai `file` (ks. plugin.safeMode)
|
||||
@msg that plug-in isn't disabled (see plugin.safeMode) laajennusta ei ole poistettu käytöstä (ks. plugin.safeMode)
|
||||
@msg Couldn't save the disabled plug-ins for the next launch Käytöstä poistettuja laajennuksia ei voitu tallentaa seuraavaa käynnistystä varten
|
||||
@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Laajenna ulkoasu: valinnan laajennustehostetta ei voi laajentaa vikasietotilassa
|
||||
|
||||
# Align to Pixel Grid
|
||||
Align to Pixel Grid Tasaa pikseliruudukkoon
|
||||
@msg only paths and compound paths can be aligned to the pixel grid vain polut ja yhdistelmäpolut voi tasata pikseliruudukkoon
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6514,6 +6514,24 @@ breakText Glyphs Glyphes
|
||||
3D Box Boîte 3D
|
||||
3D Box Tool Outil Boîte 3D
|
||||
|
||||
# Safe Mode
|
||||
Safe Mode… Mode sans échec…
|
||||
Safe Mode Mode sans échec
|
||||
Help › Safe Mode… Aide › Mode sans échec…
|
||||
Enable on Re-launch Activer au prochain lancement
|
||||
The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. Le dernier lancement de VectorCraft s’est arrêté pendant le chargement du module externe {name}. Ce module n’en est peut-être pas la cause.
|
||||
Disable and Continue Désactiver et continuer
|
||||
Start Normally Démarrer normalement
|
||||
Start in Safe Mode Démarrer en mode sans échec
|
||||
VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft est en mode sans échec : aucun module externe n’est chargé ni exécuté avant un démarrage normal.
|
||||
Disabled plug-ins Modules externes désactivés
|
||||
No plug-ins are disabled. Aucun module externe n’est désactivé.
|
||||
@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Le mode sans échec est activé : les modules externes ne sont ni chargés ni exécutés avant que VectorCraft démarre normalement
|
||||
@msg give `id` or `file` (see plugin.safeMode) indiquez `id` ou `file` (voir plugin.safeMode)
|
||||
@msg that plug-in isn't disabled (see plugin.safeMode) ce module externe n’est pas désactivé (voir plugin.safeMode)
|
||||
@msg Couldn't save the disabled plug-ins for the next launch Impossible d’enregistrer les modules externes désactivés pour le prochain lancement
|
||||
@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Décomposer l’aspect : un effet de module externe de la sélection ne peut pas être décomposé en mode sans échec
|
||||
|
||||
# Align to Pixel Grid
|
||||
Align to Pixel Grid Aligner sur la grille de pixels
|
||||
@msg only paths and compound paths can be aligned to the pixel grid seuls les tracés et les tracés transparents peuvent être alignés sur la grille de pixels
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -7976,6 +7976,24 @@ swatch Yellow पीला
|
||||
swatch Yellow Green पीला हरा
|
||||
theme Appearance अपीयरेंस
|
||||
|
||||
# Safe Mode
|
||||
Safe Mode… सुरक्षित मोड…
|
||||
Safe Mode सुरक्षित मोड
|
||||
Help › Safe Mode… मदद › सुरक्षित मोड…
|
||||
Enable on Re-launch अगली बार शुरू होने पर सक्षम करें
|
||||
The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. VectorCraft का पिछला लॉन्च प्लग-इन {name} के लोड होते समय रुक गया। हो सकता है कि वह इसका कारण न हो।
|
||||
Disable and Continue अक्षम करें और जारी रखें
|
||||
Start Normally सामान्य रूप से शुरू करें
|
||||
Start in Safe Mode सुरक्षित मोड में शुरू करें
|
||||
VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft सुरक्षित मोड में है: जब तक यह सामान्य रूप से शुरू नहीं होता, कोई भी प्लग-इन लोड या चलाया नहीं जाता।
|
||||
Disabled plug-ins अक्षम प्लग-इन
|
||||
No plug-ins are disabled. कोई प्लग-इन अक्षम नहीं है।
|
||||
@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally सुरक्षित मोड चालू है: जब तक VectorCraft सामान्य रूप से शुरू नहीं होता, प्लग-इन लोड या चलाए नहीं जाते
|
||||
@msg give `id` or `file` (see plugin.safeMode) `id` या `file` दें (plugin.safeMode देखें)
|
||||
@msg that plug-in isn't disabled (see plugin.safeMode) वह प्लग-इन अक्षम नहीं है (plugin.safeMode देखें)
|
||||
@msg Couldn't save the disabled plug-ins for the next launch अगली बार लॉन्च के लिए अक्षम प्लग-इन सहेजे नहीं जा सके
|
||||
@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode उपस्थिति का विस्तार: सिलेक्शन में किसी प्लग-इन इफ़ेक्ट का सुरक्षित मोड में विस्तार नहीं किया जा सकता
|
||||
|
||||
# Align to Pixel Grid
|
||||
Align to Pixel Grid पिक्सेल ग्रिड से संरेखित करें
|
||||
@msg only paths and compound paths can be aligned to the pixel grid केवल पाथ और कंपाउंड पाथ को पिक्सेल ग्रिड से संरेखित किया जा सकता है
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -6503,6 +6503,24 @@ breakText Glyphs Glifák
|
||||
3D Box 3D doboz
|
||||
3D Box Tool 3D doboz eszköz
|
||||
|
||||
# Safe Mode
|
||||
Safe Mode… Csökkentett mód…
|
||||
Safe Mode Csökkentett mód
|
||||
Help › Safe Mode… Súgó › Csökkentett mód…
|
||||
Enable on Re-launch Engedélyezés a következő indításkor
|
||||
The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. A VectorCraft legutóbbi indítása leállt, miközben a(z) {name} bővítmény betöltődött. Lehet, hogy nem ez okozta.
|
||||
Disable and Continue Letiltás és folytatás
|
||||
Start Normally Normál indítás
|
||||
Start in Safe Mode Indítás csökkentett módban
|
||||
VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. A VectorCraft csökkentett módban fut: a bővítmények nem töltődnek be és nem futnak, amíg normál módban el nem indul.
|
||||
Disabled plug-ins Letiltott bővítmények
|
||||
No plug-ins are disabled. Nincs letiltott bővítmény.
|
||||
@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally A csökkentett mód be van kapcsolva: a bővítmények nem töltődnek be és nem futnak, amíg a VectorCraft normál módban el nem indul
|
||||
@msg give `id` or `file` (see plugin.safeMode) adja meg: `id` vagy `file` (lásd: plugin.safeMode)
|
||||
@msg that plug-in isn't disabled (see plugin.safeMode) ez a bővítmény nincs letiltva (lásd: plugin.safeMode)
|
||||
@msg Couldn't save the disabled plug-ins for the next launch A letiltott bővítményeket nem sikerült menteni a következő indításhoz
|
||||
@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Megjelenés kibontása: a kijelölésben lévő bővítményeffektus csökkentett módban nem bontható ki
|
||||
|
||||
# Align to Pixel Grid
|
||||
Align to Pixel Grid Igazítás a pixelrácshoz
|
||||
@msg only paths and compound paths can be aligned to the pixel grid csak útvonalak és összetett útvonalak igazíthatók a pixelrácshoz
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6351,5 +6351,23 @@ breakText Glyphs Glif
|
||||
3D Box Kotak 3D
|
||||
3D Box Tool Alat Kotak 3D
|
||||
|
||||
# Safe Mode
|
||||
Safe Mode… Mode aman…
|
||||
Safe Mode Mode aman
|
||||
Help › Safe Mode… Bantuan › Mode aman…
|
||||
Enable on Re-launch Aktifkan saat dijalankan berikutnya
|
||||
The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. Peluncuran terakhir VectorCraft berhenti saat plug-in {name} sedang dimuat. Plug-in itu mungkin bukan penyebabnya.
|
||||
Disable and Continue Nonaktifkan dan lanjutkan
|
||||
Start Normally Mulai secara normal
|
||||
Start in Safe Mode Mulai dalam mode aman
|
||||
VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft dalam mode aman: tidak ada plug-in yang dimuat atau dijalankan sampai aplikasi dimulai secara normal.
|
||||
Disabled plug-ins Plug-in yang dinonaktifkan
|
||||
No plug-ins are disabled. Tidak ada plug-in yang dinonaktifkan.
|
||||
@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Mode aman aktif: plug-in tidak dimuat atau dijalankan sampai VectorCraft dimulai secara normal
|
||||
@msg give `id` or `file` (see plugin.safeMode) berikan `id` atau `file` (lihat plugin.safeMode)
|
||||
@msg that plug-in isn't disabled (see plugin.safeMode) plug-in itu tidak dinonaktifkan (lihat plugin.safeMode)
|
||||
@msg Couldn't save the disabled plug-ins for the next launch Plug-in yang dinonaktifkan tidak dapat disimpan untuk peluncuran berikutnya
|
||||
@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Perluas tampilan: efek plug-in dalam seleksi tidak dapat diperluas dalam mode aman
|
||||
|
||||
# Align to Pixel Grid
|
||||
Align to Pixel Grid Sejajarkan ke grid piksel
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2270 and column 5.
|
@@ -6139,6 +6139,24 @@ breakText Glyphs Glifi
|
||||
3D Box Scatola 3D
|
||||
3D Box Tool Strumento Scatola 3D
|
||||
|
||||
# Safe Mode
|
||||
Safe Mode… Modalità provvisoria…
|
||||
Safe Mode Modalità provvisoria
|
||||
Help › Safe Mode… Aiuto › Modalità provvisoria…
|
||||
Enable on Re-launch Attiva al prossimo avvio
|
||||
The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. L'ultimo avvio di VectorCraft si è interrotto durante il caricamento del plug-in {name}. Potrebbe non esserne la causa.
|
||||
Disable and Continue Disattiva e continua
|
||||
Start Normally Avvia normalmente
|
||||
Start in Safe Mode Avvia in modalità provvisoria
|
||||
VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft è in modalità provvisoria: nessun plug-in viene caricato o eseguito finché non si avvia normalmente.
|
||||
Disabled plug-ins Plug-in disattivati
|
||||
No plug-ins are disabled. Nessun plug-in disattivato.
|
||||
@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally La modalità provvisoria è attiva: i plug-in non vengono caricati né eseguiti finché VectorCraft non si avvia normalmente
|
||||
@msg give `id` or `file` (see plugin.safeMode) indica `id` o `file` (vedi plugin.safeMode)
|
||||
@msg that plug-in isn't disabled (see plugin.safeMode) quel plug-in non è disattivato (vedi plugin.safeMode)
|
||||
@msg Couldn't save the disabled plug-ins for the next launch Impossibile salvare i plug-in disattivati per il prossimo avvio
|
||||
@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Espandi aspetto: un effetto plug-in nella selezione non può essere espanso in modalità provvisoria
|
||||
|
||||
# Align to Pixel Grid
|
||||
Align to Pixel Grid Allinea alla griglia pixel
|
||||
@msg only paths and compound paths can be aligned to the pixel grid solo i tracciati e i tracciati composti possono essere allineati alla griglia pixel
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6204,6 +6204,24 @@ breakText Glyphs 字形
|
||||
3D Box 3Dボックス
|
||||
3D Box Tool 3Dボックスツール
|
||||
|
||||
# Safe Mode
|
||||
Safe Mode… セーフモード…
|
||||
Safe Mode セーフモード
|
||||
Help › Safe Mode… ヘルプ › セーフモード…
|
||||
Enable on Re-launch 次回起動時に有効にする
|
||||
The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. 前回の VectorCraft の起動は、プラグイン {name} の読み込み中に停止しました。これが原因とは限りません。
|
||||
Disable and Continue 無効にして続行
|
||||
Start Normally 通常どおり起動
|
||||
Start in Safe Mode セーフモードで起動
|
||||
VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft はセーフモードです: 通常どおり起動するまで、プラグインは読み込まれず実行もされません。
|
||||
Disabled plug-ins 無効なプラグイン
|
||||
No plug-ins are disabled. 無効なプラグインはありません。
|
||||
@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally セーフモードがオンです: VectorCraft が通常どおり起動するまで、プラグインは読み込まれず実行もされません
|
||||
@msg give `id` or `file` (see plugin.safeMode) `id` または `file` を指定してください (plugin.safeMode を参照)
|
||||
@msg that plug-in isn't disabled (see plugin.safeMode) そのプラグインは無効になっていません (plugin.safeMode を参照)
|
||||
@msg Couldn't save the disabled plug-ins for the next launch 無効なプラグインを次回の起動用に保存できませんでした
|
||||
@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode アピアランスを分割: 選択範囲のプラグイン効果はセーフモードでは分割できません
|
||||
|
||||
# Align to Pixel Grid
|
||||
Align to Pixel Grid ピクセルグリッドに整合
|
||||
@msg only paths and compound paths can be aligned to the pixel grid ピクセルグリッドに整合できるのはパスと複合パスのみです
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6531,6 +6531,24 @@ font-script Japanese იაპონური
|
||||
3D Box 3D ყუთი
|
||||
3D Box Tool 3D ყუთის ხელსაწყო
|
||||
|
||||
# Safe Mode
|
||||
Safe Mode… უსაფრთხო რეჟიმი…
|
||||
Safe Mode უსაფრთხო რეჟიმი
|
||||
Help › Safe Mode… დახმარება › უსაფრთხო რეჟიმი…
|
||||
Enable on Re-launch ჩართვა შემდეგ გაშვებისას
|
||||
The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. VectorCraft-ის ბოლო გაშვება შეჩერდა, როცა პლაგინი {name} იტვირთებოდა. შესაძლოა, მიზეზი ის არ იყოს.
|
||||
Disable and Continue გამორთვა და გაგრძელება
|
||||
Start Normally ჩვეულებრივ გაშვება
|
||||
Start in Safe Mode უსაფრთხო რეჟიმში გაშვება
|
||||
VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft უსაფრთხო რეჟიმშია: პლაგინები არ იტვირთება და არ ეშვება, სანამ პროგრამა ჩვეულებრივ არ გაეშვება.
|
||||
Disabled plug-ins გამორთული პლაგინები
|
||||
No plug-ins are disabled. გამორთული პლაგინები არ არის.
|
||||
@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally უსაფრთხო რეჟიმი ჩართულია: პლაგინები არ იტვირთება და არ ეშვება, სანამ VectorCraft ჩვეულებრივ არ გაეშვება
|
||||
@msg give `id` or `file` (see plugin.safeMode) მიუთითეთ `id` ან `file` (იხ. plugin.safeMode)
|
||||
@msg that plug-in isn't disabled (see plugin.safeMode) ეს პლაგინი გამორთული არ არის (იხ. plugin.safeMode)
|
||||
@msg Couldn't save the disabled plug-ins for the next launch გამორთული პლაგინების შენახვა შემდეგი გაშვებისთვის ვერ მოხერხდა
|
||||
@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode იერსახის გაშლა: მონიშნულში პლაგინის ეფექტის გაშლა უსაფრთხო რეჟიმში შეუძლებელია
|
||||
|
||||
# Align to Pixel Grid
|
||||
Align to Pixel Grid პიქსელურ ბადეზე გასწორება
|
||||
@msg only paths and compound paths can be aligned to the pixel grid პიქსელურ ბადეზე მხოლოდ ტრაექტორიების და შედგენილი ტრაექტორიების გასწორებაა შესაძლებელი
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -6100,6 +6100,24 @@ breakText Glyphs 글리프
|
||||
3D Box 3D 상자
|
||||
3D Box Tool 3D 상자 도구
|
||||
|
||||
# Safe Mode
|
||||
Safe Mode… 안전 모드…
|
||||
Safe Mode 안전 모드
|
||||
Help › Safe Mode… 도움말 › 안전 모드…
|
||||
Enable on Re-launch 다음 실행 시 활성화
|
||||
The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. 지난번 VectorCraft 실행이 플러그인 {name}을(를) 불러오는 중에 중지되었습니다. 이 플러그인이 원인이 아닐 수도 있습니다.
|
||||
Disable and Continue 비활성화하고 계속
|
||||
Start Normally 정상적으로 시작
|
||||
Start in Safe Mode 안전 모드로 시작
|
||||
VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft가 안전 모드입니다. 정상적으로 시작할 때까지 플러그인을 불러오거나 실행하지 않습니다.
|
||||
Disabled plug-ins 비활성화된 플러그인
|
||||
No plug-ins are disabled. 비활성화된 플러그인이 없습니다.
|
||||
@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally 안전 모드가 켜져 있습니다. VectorCraft가 정상적으로 시작할 때까지 플러그인을 불러오거나 실행하지 않습니다
|
||||
@msg give `id` or `file` (see plugin.safeMode) `id` 또는 `file`을 지정하세요(plugin.safeMode 참조)
|
||||
@msg that plug-in isn't disabled (see plugin.safeMode) 해당 플러그인은 비활성화되어 있지 않습니다(plugin.safeMode 참조)
|
||||
@msg Couldn't save the disabled plug-ins for the next launch 비활성화된 플러그인을 다음 실행을 위해 저장할 수 없습니다
|
||||
@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode 모양새 확장: 선택 항목의 플러그인 효과는 안전 모드에서 확장할 수 없습니다
|
||||
|
||||
# Align to Pixel Grid
|
||||
Align to Pixel Grid 픽셀 격자에 정렬
|
||||
@msg only paths and compound paths can be aligned to the pixel grid 패스와 컴파운드 패스만 픽셀 격자에 정렬할 수 있습니다
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6397,5 +6397,23 @@ breakText Glyphs Glyphs
|
||||
3D Box 3D-doos
|
||||
3D Box Tool 3D-doosgereedschap
|
||||
|
||||
# Safe Mode
|
||||
Safe Mode… Veilige modus…
|
||||
Safe Mode Veilige modus
|
||||
Help › Safe Mode… Help › Veilige modus…
|
||||
Enable on Re-launch Inschakelen bij volgende start
|
||||
The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. De laatste start van VectorCraft stopte terwijl de plug-in {name} werd geladen. Deze is mogelijk niet de oorzaak.
|
||||
Disable and Continue Uitschakelen en doorgaan
|
||||
Start Normally Normaal starten
|
||||
Start in Safe Mode Starten in veilige modus
|
||||
VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft is in veilige modus: er worden geen plug-ins geladen of uitgevoerd totdat het normaal start.
|
||||
Disabled plug-ins Uitgeschakelde plug-ins
|
||||
No plug-ins are disabled. Er zijn geen plug-ins uitgeschakeld.
|
||||
@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Veilige modus is aan: plug-ins worden niet geladen of uitgevoerd totdat VectorCraft normaal start
|
||||
@msg give `id` or `file` (see plugin.safeMode) geef `id` of `file` op (zie plugin.safeMode)
|
||||
@msg that plug-in isn't disabled (see plugin.safeMode) die plug-in is niet uitgeschakeld (zie plugin.safeMode)
|
||||
@msg Couldn't save the disabled plug-ins for the next launch De uitgeschakelde plug-ins konden niet worden opgeslagen voor de volgende start
|
||||
@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Vormgeving uitbreiden: een plug-in-effect in de selectie kan niet worden uitgebreid in veilige modus
|
||||
|
||||
# Align to Pixel Grid
|
||||
Align to Pixel Grid Uitlijnen op pixelraster
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6374,6 +6374,24 @@ breakText Glyphs Glify
|
||||
3D Box Bryła 3D
|
||||
3D Box Tool Narzędzie Bryła 3D
|
||||
|
||||
# Safe Mode
|
||||
Safe Mode… Tryb awaryjny…
|
||||
Safe Mode Tryb awaryjny
|
||||
Help › Safe Mode… Pomoc › Tryb awaryjny…
|
||||
Enable on Re-launch Włącz przy następnym uruchomieniu
|
||||
The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. Ostatnie uruchomienie programu VectorCraft zatrzymało się podczas wczytywania wtyczki {name}. Może ona nie być przyczyną.
|
||||
Disable and Continue Wyłącz i kontynuuj
|
||||
Start Normally Uruchom normalnie
|
||||
Start in Safe Mode Uruchom w trybie awaryjnym
|
||||
VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft działa w trybie awaryjnym: żadne wtyczki nie są wczytywane ani uruchamiane, dopóki program nie zostanie uruchomiony normalnie.
|
||||
Disabled plug-ins Wyłączone wtyczki
|
||||
No plug-ins are disabled. Żadna wtyczka nie jest wyłączona.
|
||||
@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Tryb awaryjny jest włączony: wtyczki nie są wczytywane ani uruchamiane, dopóki VectorCraft nie zostanie uruchomiony normalnie
|
||||
@msg give `id` or `file` (see plugin.safeMode) podaj `id` lub `file` (patrz plugin.safeMode)
|
||||
@msg that plug-in isn't disabled (see plugin.safeMode) ta wtyczka nie jest wyłączona (patrz plugin.safeMode)
|
||||
@msg Couldn't save the disabled plug-ins for the next launch Nie można zapisać wyłączonych wtyczek na następne uruchomienie
|
||||
@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Rozszerz wygląd: efektu wtyczki w zaznaczeniu nie można rozszerzyć w trybie awaryjnym
|
||||
|
||||
# Align to Pixel Grid
|
||||
Align to Pixel Grid Wyrównaj do siatki pikseli
|
||||
@msg only paths and compound paths can be aligned to the pixel grid do siatki pikseli można wyrównać tylko ścieżki i ścieżki złożone
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6592,6 +6592,24 @@ swatch Yellow Green Verde-amarelado
|
||||
3D Box Caixa 3D
|
||||
3D Box Tool Ferramenta Caixa 3D
|
||||
|
||||
# Safe Mode
|
||||
Safe Mode… Modo de Segurança…
|
||||
Safe Mode Modo de Segurança
|
||||
Help › Safe Mode… Ajuda › Modo de Segurança…
|
||||
Enable on Re-launch Ativar na Próxima Inicialização
|
||||
The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. A última inicialização do VectorCraft parou enquanto o plug-in {name} era carregado. Ele pode não ser a causa.
|
||||
Disable and Continue Desativar e Continuar
|
||||
Start Normally Iniciar Normalmente
|
||||
Start in Safe Mode Iniciar no Modo de Segurança
|
||||
VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. O VectorCraft está no Modo de Segurança: nenhum plug-in é carregado ou executado até que ele seja iniciado normalmente.
|
||||
Disabled plug-ins Plug-ins desativados
|
||||
No plug-ins are disabled. Nenhum plug-in está desativado.
|
||||
@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally O Modo de Segurança está ativado: os plug-ins não são carregados nem executados até que o VectorCraft seja iniciado normalmente
|
||||
@msg give `id` or `file` (see plugin.safeMode) informe `id` ou `file` (veja plugin.safeMode)
|
||||
@msg that plug-in isn't disabled (see plugin.safeMode) esse plug-in não está desativado (veja plugin.safeMode)
|
||||
@msg Couldn't save the disabled plug-ins for the next launch Não foi possível salvar os plug-ins desativados para a próxima inicialização
|
||||
@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Expandir aparência: um efeito de plug-in na seleção não pode ser expandido no Modo de Segurança
|
||||
|
||||
# Align to Pixel Grid
|
||||
Align to Pixel Grid Alinhar à Grade de Pixels
|
||||
@msg only paths and compound paths can be aligned to the pixel grid somente caminhos e caminhos compostos podem ser alinhados à grade de pixels
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -6608,6 +6608,24 @@ shape Open Открытый
|
||||
3D Box 3D-параллелепипед
|
||||
3D Box Tool Инструмент «3D-параллелепипед»
|
||||
|
||||
# Safe Mode
|
||||
Safe Mode… Безопасный режим…
|
||||
Safe Mode Безопасный режим
|
||||
Help › Safe Mode… Справка › Безопасный режим…
|
||||
Enable on Re-launch Включить при следующем запуске
|
||||
The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. Последний запуск VectorCraft остановился во время загрузки модуля {name}. Возможно, причина не в нём.
|
||||
Disable and Continue Отключить и продолжить
|
||||
Start Normally Запустить в обычном режиме
|
||||
Start in Safe Mode Запустить в безопасном режиме
|
||||
VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft работает в безопасном режиме: модули не загружаются и не выполняются, пока программа не будет запущена в обычном режиме.
|
||||
Disabled plug-ins Отключённые модули
|
||||
No plug-ins are disabled. Нет отключённых модулей.
|
||||
@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Безопасный режим включён: модули не загружаются и не выполняются, пока VectorCraft не будет запущен в обычном режиме
|
||||
@msg give `id` or `file` (see plugin.safeMode) укажите `id` или `file` (см. plugin.safeMode)
|
||||
@msg that plug-in isn't disabled (see plugin.safeMode) этот модуль не отключён (см. plugin.safeMode)
|
||||
@msg Couldn't save the disabled plug-ins for the next launch Не удалось сохранить отключённые модули для следующего запуска
|
||||
@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Разбор оформления: эффект модуля в выделении нельзя разобрать в безопасном режиме
|
||||
|
||||
# Align to Pixel Grid
|
||||
Align to Pixel Grid Выровнять по пиксельной сетке
|
||||
@msg only paths and compound paths can be aligned to the pixel grid по пиксельной сетке можно выровнять только контуры и составные контуры
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -8006,6 +8006,24 @@ digits Hindi Hindi
|
||||
@msg zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End) zone behöver ett ankare: docka bredvid en panel med anchor eller i en ny dockkolumn med side (Start eller End)
|
||||
@msg zoom must be positive zoom måste vara positiv
|
||||
|
||||
# Safe Mode
|
||||
Safe Mode… Felsäkert läge…
|
||||
Safe Mode Felsäkert läge
|
||||
Help › Safe Mode… Hjälp › Felsäkert läge…
|
||||
Enable on Re-launch Aktivera vid nästa start
|
||||
The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. Den senaste starten av VectorCraft stoppades medan pluginet {name} lästes in. Det är kanske inte orsaken.
|
||||
Disable and Continue Inaktivera och fortsätt
|
||||
Start Normally Starta normalt
|
||||
Start in Safe Mode Starta i felsäkert läge
|
||||
VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft är i felsäkert läge: inga plugins läses in eller körs förrän programmet startas normalt.
|
||||
Disabled plug-ins Inaktiverade plugins
|
||||
No plug-ins are disabled. Inga plugins är inaktiverade.
|
||||
@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Felsäkert läge är på: plugins läses inte in eller körs förrän VectorCraft startas normalt
|
||||
@msg give `id` or `file` (see plugin.safeMode) ange `id` eller `file` (se plugin.safeMode)
|
||||
@msg that plug-in isn't disabled (see plugin.safeMode) det pluginet är inte inaktiverat (se plugin.safeMode)
|
||||
@msg Couldn't save the disabled plug-ins for the next launch De inaktiverade pluginen kunde inte sparas till nästa start
|
||||
@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Expandera utseende: en plugin-effekt i markeringen kan inte expanderas i felsäkert läge
|
||||
|
||||
# Align to Pixel Grid
|
||||
Align to Pixel Grid Justera mot pixelrutnät
|
||||
@msg only paths and compound paths can be aligned to the pixel grid bara banor och sammansatta banor kan justeras mot pixelrutnätet
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -6476,6 +6476,24 @@ breakText Glyphs Glifler
|
||||
3D Box 3B Kutu
|
||||
3D Box Tool 3B Kutu Aracı
|
||||
|
||||
# Safe Mode
|
||||
Safe Mode… Güvenli Mod…
|
||||
Safe Mode Güvenli Mod
|
||||
Help › Safe Mode… Yardım › Güvenli Mod…
|
||||
Enable on Re-launch Sonraki Başlatmada Etkinleştir
|
||||
The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. VectorCraft'ın son başlatılması, {name} eklentisi yüklenirken durdu. Nedeni bu eklenti olmayabilir.
|
||||
Disable and Continue Devre Dışı Bırak ve Devam Et
|
||||
Start Normally Normal Başlat
|
||||
Start in Safe Mode Güvenli Modda Başlat
|
||||
VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft Güvenli Mod'da: normal başlatılana kadar hiçbir eklenti yüklenmez veya çalıştırılmaz.
|
||||
Disabled plug-ins Devre dışı eklentiler
|
||||
No plug-ins are disabled. Devre dışı bırakılmış eklenti yok.
|
||||
@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Güvenli Mod açık: VectorCraft normal başlatılana kadar eklentiler yüklenmez veya çalıştırılmaz
|
||||
@msg give `id` or `file` (see plugin.safeMode) `id` veya `file` belirtin (bkz. plugin.safeMode)
|
||||
@msg that plug-in isn't disabled (see plugin.safeMode) bu eklenti devre dışı değil (bkz. plugin.safeMode)
|
||||
@msg Couldn't save the disabled plug-ins for the next launch Devre dışı eklentiler bir sonraki başlatma için kaydedilemedi
|
||||
@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Görünümü Genişlet: seçimdeki bir eklenti efekti Güvenli Mod'da genişletilemez
|
||||
|
||||
# Align to Pixel Grid
|
||||
Align to Pixel Grid Piksel Izgarasına Hizala
|
||||
@msg only paths and compound paths can be aligned to the pixel grid piksel ızgarasına yalnızca yollar ve bileşik yollar hizalanabilir
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -6119,6 +6119,24 @@ breakText Glyphs Гліфи
|
||||
3D Box 3D-паралелепіпед
|
||||
3D Box Tool Інструмент «3D-паралелепіпед»
|
||||
|
||||
# Safe Mode
|
||||
Safe Mode… Безпечний режим…
|
||||
Safe Mode Безпечний режим
|
||||
Help › Safe Mode… Довідка › Безпечний режим…
|
||||
Enable on Re-launch Увімкнути під час наступного запуску
|
||||
The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. Останній запуск VectorCraft зупинився під час завантаження модуля {name}. Можливо, причина не в ньому.
|
||||
Disable and Continue Вимкнути й продовжити
|
||||
Start Normally Запустити у звичайному режимі
|
||||
Start in Safe Mode Запустити в безпечному режимі
|
||||
VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft працює в безпечному режимі: модулі не завантажуються й не виконуються, доки програму не буде запущено у звичайному режимі.
|
||||
Disabled plug-ins Вимкнені модулі
|
||||
No plug-ins are disabled. Немає вимкнених модулів.
|
||||
@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Безпечний режим увімкнено: модулі не завантажуються й не виконуються, доки VectorCraft не буде запущено у звичайному режимі
|
||||
@msg give `id` or `file` (see plugin.safeMode) укажіть `id` або `file` (див. plugin.safeMode)
|
||||
@msg that plug-in isn't disabled (see plugin.safeMode) цей модуль не вимкнено (див. plugin.safeMode)
|
||||
@msg Couldn't save the disabled plug-ins for the next launch Не вдалося зберегти вимкнені модулі для наступного запуску
|
||||
@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Розбір оформлення: ефект модуля у виділенні не можна розібрати в безпечному режимі
|
||||
|
||||
# Align to Pixel Grid
|
||||
Align to Pixel Grid Вирівняти за піксельною сіткою
|
||||
@msg only paths and compound paths can be aligned to the pixel grid за піксельною сіткою можна вирівняти лише контури і складені контури
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 182 and column 5.
|
@@ -6120,6 +6120,24 @@ breakText Glyphs Glyph
|
||||
3D Box Hộp 3D
|
||||
3D Box Tool Công cụ Hộp 3D
|
||||
|
||||
# Safe Mode
|
||||
Safe Mode… Chế độ an toàn…
|
||||
Safe Mode Chế độ an toàn
|
||||
Help › Safe Mode… Trợ giúp › Chế độ an toàn…
|
||||
Enable on Re-launch Bật ở lần khởi chạy tiếp theo
|
||||
The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. Lần khởi chạy VectorCraft trước đã dừng khi đang tải plug-in {name}. Có thể plug-in đó không phải là nguyên nhân.
|
||||
Disable and Continue Tắt và tiếp tục
|
||||
Start Normally Khởi động bình thường
|
||||
Start in Safe Mode Khởi động ở chế độ an toàn
|
||||
VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft đang ở chế độ an toàn: không plug-in nào được tải hay chạy cho đến khi ứng dụng khởi động bình thường.
|
||||
Disabled plug-ins Plug-in đã tắt
|
||||
No plug-ins are disabled. Không có plug-in nào bị tắt.
|
||||
@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally Chế độ an toàn đang bật: plug-in không được tải hay chạy cho đến khi VectorCraft khởi động bình thường
|
||||
@msg give `id` or `file` (see plugin.safeMode) cung cấp `id` hoặc `file` (xem plugin.safeMode)
|
||||
@msg that plug-in isn't disabled (see plugin.safeMode) plug-in đó không bị tắt (xem plugin.safeMode)
|
||||
@msg Couldn't save the disabled plug-ins for the next launch Không thể lưu các plug-in đã tắt cho lần khởi chạy tiếp theo
|
||||
@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode Mở rộng diện mạo: không thể mở rộng hiệu ứng plug-in trong vùng chọn ở chế độ an toàn
|
||||
|
||||
# Align to Pixel Grid
|
||||
Align to Pixel Grid Căn theo lưới pixel
|
||||
@msg only paths and compound paths can be aligned to the pixel grid chỉ đường dẫn và đường dẫn phức mới căn theo lưới pixel được
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 204 and column 5.
|
@@ -6360,6 +6360,24 @@ breakText Glyphs 字形
|
||||
3D Box 3D 盒子
|
||||
3D Box Tool 3D 盒子工具
|
||||
|
||||
# Safe Mode
|
||||
Safe Mode… 安全模式…
|
||||
Safe Mode 安全模式
|
||||
Help › Safe Mode… 帮助 › 安全模式…
|
||||
Enable on Re-launch 下次启动时启用
|
||||
The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. VectorCraft 上次启动时在载入增效工具 {name} 期间停止。该增效工具不一定是原因。
|
||||
Disable and Continue 停用并继续
|
||||
Start Normally 正常启动
|
||||
Start in Safe Mode 以安全模式启动
|
||||
VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft 处于安全模式:在正常启动之前,不会载入或运行任何增效工具。
|
||||
Disabled plug-ins 已停用的增效工具
|
||||
No plug-ins are disabled. 没有停用的增效工具。
|
||||
@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally 安全模式已开启:在 VectorCraft 正常启动之前,增效工具不会载入或运行
|
||||
@msg give `id` or `file` (see plugin.safeMode) 请提供 `id` 或 `file` (请参阅 plugin.safeMode)
|
||||
@msg that plug-in isn't disabled (see plugin.safeMode) 该增效工具未停用 (请参阅 plugin.safeMode)
|
||||
@msg Couldn't save the disabled plug-ins for the next launch 无法为下次启动保存已停用的增效工具
|
||||
@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode 扩展外观:在安全模式下无法扩展所选对象中的增效工具效果
|
||||
|
||||
# Align to Pixel Grid
|
||||
Align to Pixel Grid 对齐像素网格
|
||||
@msg only paths and compound paths can be aligned to the pixel grid 只有路径和复合路径可以对齐像素网格
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -6157,6 +6157,24 @@ breakText Glyphs 字符
|
||||
3D Box 3D 盒子
|
||||
3D Box Tool 3D 盒子工具
|
||||
|
||||
# Safe Mode
|
||||
Safe Mode… 安全模式…
|
||||
Safe Mode 安全模式
|
||||
Help › Safe Mode… 說明 › 安全模式…
|
||||
Enable on Re-launch 下次啟動時啟用
|
||||
The last launch of VectorCraft stopped while the plug-in {name} was loading. It may not be what stopped it. VectorCraft 上次啟動時在載入增效模組 {name} 期間停止。該增效模組不一定是原因。
|
||||
Disable and Continue 停用並繼續
|
||||
Start Normally 正常啟動
|
||||
Start in Safe Mode 以安全模式啟動
|
||||
VectorCraft is in Safe Mode: no plug-ins load or run until it starts normally. VectorCraft 處於安全模式:在正常啟動之前,不會載入或執行任何增效模組。
|
||||
Disabled plug-ins 已停用的增效模組
|
||||
No plug-ins are disabled. 沒有停用的增效模組。
|
||||
@msg Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally 安全模式已開啟:在 VectorCraft 正常啟動之前,增效模組不會載入或執行
|
||||
@msg give `id` or `file` (see plugin.safeMode) 請提供 `id` 或 `file` (請參閱 plugin.safeMode)
|
||||
@msg that plug-in isn't disabled (see plugin.safeMode) 該增效模組未停用 (請參閱 plugin.safeMode)
|
||||
@msg Couldn't save the disabled plug-ins for the next launch 無法為下次啟動儲存已停用的增效模組
|
||||
@msg Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode 展開外觀:在安全模式下無法展開選取範圍中的增效模組效果
|
||||
|
||||
# Align to Pixel Grid
|
||||
Align to Pixel Grid 對齊像素格點
|
||||
@msg only paths and compound paths can be aligned to the pixel grid 只有路徑和複合路徑可以對齊像素格點
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -638,6 +638,7 @@ fn run_ui_part7(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"help.checkForUpdates" => crate::updates_ui::check(app),
|
||||
"help.safeMode" => crate::dialogs::safe_mode::open(app),
|
||||
"help.commandPalette" => {
|
||||
app.ui.palette_open = !app.ui.palette_open;
|
||||
app.ui.palette_query.clear();
|
||||
|
||||
@@ -702,6 +702,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
|
||||
Sep,
|
||||
c("Search Commands…", "help.commandPalette"),
|
||||
todos("VectorCraft Help…", "F1"),
|
||||
c("Safe Mode…", "help.safeMode"),
|
||||
Sep,
|
||||
c("Check for Updates…", "help.checkForUpdates"),
|
||||
c("About VectorCraft", "help.about"),
|
||||
|
||||
@@ -718,6 +718,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
("help.about", "About VectorCraft", "", "{}"),
|
||||
("help.checkForUpdates", "Check for Updates…", "", "{}"),
|
||||
("help.commandPalette", "Search Commands…", "Cmd+Shift+/", "{}"),
|
||||
(
|
||||
"help.safeMode",
|
||||
"Safe Mode…",
|
||||
"",
|
||||
"{} open the Safe Mode dialog (dialog `safeMode`): whether VectorCraft is in Safe Mode, and the disabled plug-ins, each with Enable on Re-launch (engine: plugin.setEnabled). Closing it leaves Safe Mode as it is; Safe Mode is chosen at launch (--safe-mode, or the choice after a launch that stopped while a plug-in was loading, dialog `pluginStopped`)",
|
||||
),
|
||||
("app.quit", "Quit VectorCraft", "Cmd+Q", "{}"),
|
||||
(
|
||||
"ui.swatchOptions",
|
||||
|
||||
@@ -251,6 +251,21 @@ pub(crate) fn shown_effect_label(id: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// An effect row's name: [`shown_effect_label`], followed by "(Safe Mode)" for a plug-in effect
|
||||
/// Safe Mode skips (the object keeps it and draws without it).
|
||||
pub fn effect_row_label(id: &str) -> String {
|
||||
let label = shown_effect_label(id);
|
||||
if vectorcraft_plugins::effect::skipped(id) { format!("{label} ({})", tl!("Safe Mode")) } else { label }
|
||||
}
|
||||
|
||||
/// The tooltip of an effect row Safe Mode skips: why it draws nothing, and its plug-in's id;
|
||||
/// `None` for an effect that draws.
|
||||
pub fn skipped_effect_tip(id: &str) -> Option<String> {
|
||||
let plugin = vectorcraft_plugins::effect::plugin_id(id).filter(|_| vectorcraft_plugins::effect::skipped(id))?;
|
||||
let why = crate::i18n::msg(&vectorcraft_plugins::Error::SafeMode.to_string());
|
||||
Some(format!("{why}\n{}", vectorcraft_plugins::safe_mode::display_name(plugin)))
|
||||
}
|
||||
|
||||
/// "Opacity: Default" or "Opacity: 50% Multiply".
|
||||
pub fn opacity_text(opacity: f32, blend: BlendMode) -> String {
|
||||
if (opacity - 1.0).abs() < 1e-4 && blend == BlendMode::Normal {
|
||||
@@ -582,7 +597,7 @@ fn default_stack(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let fx = |ui: &mut Ui, e: &Effect, id: (Option<usize>, usize)| {
|
||||
let (r, _) = row(ui, false);
|
||||
eye(ui, r, ("ap-def-fx", id), e.visible, false);
|
||||
text(ui, pos2(r.left() + EYE_W + 24.0 + if id.0.is_some() { 12.0 } else { 0.0 }, r.center().y), &shown_effect_label(&e.id), false);
|
||||
text(ui, pos2(r.left() + EYE_W + 24.0 + if id.0.is_some() { 12.0 } else { 0.0 }, r.center().y), &effect_row_label(&e.id), false);
|
||||
icons::paint(ui, "dc-fx", Rect::from_center_size(r.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)), t.icon);
|
||||
};
|
||||
for (i, it) in ap.items.iter().enumerate().rev() {
|
||||
@@ -856,7 +871,7 @@ fn drag_ghost(app: &VectorcraftApp, ui: &Ui, n: &Node, what: Dragged, order: &[u
|
||||
Dragged::Effect(item, k) => {
|
||||
let Some(e) = n.appearance.effects_at(item).and_then(|fx| fx.get(k)) else { return };
|
||||
let indent = if item.is_some() { 12.0 } else { 0.0 };
|
||||
text(ui, pos2(r.left() + indent + EYE_W + 24.0, y), &shown_effect_label(&e.id), false);
|
||||
text(ui, pos2(r.left() + indent + EYE_W + 24.0, y), &effect_row_label(&e.id), false);
|
||||
}
|
||||
Dragged::Contents => {
|
||||
if let Some(label) = n.contents_label() {
|
||||
@@ -1128,7 +1143,11 @@ fn effect_row(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k: usi
|
||||
// The name edits the effect on a click; the empty space takes the click rules (in `stack`)
|
||||
// and edits it on a double-click: its settings dialog, or the inline editor of an effect
|
||||
// without settings.
|
||||
let name = name_link(ui, pos2(lx, r.center().y), ("ap-fx-link", item, k), &shown_effect_label(&e.id));
|
||||
let name = name_link(ui, pos2(lx, r.center().y), ("ap-fx-link", item, k), &effect_row_label(&e.id));
|
||||
let name = match skipped_effect_tip(&e.id) {
|
||||
Some(tip) => name.on_hover_text(tip),
|
||||
None => name,
|
||||
};
|
||||
let resp = edit_tip(resp, &name);
|
||||
let edit = name.clicked() || resp.double_clicked();
|
||||
if edit {
|
||||
|
||||
@@ -28,10 +28,11 @@ pub struct Timer {
|
||||
beat: f64,
|
||||
}
|
||||
|
||||
/// Each frame: the startup question once, the timer and the heartbeat (`now`: app time in
|
||||
/// seconds) → in how many seconds they want the next frame (none while Data Recovery is off).
|
||||
/// Each frame: the startup question once no other dialog is open, the timer and the heartbeat
|
||||
/// (`now`: app time in seconds) → in how many seconds they want the next frame (none while Data Recovery is off).
|
||||
pub fn frame(app: &mut VectorcraftApp, now: f64) -> Option<f64> {
|
||||
if !app.recovery.checked {
|
||||
// A dialog already open at launch (the Safe Mode choice) is answered first.
|
||||
if !app.recovery.checked && app.ui.dialog.is_none() {
|
||||
app.recovery.checked = true;
|
||||
offer(app);
|
||||
}
|
||||
|
||||
@@ -633,6 +633,11 @@ pub struct UiState {
|
||||
/// place, [`crate::dialogs::settle`] puts the document back at the front of `pending_fonts`.
|
||||
#[serde(skip)]
|
||||
pub fonts_dialog: Option<u64>,
|
||||
/// The plug-in (`{id, file, name, label}`) the last launch was loading when it stopped, while
|
||||
/// the user has not chosen what to do about it. When another dialog takes the choice's place,
|
||||
/// [`crate::dialogs::settle`] opens the choice again once no dialog is open.
|
||||
#[serde(skip)]
|
||||
pub stopped_plugin: Option<Value>,
|
||||
/// The search the Missing Fonts dialog started (`text.findFontFiles`'s `id`), stopped once the
|
||||
/// dialog is gone.
|
||||
#[serde(skip)]
|
||||
@@ -1049,6 +1054,7 @@ impl Default for UiState {
|
||||
made_text_style: vec![],
|
||||
pending_fonts: vec![],
|
||||
fonts_dialog: None,
|
||||
stopped_plugin: None,
|
||||
dialog_search: None,
|
||||
links_search: None,
|
||||
dxf_options: Value::Null,
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
//! Safe Mode in the app: the choice after a launch that stopped while a plug-in was loading
|
||||
//! (Disable and Continue, Start Normally, Start in Safe Mode), Help › Safe Mode with Enable on
|
||||
//! Re-launch, and Safe Mode in the window title. Safe Mode and installed plug-ins are
|
||||
//! process-wide, so these tests run in their own test binary, one at a time.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_plugins::safe_mode::{self, Item};
|
||||
use vectorcraft_plugins::{registry, wat as tpl};
|
||||
use vectorcraft_ui_egui::dialogs::safe_mode as ui;
|
||||
use vectorcraft_ui_egui::panels::appearance;
|
||||
use vectorcraft_ui_egui::{Services, VectorcraftApp, chrome, dialogs, theme};
|
||||
|
||||
static LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Safe Mode off, no launch marker, nothing pending, disabled or installed.
|
||||
fn reset() {
|
||||
ui::end_launch();
|
||||
safe_mode::set_active(None);
|
||||
safe_mode::forget_previous();
|
||||
safe_mode::set_disabled(vec![]);
|
||||
for p in registry::list() {
|
||||
registry::remove(p.id());
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds the lock for one test and resets the process-wide state when the test starts and when it
|
||||
/// ends, so a test that fails leaves the next one as it would find a new process.
|
||||
struct Fresh {
|
||||
_guard: std::sync::MutexGuard<'static, ()>,
|
||||
}
|
||||
|
||||
fn fresh() -> Fresh {
|
||||
let guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
reset();
|
||||
Fresh { _guard: guard }
|
||||
}
|
||||
|
||||
impl Drop for Fresh {
|
||||
fn drop(&mut self) {
|
||||
reset();
|
||||
}
|
||||
}
|
||||
|
||||
fn filter(id: &str) -> Vec<u8> {
|
||||
wat::parse_str(tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#)).unwrap()
|
||||
}
|
||||
|
||||
fn tmp_dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("vc-ui-safe-mode-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// A plug-in folder holding x and y, as `plugin.install` keeps them.
|
||||
fn store_with_two(tag: &str) -> PathBuf {
|
||||
let dir = tmp_dir(tag);
|
||||
let mut s = Session::new();
|
||||
s.attach_plugin_store(dir.clone());
|
||||
for id in ["test.ui.x", "test.ui.y"] {
|
||||
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&filter(id))})).unwrap();
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
/// A launch of the app after one that stopped while `stopped` was loading (none: Safe Mode from
|
||||
/// `--safe-mode` when `flag`), with the plug-in folder `dir` and the launch marker folder `launch`.
|
||||
fn launch(dir: &Path, launch: &Path, flag: bool) -> VectorcraftApp {
|
||||
for p in registry::list() {
|
||||
registry::remove(p.id());
|
||||
}
|
||||
let stopped = ui::begin_launch(Some(launch), flag);
|
||||
let mut session = Session::new();
|
||||
session.attach_plugin_store(dir.to_path_buf());
|
||||
let mut app = VectorcraftApp::new(session, Services::default());
|
||||
ui::after_start(&mut app, stopped);
|
||||
app
|
||||
}
|
||||
|
||||
/// A marker left by a launch that stopped while the stored plug-in x was loading.
|
||||
fn stopped_while_loading_x(launch: &Path, dir: &Path) {
|
||||
let x = std::fs::read_dir(dir).unwrap().flatten().map(|e| e.path()).find(|p| p.to_string_lossy().contains("test_ui_x")).unwrap();
|
||||
let item = json!({"format": "vectorcraft-launch", "version": 1, "item": {"file": x.display().to_string()}});
|
||||
std::fs::create_dir_all(launch).unwrap();
|
||||
std::fs::write(launch.join("launch-1-dead.json"), item.to_string()).unwrap();
|
||||
}
|
||||
|
||||
/// One headless frame of the dialog layer.
|
||||
fn frame(app: &mut VectorcraftApp) {
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| dialogs::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn done(launch: &Path) {
|
||||
ui::end_launch();
|
||||
safe_mode::set_active(None);
|
||||
safe_mode::forget_previous();
|
||||
safe_mode::set_disabled(vec![]);
|
||||
let _ = std::fs::remove_dir_all(launch);
|
||||
}
|
||||
|
||||
fn kind(app: &VectorcraftApp) -> Option<&str> {
|
||||
app.ui.dialog.as_ref().map(|d| d.kind.as_str())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disable_and_continue_loads_every_plug_in_but_the_one_that_was_loading() {
|
||||
let _f = fresh();
|
||||
let dir = store_with_two("disable");
|
||||
let launch_dir = dir.join("Launch");
|
||||
stopped_while_loading_x(&launch_dir, &dir);
|
||||
let mut app = launch(&dir, &launch_dir, false);
|
||||
assert_eq!(kind(&app), Some(ui::STOPPED));
|
||||
assert!(safe_mode::active() && registry::list().is_empty(), "nothing loads until the user chooses");
|
||||
assert!(chrome::window_title(&app).ends_with("(Safe Mode)"));
|
||||
frame(&mut app);
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert!(!safe_mode::active() && kind(&app).is_none());
|
||||
assert!(registry::get("test.ui.x").is_none() && registry::get("test.ui.y").is_some(), "only the one that was loading is off");
|
||||
assert_eq!(chrome::window_title(&app), "VectorCraft");
|
||||
assert!(dir.join("disabled.json").is_file(), "kept off at later launches");
|
||||
ui::end_launch();
|
||||
|
||||
// The next launch, which quit normally before: nothing is asked, and x stays off.
|
||||
let app = launch(&dir, &launch_dir, false);
|
||||
assert!(kind(&app).is_none() && !safe_mode::active());
|
||||
assert!(registry::get("test.ui.x").is_none() && registry::get("test.ui.y").is_some());
|
||||
done(&launch_dir);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_normally_loads_everything_and_start_in_safe_mode_loads_nothing() {
|
||||
let _f = fresh();
|
||||
let dir = store_with_two("normal");
|
||||
let launch_dir = dir.join("Launch");
|
||||
stopped_while_loading_x(&launch_dir, &dir);
|
||||
let mut app = launch(&dir, &launch_dir, false);
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("normal".into(), json!(true));
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert!(!ui::choosing(&app) && !ui::staying_on(&app), "Start Normally ends Safe Mode");
|
||||
assert!(registry::get("test.ui.x").is_some() && registry::get("test.ui.y").is_some());
|
||||
assert!(safe_mode::disabled().is_empty() && !dir.join("disabled.json").exists(), "Start Normally changes no setting");
|
||||
ui::end_launch();
|
||||
|
||||
stopped_while_loading_x(&launch_dir, &dir);
|
||||
let mut app = launch(&dir, &launch_dir, false);
|
||||
assert!(ui::choosing(&app) && !ui::staying_on(&app), "Safe Mode until the user chooses");
|
||||
dialogs::cancel(&mut app);
|
||||
assert!(!ui::choosing(&app) && ui::staying_on(&app), "Safe Mode for the rest of the launch");
|
||||
assert!(safe_mode::active() && registry::list().is_empty(), "Start in Safe Mode");
|
||||
assert_eq!(safe_mode::source(), Some(safe_mode::Source::Startup));
|
||||
assert!(safe_mode::disabled().is_empty(), "nothing turned off");
|
||||
ui::end_launch();
|
||||
safe_mode::set_active(None);
|
||||
let app = launch(&dir, &launch_dir, false);
|
||||
assert_eq!(kind(&app), Some(ui::STOPPED), "Start in Safe Mode leaves the choice to the next launch");
|
||||
done(&launch_dir);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_disabled_list_that_cannot_be_saved_says_so() {
|
||||
let _f = fresh();
|
||||
let dir = store_with_two("unsaved");
|
||||
let launch_dir = dir.join("Launch");
|
||||
stopped_while_loading_x(&launch_dir, &dir);
|
||||
// A later version's list, which a save keeps as it is.
|
||||
let later = r#"{"format": "vectorcraft-disabled-plugins", "version": 2, "items": []}"#;
|
||||
std::fs::write(dir.join("disabled.json"), later).unwrap();
|
||||
let unsaved = "Couldn't save the disabled plug-ins for the next launch";
|
||||
let mut app = launch(&dir, &launch_dir, false);
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.ui.status, unsaved, "Disable and Continue");
|
||||
assert!(safe_mode::previous().is_some(), "the next launch asks again");
|
||||
assert!(registry::get("test.ui.x").is_none(), "this launch still skips it");
|
||||
app.ui.status.clear();
|
||||
app.run("help.safeMode", json!({})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields["items"][0]["enable"] = json!(true);
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.ui.status, unsaved, "Enable on Re-launch");
|
||||
assert_eq!(std::fs::read_to_string(dir.join("disabled.json")).unwrap(), later);
|
||||
done(&launch_dir);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_safe_mode_label_leaves_the_menus_their_room() {
|
||||
let _f = fresh();
|
||||
safe_mode::set_active(Some(safe_mode::Source::Flag));
|
||||
let menu_names = ["File", "Edit", "Object", "Type", "Select", "Effect", "View", "Window", "Help"];
|
||||
for width in [560.0, 720.0, 1280.0] {
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services::default());
|
||||
let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| chrome::app_bar(&mut app, ui));
|
||||
out.textures_delta.clear();
|
||||
let texts: Vec<_> = out
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|s| match &s.shape {
|
||||
egui::Shape::Text(t) => Some((t.galley.job.text.clone(), t.visual_bounding_rect())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let menus: Vec<egui::Rect> = texts.iter().filter(|(t, _)| menu_names.contains(&t.as_str())).map(|(_, r)| *r).collect();
|
||||
assert!(!menus.is_empty(), "{width}: the menus are drawn: {texts:?}");
|
||||
let (start, end) = menus.iter().fold((f32::MAX, f32::MIN), |(a, b), r| (a.min(r.left()), b.max(r.right())));
|
||||
assert!(texts.iter().any(|(t, _)| t == "Safe Mode"), "{width}: the label is drawn");
|
||||
for (t, r) in texts.iter().filter(|(t, _)| !menu_names.contains(&t.as_str())) {
|
||||
assert!(r.left() >= end || r.right() <= start, "{width}: {t:?} at {r:?} overlaps the menus from {start} to {end}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dialog_that_replaces_the_choice_leaves_it_waiting() {
|
||||
let _f = fresh();
|
||||
let dir = store_with_two("replaced");
|
||||
let launch_dir = dir.join("Launch");
|
||||
stopped_while_loading_x(&launch_dir, &dir);
|
||||
let mut app = launch(&dir, &launch_dir, false);
|
||||
// Help › Safe Mode (or any other dialog) takes the choice's place before it is answered.
|
||||
app.run("help.safeMode", json!({})).unwrap();
|
||||
assert_eq!(kind(&app), Some(ui::KIND));
|
||||
assert!(ui::choosing(&app) && !ui::staying_on(&app), "still waiting for the choice");
|
||||
dialogs::cancel(&mut app);
|
||||
assert_eq!(kind(&app), Some(ui::STOPPED), "the choice opens again once no dialog is open");
|
||||
assert!(ui::choosing(&app) && registry::list().is_empty());
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert!(!ui::choosing(&app) && !safe_mode::active());
|
||||
assert!(registry::get("test.ui.x").is_none() && registry::get("test.ui.y").is_some());
|
||||
done(&launch_dir);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_safe_mode_flag_asks_nothing_and_loads_nothing() {
|
||||
let _f = fresh();
|
||||
let dir = store_with_two("flag");
|
||||
let launch_dir = dir.join("Launch");
|
||||
let app = launch(&dir, &launch_dir, true);
|
||||
assert!(kind(&app).is_none() && ui::staying_on(&app));
|
||||
assert_eq!((safe_mode::source(), registry::list().len()), (Some(safe_mode::Source::Flag), 0));
|
||||
assert_eq!(app.ui.status, vectorcraft_plugins::Error::SafeMode.to_string());
|
||||
assert!(chrome::window_title(&app).ends_with("(Safe Mode)"));
|
||||
// An object's plug-in effect stays in its Appearance list, marked as skipped.
|
||||
assert!(appearance::effect_row_label("plugin.org.example.glow").ends_with("(Safe Mode)"));
|
||||
assert!(!appearance::effect_row_label("stylize.dropShadow").contains("Safe Mode"), "built-in effects still draw");
|
||||
// Two plug-ins' effects with the same row name: the tooltip names each one's plug-in.
|
||||
let (a, b) = (appearance::skipped_effect_tip("plugin.com.a.blur").unwrap(), appearance::skipped_effect_tip("plugin.com.b.blur").unwrap());
|
||||
assert!(a.ends_with("\ncom.a.blur") && b.ends_with("\ncom.b.blur"), "{a:?} {b:?}");
|
||||
assert_eq!(appearance::skipped_effect_tip("stylize.dropShadow"), None);
|
||||
safe_mode::set_active(None);
|
||||
assert!(!appearance::effect_row_label("plugin.org.example.glow").contains("Safe Mode"));
|
||||
assert_eq!(appearance::skipped_effect_tip("plugin.com.a.blur"), None);
|
||||
done(&launch_dir);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_safe_mode_flag_launch_leaves_the_stopped_plug_in_to_the_next_launch() {
|
||||
let _f = fresh();
|
||||
let dir = store_with_two("flag-pending");
|
||||
let launch_dir = dir.join("Launch");
|
||||
stopped_while_loading_x(&launch_dir, &dir);
|
||||
let app = launch(&dir, &launch_dir, true);
|
||||
assert!(kind(&app).is_none() && registry::list().is_empty(), "--safe-mode asks nothing");
|
||||
assert!(safe_mode::previous().is_some(), "the plug-in is still pending");
|
||||
ui::end_launch();
|
||||
safe_mode::set_active(None);
|
||||
|
||||
// The next launch, without the flag, asks about it before anything loads.
|
||||
let mut app = launch(&dir, &launch_dir, false);
|
||||
assert_eq!(kind(&app), Some(ui::STOPPED));
|
||||
assert!(registry::list().is_empty());
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert!(registry::get("test.ui.x").is_none() && registry::get("test.ui.y").is_some());
|
||||
ui::end_launch();
|
||||
|
||||
// A launch after the choice asks nothing.
|
||||
let app = launch(&dir, &launch_dir, false);
|
||||
assert!(kind(&app).is_none() && !safe_mode::active());
|
||||
done(&launch_dir);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_safe_mode_enables_a_plug_in_for_the_next_launch_only() {
|
||||
let _f = fresh();
|
||||
let dir = store_with_two("help");
|
||||
let launch_dir = dir.join("Launch");
|
||||
let x = Item { id: Some("test.ui.x".into()), name: Some("X\u{202E}gnp".into()), file: None };
|
||||
let mut s = Session::new();
|
||||
s.attach_plugin_store(dir.clone());
|
||||
assert!(safe_mode::set_enabled(&x, false));
|
||||
assert!(s.store_disabled_plugins());
|
||||
let mut app = launch(&dir, &launch_dir, false);
|
||||
assert!(registry::get("test.ui.x").is_none());
|
||||
app.run("help.safeMode", json!({})).unwrap();
|
||||
assert_eq!(kind(&app), Some(ui::KIND));
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
assert_eq!(d.fields["items"][0]["label"], "Xgnp", "a name can't reorder the dialog's text");
|
||||
frame(&mut app);
|
||||
// Close keeps everything as it was.
|
||||
dialogs::cancel(&mut app);
|
||||
assert_eq!(safe_mode::disabled(), std::slice::from_ref(&x));
|
||||
app.run("help.safeMode", json!({})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields["items"][0]["enable"] = json!(true);
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert!(safe_mode::disabled().is_empty());
|
||||
assert!(registry::get("test.ui.x").is_none(), "not loaded during this launch");
|
||||
ui::end_launch();
|
||||
launch(&dir, &launch_dir, false);
|
||||
assert!(registry::get("test.ui.x").is_some(), "loaded at the next launch");
|
||||
done(&launch_dir);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
+31
@@ -6319,7 +6319,38 @@ permission names, and a `hash` that is not the module's SHA-256. `prefs.set` ret
|
||||
changes nothing when a new `pluginsFolder` is missing, is not a folder or can't be listed. It does not check an empty
|
||||
value or the folder the preference already holds, and the web build does not check folders.
|
||||
|
||||
Safe Mode starts VectorCraft without plug-ins: `vectorcraft --safe-mode`, `vectorcraft-cli --safe-mode <subcommand>`, or
|
||||
the choice the desktop app offers when the last launch stopped while a plug-in was loading (Disable and Continue, Start
|
||||
Normally, Start in Safe Mode). It names that plug-in without saying it caused the stop. Until the user picks Disable and
|
||||
Continue or Start Normally, the next launch asks again, also after a `--safe-mode` launch, which asks nothing.
|
||||
`vectorcraft --safe-mode` opens its files in a window of its own, and on Windows and Linux a file opened from the file
|
||||
manager while the app is in Safe Mode starts a window with plug-ins. Files given on the command line, or opened from
|
||||
the file manager while the choice is up, open after the user picks. On Windows and Linux, when the choice is Start in Safe
|
||||
Mode, the files from the file manager open in a new window with plug-ins. `vectorcraft-cli --safe-mode mcp` serves a headless
|
||||
session and refuses `--connect`. In Safe Mode no plug-in loads or
|
||||
runs: `plugin.install`, `plugin.run`, `plugin.reload` and exporters return an error, and a live-effect plug-in draws its
|
||||
object's geometry unchanged while the object keeps the effect: `effect.list` gives that effect `skipped: true`, and the
|
||||
Appearance panel shows "(Safe Mode)" after its name and the plug-in's id in its tooltip. `effect.expandAppearance` returns an
|
||||
error and changes nothing when an object it would expand holds such an effect, in it or in a symbol it places. `vectorcraft` rejects an option it does not know (such as
|
||||
`--safe-mod`) instead of opening it as a file; `--` before a file name that starts with `-` opens it. The web build keeps
|
||||
no installed plug-ins from one page load to the next and has no launch marker, so it never offers the choice. `plugin.safeMode` returns `{active, source, disabled,
|
||||
pendingItem}`; a command does not turn Safe Mode on or off. `plugin.setEnabled {id | file, enabled}` turns a plug-in on or
|
||||
off for the next launch, as Help › Safe Mode's Enable on Re-launch does; it does not load, unload or uninstall anything
|
||||
now, and `plugin.install` of a disabled plug-in turns it on. Turning a plug-in on by `id` or by installing it also turns
|
||||
on the file it is stored in, which names it when it stopped a launch while loading; when the new version can't be saved
|
||||
over that file, the plug-in stays disabled. `plugin.list` reports `safeMode` and `disabled`, and
|
||||
`plugin.reload` skips disabled plug-ins. A change to the disabled list is saved on top of the list the file holds at
|
||||
that moment, so a change another window saved to the same preferences meanwhile is kept. When the list file can't be
|
||||
read (it is too large) or holds another format or version, it is left as it is; a file that isn't JSON is replaced.
|
||||
`plugin.setEnabled` returns `saved: false` when its change isn't in the plug-in folder: the session has none (a server
|
||||
confined with `--automation-read-root` or `--automation-write-root`), the list there can't be read or written, or it
|
||||
already holds 1,000 plug-ins, as another window may have saved it. The
|
||||
change stays in the process and is saved with the next change that saves, and the app shows a status message. When
|
||||
Disable and Continue can't save the plug-in it turns off, this launch skips it and the next launch asks again.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"plugin.safeMode","params":{}}}
|
||||
{"name":"run_command","arguments":{"command":"plugin.setEnabled","params":{"id":"org.example.wobble","enabled":true}}}
|
||||
{"name":"run_command","arguments":{"command":"plugin.install","params":{"path":"/plugins/desaturate.wasm"}}}
|
||||
{"name":"run_command","arguments":{"command":"plugin.run","params":{"id":"org.vectorcraft.example.desaturate","params":{"amount":60}}}}
|
||||
{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"plugin.org.example.wobble","params":{"size":4}}}}
|
||||
|
||||
@@ -1513,6 +1513,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Filter plug-ins set effects and stroke styles (G17):** a filter is given each object's effect records and its strokes' `join`, `cap` and `miterLimit`, and may return them; the records are admitted as `effect.apply` admits them (a known effect or installed live-effect plug-in, parameters it takes, one 3D effect that fits, no texture read from a file), and one refused refuses the output. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged.
|
||||
- **Plug-in data kept with the document (G17):** a filter writes JSON of at most 1 MiB with `documentData` (one undo step with its objects; `null` removes it) and reads it back in `_context.documentData`, which no other filter is given. It is the document's foreign data (`unknown.pluginData`, by plug-in id), as the perspective grid is, so the native file keeps it with no section of its own; an exporter is given the whole document, so it can read every plug-in's. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_export.rs` (the exporter), `crates/doc/src/plugin_data.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged.
|
||||
- **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged.
|
||||
- **Safe Mode for plug-ins (G17):** `vectorcraft --safe-mode` and `vectorcraft-cli --safe-mode <subcommand>` start without plug-ins. When a launch stopped while a plug-in was loading or drawing its first live effect, the next launch names that plug-in, without saying it caused the stop, and offers Disable and Continue, Start Normally or Start in Safe Mode. Help › Safe Mode lists the disabled plug-ins with Enable on Re-launch, which takes effect at the next launch, and the app bar and the window title show Safe Mode. The help page (https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf, pages 65 to 69) describes the same choice between diagnosing and starting normally, a Safe Mode with the problem item off and the rest of the app working, and turning the item back on at the next launch. In Safe Mode no plug-in loads or runs, and a live-effect plug-in draws its object's geometry unchanged while the object keeps the effect, which the Appearance panel marks "(Safe Mode)" with the plug-in's id in its tooltip and `effect.list` marks `skipped`; Expand Appearance refuses such an object. The web build keeps no plug-ins between page loads and has no marker. Each desktop launch with preferences keeps a marker in the `Launch` folder next to them; `vectorcraft-cli` and a desktop launch without preferences keep none, so a stop there leads to no choice at the next launch. Disabled plug-ins are listed in `Plug-ins/disabled.json`. `plugin.safeMode` reports Safe Mode and the disabled plug-ins, and `plugin.setEnabled` changes the list. Regressions: `crates/plugins/src/safe_mode.rs`, `crates/plugins/tests/safe_mode.rs`, `crates/engine/tests/plugin_safe_mode.rs`, `crates/ui-egui/tests/safe_mode.rs`. Scores and hours unchanged.
|
||||
- **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged.
|
||||
- **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged.
|
||||
- **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged.
|
||||
|
||||
Reference in New Issue
Block a user