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:
parent
cb0f7d358d
commit
2dd782db87
7 changed files with 534 additions and 8 deletions
|
|
@ -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"]);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue