runtime: plugin commands for the command palette (api.commands / scope.commands: register, list, run, owner = the starting plugin, removed on stop, caps, in-page shortcuts); host: start/stop scripts own and drop a plugin's commands; dev_start_script with sourceURL + devError for live reload; devErrors (file:line from a start failure)

This commit is contained in:
CRTkafa 2026-09-29 01:35:44 +07:00
parent cb0f7d358d
commit 2dd782db87
7 changed files with 534 additions and 8 deletions

View file

@ -445,37 +445,69 @@ fn js_string(value: &str) -> Result<String, String> {
/// Wraps a CommonJS-style plugin (`module.exports = class { start() {} stop() {} }`).
/// Native code evaluates the result in the site tab, so the page's CSP does not block it.
///
/// The module body, construction, `start()` and `stop()` run inside
/// Construction and `start()` run inside `api.commands.withOwner(file, ...)` when the runtime has
/// a command registry, so palette commands registered meanwhile belong to this plugin and are
/// removed by [`stop_script`] (or at once when the start fails).
///
/// The module body, construction, `start()` and `stop()` also run inside
/// `window.<global>.withPlugin(id, grants, fn)` when the runtime has it: reads of
/// `window.<global>.api` there return the plugin's view for its declared `@permissions` (`grants`
/// = null for a legacy plugin without the line: the full API).
pub fn start_script(host: &PluginHost, file: &str, source: &str) -> Result<String, String> {
start_script_with(host, file, source, None)
}
/// [`start_script`] for the live-reload developer mode: the evaluated script ends with
/// `//# sourceURL=<prefix><file>` so stack traces name the file and line (line 1 of the file is
/// line 2 of the evaluated script), and a start failure is also handed to the runtime's
/// `devError(id, error)` (which shows it in the page).
pub fn dev_start_script(host: &PluginHost, file: &str, source: &str, source_url_prefix: &str) -> Result<String, String> {
if !source_url_prefix.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_/:.".contains(&b)) {
return Err("VALIDATION: invalid source URL prefix".into());
}
start_script_with(host, file, source, Some(source_url_prefix))
}
/// Line of the evaluated start script on which the plugin file's first line lands.
pub const START_SCRIPT_LINE_OFFSET: u32 = 1;
fn start_script_with(host: &PluginHost, file: &str, source: &str, dev: Option<&str>) -> Result<String, String> {
let id = js_string(file)?;
let grants = serde_json::to_string(&parse_meta(file, source).permissions).map_err(|_| "PLUGINS: serialization failed")?;
let PluginHost { runtime_global: global, origin, product, .. } = host;
// Only a valid file name (letters, digits, `_-. `) reaches the comment.
let source_url = match dev {
Some(prefix) if valid_file_name(file) => format!("\n//# sourceURL={prefix}{}", file.replace(' ', "%20")),
_ => String::new(),
};
let on_error = if dev.is_some() { "if(typeof XW.devError==='function')XW.devError(id,e);" } else { "" };
Ok(format!(
"(()=>{{const XW=window.{global};if(!XW||window.top!==window||location.origin!=='{origin}')return;\
const id={id};if(XW.plugins[id])return;\
const as=f=>typeof XW.withPlugin==='function'?XW.withPlugin(id,{grants},f):f();\
const C=XW.api&&XW.api.commands;const as=f=>typeof XW.withPlugin==='function'?XW.withPlugin(id,{grants},f):f();\
const own=f=>as(()=>C&&typeof C.withOwner==='function'?C.withOwner(id,f):f());\
try{{const module={{exports:{{}}}};const exports=module.exports;\
as(()=>{{(function(module,exports){{\n{source}\n}})(module,exports);}});\
let P=module.exports&&module.exports.default?module.exports.default:module.exports;\
const inst=as(()=>typeof P==='function'?(P.prototype&&(P.prototype.start||P.prototype.stop)?new P():P()):P);\
XW.plugins[id]=inst||{{}};delete XW.errors[id];if(inst&&typeof inst.start==='function')as(()=>inst.start());\
const inst=own(()=>typeof P==='function'?(P.prototype&&(P.prototype.start||P.prototype.stop)?new P():P()):P);\
XW.plugins[id]=inst||{{}};delete XW.errors[id];if(inst&&typeof inst.start==='function')own(()=>inst.start());\
console.info('[{product}] started',id);}}\
catch(e){{XW.errors[id]=String(e&&e.stack||e);console.error('[{product}] plugin failed',id,e);}}}})();"
catch(e){{XW.errors[id]=String(e&&e.stack||e);if(C&&typeof C.removeOwner==='function')C.removeOwner(id);{on_error}\
console.error('[{product}] plugin failed',id,e);}}}})();{source_url}"
))
}
/// Stops a plugin; whatever palette commands it forgot to unregister go with it.
pub fn stop_script(host: &PluginHost, file: &str) -> Result<String, String> {
let id = js_string(file)?;
let PluginHost { runtime_global: global, product, .. } = host;
Ok(format!(
"(()=>{{const XW=window.{global};if(!XW)return;const id={id};const p=XW.plugins[id];\
if(!p)return;delete XW.plugins[id];\
const C=XW.api&&XW.api.commands;const drop=()=>{{if(C&&typeof C.removeOwner==='function')C.removeOwner(id);}};\
if(!p){{drop();return;}}delete XW.plugins[id];\
const as=f=>typeof XW.withPlugin==='function'?XW.withPlugin(id,undefined,f):f();\
try{{if(typeof p.stop==='function')as(()=>p.stop());console.info('[{product}] stopped',id);}}\
catch(e){{console.error('[{product}] plugin stop failed',id,e);}}}})();"
catch(e){{console.error('[{product}] plugin stop failed',id,e);}}drop();}})();"
))
}
@ -571,6 +603,31 @@ mod tests {
assert!(css.contains("const id='demo-css'") && css.contains(r#"s.textContent="</style><script>";"#));
}
#[test]
fn palette_commands_belong_to_the_plugin_and_go_when_it_stops() {
let start = start_script(&HOST, "focus.plugin.js", "module.exports = class { start(){} };").unwrap();
// Construction and start() run with the plugin as the commands' owner.
assert!(start.contains("C.withOwner(id,f)") && start.contains("own(()=>inst.start())"));
// A failed start drops whatever it registered before throwing.
assert!(start.contains("catch(e){XW.errors[id]=String(e&&e.stack||e);if(C&&typeof C.removeOwner==='function')C.removeOwner(id);"));
assert!(!start.contains("sourceURL") && !start.contains("devError"));
let stop = stop_script(&HOST, "focus.plugin.js").unwrap();
// Removed after stop(), and also when the plugin was not running (a failed start).
assert!(stop.contains("if(!p){drop();return;}") && stop.ends_with("drop();})();"));
}
#[test]
fn dev_start_script_names_the_file_for_stack_traces() {
let source = "module.exports = class {\n start(){ throw new Error('x'); }\n};";
let dev = dev_start_script(&HOST, "My Plugin.plugin.js", source, "wb-dev/").unwrap();
assert!(dev.ends_with("\n//# sourceURL=wb-dev/My%20Plugin.plugin.js"));
assert!(dev.contains("XW.devError(id,e)"));
// The file's first line is line 1 + START_SCRIPT_LINE_OFFSET of the evaluated script.
let first = dev.lines().position(|line| line == "module.exports = class {").unwrap() as u32;
assert_eq!(first, START_SCRIPT_LINE_OFFSET);
assert!(dev_start_script(&HOST, "a.plugin.js", source, "x\n//# evil").is_err());
}
#[test]
fn permissions_header_is_parsed_strictly() {
assert_eq!(parse_permissions("storage, ui clipboard,UI").unwrap(), vec!["storage", "ui", "clipboard"]);