diff --git a/src/main/java/net/rptools/maptool/client/functions/MacroJavaScriptBridge.java b/src/main/java/net/rptools/maptool/client/functions/MacroJavaScriptBridge.java index ffedf0e7ef..4bc84c9daa 100644 --- a/src/main/java/net/rptools/maptool/client/functions/MacroJavaScriptBridge.java +++ b/src/main/java/net/rptools/maptool/client/functions/MacroJavaScriptBridge.java @@ -26,6 +26,7 @@ import net.rptools.maptool.client.MapTool; import net.rptools.maptool.client.MapToolVariableResolver; import net.rptools.maptool.client.script.javascript.JSArray; +import net.rptools.maptool.client.script.javascript.JSContext; import net.rptools.maptool.client.script.javascript.JSObject; import net.rptools.maptool.client.script.javascript.JSScriptEngine; import net.rptools.maptool.client.script.javascript.api.MapToolJSAPIInterface; @@ -52,13 +53,14 @@ public class MacroJavaScriptBridge extends AbstractFunction implements DefinesSp private MacroJavaScriptBridge() { super( - 1, + 0, UNLIMITED_PARAMETERS, "js.eval", "js.evalNS", "js.evalURI", "js.removeNS", - "js.createNS"); + "js.createNS", + "js.listNS"); } public static MacroJavaScriptBridge getInstance() { @@ -72,6 +74,20 @@ public Object childEvaluate( variableResolver = (MapToolVariableResolver) resolver; String contextName = null; + if ("js.listNS".equalsIgnoreCase(functionName)) { + JsonArray array = new JsonArray(); + JSScriptEngine.getContexts().stream() + .sorted(Comparator.comparing(JSContext::name)) + .forEach( + c -> { + JsonObject obj = new JsonObject(); + obj.addProperty("name", c.name()); + obj.addProperty("trusted", c.trusted()); + array.add(obj); + }); + return array; + } + if ("js.evalNS".equalsIgnoreCase(functionName) || "js.evalURI".equalsIgnoreCase(functionName)) { if (args.size() < 2) { throw new ParameterException(String.format(NOT_ENOUGH_PARAM, functionName, 2, args.size())); @@ -80,11 +96,17 @@ public Object childEvaluate( } if ("js.removeNS".equalsIgnoreCase(functionName)) { + if (args.size() < 1) { + throw new ParameterException(String.format(NOT_ENOUGH_PARAM, functionName, 2, args.size())); + } contextName = (String) args.remove(0); JSScriptEngine.removeContext(contextName, MapTool.getParser().isMacroTrusted()); return "removed"; } if ("js.createNS".equalsIgnoreCase(functionName)) { + if (args.size() < 1) { + throw new ParameterException(String.format(NOT_ENOUGH_PARAM, functionName, 2, args.size())); + } contextName = (String) args.remove(0); boolean makeTrusted = MapTool.getParser().isMacroTrusted(); if (args.size() > 0) { @@ -97,6 +119,9 @@ public Object childEvaluate( String script; if ("js.evalURI".equalsIgnoreCase(functionName)) { + if (args.size() < 1) { + throw new ParameterException(String.format(NOT_ENOUGH_PARAM, functionName, 2, args.size())); + } URL url; try { url = new URL(args.get(0).toString()); diff --git a/src/main/java/net/rptools/maptool/client/script/javascript/JSContext.java b/src/main/java/net/rptools/maptool/client/script/javascript/JSContext.java index cd60d861e9..418b0b49ed 100644 --- a/src/main/java/net/rptools/maptool/client/script/javascript/JSContext.java +++ b/src/main/java/net/rptools/maptool/client/script/javascript/JSContext.java @@ -20,14 +20,5 @@ import org.graalvm.polyglot.*; import org.graalvm.polyglot.HostAccess.*; -public class JSContext { - public boolean isTrusted; - public Context context; - public String name; - - public JSContext(boolean t, Context c, String name) { - this.isTrusted = t; - this.context = c; - this.name = name; - } -} +public record JSContext(boolean trusted, Context context, String name) {} +; diff --git a/src/main/java/net/rptools/maptool/client/script/javascript/JSScriptEngine.java b/src/main/java/net/rptools/maptool/client/script/javascript/JSScriptEngine.java index 7e0c74bef0..cd4f5890d7 100644 --- a/src/main/java/net/rptools/maptool/client/script/javascript/JSScriptEngine.java +++ b/src/main/java/net/rptools/maptool/client/script/javascript/JSScriptEngine.java @@ -35,6 +35,7 @@ public class JSScriptEngine { private static final JSScriptEngine jsScriptEngine = new JSScriptEngine(); private static final Logger log = LogManager.getLogger(JSScriptEngine.class); private static final Map contexts = new HashMap(); + private static final Map addOnContexts = new HashMap(); private static final Stack contextStack = new Stack<>(); public static JSContext getCurrentContext() { @@ -42,10 +43,10 @@ public static JSContext getCurrentContext() { } public static boolean inTrustedContext() { - if (jsScriptEngine.contextStack.empty()) { + if (JSScriptEngine.contextStack.empty()) { return false; } - return jsScriptEngine.contextStack.peek().isTrusted; + return JSScriptEngine.contextStack.peek().trusted(); } private void registerAPIObject(Value bindings, MapToolJSAPIInterface apiObj) { @@ -91,7 +92,7 @@ public static void removeContext(String name, boolean trusted) throws ParserExce return; } JSContext c = contexts.get(name); - if (c == null || c.isTrusted) { + if (c == null || c.trusted()) { throw new ParserException(I18N.getText("macro.function.general.noPermJS", name)); } contexts.remove(name); @@ -101,6 +102,29 @@ public static void removeContext(String name, boolean trusted) throws ParserExce public static void resetContexts() { JSMacro.clear(); contexts.clear(); + addOnContexts.clear(); + } + + public static JSContext registerAddOnContext(String name) { + JSContext c = new JSContext(true, jsScriptEngine.makeContext(), name); + addOnContexts.put(name, c); + return c; + } + + public static void removeAddOnContext(String name) { + addOnContexts.remove(name); + } + + public static boolean hasContext(String name) { + return contexts.containsKey(name); + } + + public static boolean hasAddOnContext(String name) { + return addOnContexts.containsKey(name); + } + + public static Set getContexts() { + return new HashSet<>(contexts.values()); } public Context makeContext() { @@ -130,23 +154,37 @@ public static JSScriptEngine getJSScriptEngine() { public Value evalScript(String contextName, String script) throws ScriptException, ParserException { + return evalScript(contextName, script, MapTool.getParser().isMacroTrusted()); + } + + public Value evalScript(String contextName, String script, boolean trusted) + throws ScriptException, ParserException { if (contextName == null) { return evalAnonymous(script); } JSContext jc = contexts.get(contextName); if (jc == null) { - jc = - registerContext( - contextName, - MapTool.getParser().isMacroTrusted(), - MapTool.getParser().isMacroTrusted()); + jc = registerContext(contextName, trusted, trusted); } - if (jc.isTrusted && !MapTool.getParser().isMacroTrusted()) { - throw new ParserException(I18N.getText("macro.function.general.noPermJS", contextName)); + + return evalScript(jc, script, trusted); + } + + public Value evalAddOnScript(String contextName, String script, boolean trusted) + throws ScriptException, ParserException { + JSContext jc = addOnContexts.get(contextName); + return evalScript(jc, script, trusted); + } + + public Value evalScript(JSContext context, String script, boolean trusted) + throws ScriptException, ParserException { + + if (context.trusted() && !trusted) { + throw new ParserException(I18N.getText("macro.function.general.noPermJS", context.name())); } - contextStack.push(jc); + contextStack.push(context); try { - return jc.context.eval("js", script); + return context.context().eval("js", script); } finally { contextStack.pop(); } diff --git a/src/main/java/net/rptools/maptool/model/gamedata/data/AssetDataValue.java b/src/main/java/net/rptools/maptool/model/gamedata/data/AssetDataValue.java index e5463135b0..d3f43d3299 100644 --- a/src/main/java/net/rptools/maptool/model/gamedata/data/AssetDataValue.java +++ b/src/main/java/net/rptools/maptool/model/gamedata/data/AssetDataValue.java @@ -107,7 +107,7 @@ public String asString() { if (undefined) { throw InvalidDataOperation.createUndefined(name); } else { - throw InvalidDataOperation.createInvalidConversion(DataType.ASSET, DataType.DOUBLE); + return asset.toString(); } } diff --git a/src/main/java/net/rptools/maptool/model/library/Library.java b/src/main/java/net/rptools/maptool/model/library/Library.java index 6578802fac..0955686d60 100644 --- a/src/main/java/net/rptools/maptool/model/library/Library.java +++ b/src/main/java/net/rptools/maptool/model/library/Library.java @@ -203,4 +203,10 @@ public interface Library { * @return the {@link Asset} for the library license file if it has one. */ CompletableFuture> getLicenseAsset(); + + /** + * Clean up any resources used by the library, This should be called when the library is no longer + * needed. + */ + void cleanup(); } diff --git a/src/main/java/net/rptools/maptool/model/library/addon/AddOnLibrary.java b/src/main/java/net/rptools/maptool/model/library/addon/AddOnLibrary.java index 7e48410697..6907de90af 100644 --- a/src/main/java/net/rptools/maptool/model/library/addon/AddOnLibrary.java +++ b/src/main/java/net/rptools/maptool/model/library/addon/AddOnLibrary.java @@ -28,10 +28,12 @@ import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; +import javax.script.ScriptException; import net.rptools.lib.MD5Key; import net.rptools.maptool.client.MapTool; import net.rptools.maptool.client.MapToolMacroContext; import net.rptools.maptool.client.MapToolVariableResolver; +import net.rptools.maptool.client.script.javascript.JSScriptEngine; import net.rptools.maptool.language.I18N; import net.rptools.maptool.model.Asset; import net.rptools.maptool.model.Asset.Type; @@ -49,6 +51,7 @@ import net.rptools.maptool.model.library.proto.AddOnLibraryEventsDto; import net.rptools.maptool.model.library.proto.MTScriptPropertiesDto; import net.rptools.maptool.util.threads.ThreadExecutionHelper; +import net.rptools.parser.ParserException; import org.javatuples.Pair; /** Class that implements add-on libraries. */ @@ -60,6 +63,9 @@ public class AddOnLibrary implements Library { /** The name of the event for initialization. */ private static final String INIT_EVENT = "onInit"; + /** The prefix for the name of the JavaScript context for this addon. */ + private static final String JS_CONTEXT_PREFIX = "addon:"; + /** Record used to store information about the MacrScript functions for this library. */ private record MTScript(String path, boolean autoExecute, String description, MD5Key md5Key) {} @@ -121,11 +127,17 @@ private record MTScript(String path, boolean autoExecute, String description, MD private final Map legacyEventNameMap = new HashMap<>(); /** The mapping between MTScript function paths and non legacy events. */ - private final Map eventNameMap = new HashMap<>(); + private final Map mtScriptEventNameMap = new HashMap<>(); + + /** The mapping between JavaScript script paths and non legacy events. */ + private final Map jsEventNameMap = new HashMap<>(); /** The ID of the asset for the whole of the add-on Library. */ private final MD5Key assetKey; + /** The name of the JavaScript context for the add on library. */ + private final String jsContextName; + /** * Class used to represent Drop In Libraries. * @@ -172,7 +184,11 @@ private AddOnLibrary( eventsDto.getEventsList().stream() .filter(e -> !e.getMts().isEmpty()) - .forEach(e -> eventNameMap.put(e.getName(), e.getMts())); + .forEach(e -> mtScriptEventNameMap.put(e.getName(), e.getMts())); + + eventsDto.getEventsList().stream() + .filter(e -> !e.getJs().isEmpty()) + .forEach(e -> jsEventNameMap.put(e.getName(), e.getJs())); eventsDto.getLegacyEventsList().stream() .filter(e -> !e.getMts().isEmpty()) @@ -201,6 +217,8 @@ private AddOnLibrary( licenseFile = dto.getLicenseFile(); readMeFile = dto.getReadMeFile(); + + jsContextName = JS_CONTEXT_PREFIX + namespace; } /** @@ -357,6 +375,14 @@ public CompletableFuture> getLicenseAsset() { } } + @Override + public void cleanup() { + // Remove any existing JavaScript context if it exists + if (JSScriptEngine.hasAddOnContext(jsContextName)) { + JSScriptEngine.removeAddOnContext(jsContextName); + } + } + @Override public CompletableFuture getVersion() { return CompletableFuture.completedFuture(version); @@ -485,14 +511,27 @@ void initialize() { data.needsInitialization() .thenAccept( needInit -> { + // First remove any existing JavaScript context if it exists + if (JSScriptEngine.hasAddOnContext(jsContextName)) { + JSScriptEngine.removeAddOnContext(jsContextName); + } + // Then create a new JavaScript context for the add-on library + JSScriptEngine.registerAddOnContext(jsContextName); if (needInit) { - if (eventNameMap.containsKey(FIRST_INIT_EVENT)) { - callMTSFunction(eventNameMap.get(FIRST_INIT_EVENT)).join(); + if (jsEventNameMap.containsKey(FIRST_INIT_EVENT)) { + runJS(jsEventNameMap.get(FIRST_INIT_EVENT)); + } + if (mtScriptEventNameMap.containsKey(FIRST_INIT_EVENT)) { + callMTSFunction(mtScriptEventNameMap.get(FIRST_INIT_EVENT)) + .join(); data.setNeedsToBeInitialized(false).join(); } } - if (eventNameMap.containsKey(INIT_EVENT)) { - callMTSFunction(eventNameMap.get(INIT_EVENT)).join(); + if (jsEventNameMap.containsKey(INIT_EVENT)) { + runJS(jsEventNameMap.get(INIT_EVENT)); + } + if (mtScriptEventNameMap.containsKey(INIT_EVENT)) { + callMTSFunction(mtScriptEventNameMap.get(INIT_EVENT)).join(); } }); }); @@ -516,6 +555,26 @@ private CompletableFuture callMTSFunction(String name) { }); } + /** + * Run the specified JavaScript code from the add-on library in the add-on library's JavaScript + * context. + * + * @param file the JavaScript file withing the file to run. + */ + private void runJS(String file) { + readFile(file) + .thenAccept( + script -> { + try { + JSScriptEngine.getJSScriptEngine() + .evalScript(jsContextName, script.asAsset().getDataAsString(), true); + } catch (ParserException | ScriptException e) { + throw new RuntimeException(e); + } + }) + .join(); + } + CompletableFuture readFile(String path) { return CompletableFuture.supplyAsync( () -> { diff --git a/src/main/java/net/rptools/maptool/model/library/addon/AddOnLibraryManager.java b/src/main/java/net/rptools/maptool/model/library/addon/AddOnLibraryManager.java index b462d21558..5d04309356 100644 --- a/src/main/java/net/rptools/maptool/model/library/addon/AddOnLibraryManager.java +++ b/src/main/java/net/rptools/maptool/model/library/addon/AddOnLibraryManager.java @@ -93,6 +93,7 @@ public void registerLibrary(AddOnLibrary library) { public void deregisterLibrary(String namespace) { var removed = namespaceLibraryMap.remove(namespace.toLowerCase()); if (removed != null) { + removed.cleanup(); new MapToolEventBus() .getMainEventBus() .post(new AddOnsRemovedEvent(Set.of(removed.getLibraryInfo().join()))); @@ -172,6 +173,9 @@ public void removeAllLibraries() { if (libs.size() > 0) { new MapToolEventBus().getMainEventBus().post(new AddOnsRemovedEvent(libs)); + for (var library : namespaceLibraryMap.values()) { + library.cleanup(); + } namespaceLibraryMap.clear(); } } diff --git a/src/main/java/net/rptools/maptool/model/library/token/LibraryToken.java b/src/main/java/net/rptools/maptool/model/library/token/LibraryToken.java index a4bb9bc1c1..1cfa5ee972 100644 --- a/src/main/java/net/rptools/maptool/model/library/token/LibraryToken.java +++ b/src/main/java/net/rptools/maptool/model/library/token/LibraryToken.java @@ -363,6 +363,11 @@ public CompletableFuture> getLicenseAsset() { return CompletableFuture.completedFuture(Optional.empty()); } + @Override + public void cleanup() { + // No cleanup needed + } + /** * @param id the id of the token Lib:Token to get. * @return the Token for the library.