PhoenicisOrg / phoenicis

Phoenicis PlayOnLinux and PlayOnMac 5 repository
https://phoenicis.org/
GNU Lesser General Public License v3.0
678 stars 73 forks source link

Find better way to access script classes from Java #1236

Open plata opened 6 years ago

plata commented 6 years ago

Currently, the class name is computed in a pretty hacky way in WinePrefixContainerWineToolsTab#populate. It works as long as the ID is the snake case form of the class name. There must be a better solution for this.

Some possible solutions:

specified variable

JSON

no class

madoar commented 6 years ago

I'm not really sure about which solution we should take here. The reason for this is, that I believe this to be a more fundamental problem, we're facing here. In a lot of cases we define classes in these scripts (for example for a lot of the engine related scripts), which is the most clean approach, especially when looking at things from the Java side. This leads us to the problem about how to instantiate/run them.

Often we use a simple eval approach, where we just execute the whole file as a script, but I don't think this is the most elegant approach we can and should take. The eval approach has the disadvantages that we simply run the whole script without being able to execute only parts of them, like accessing some information fields or helper methods.

In my eyes a better approach would be to use objects, which can be accessed from the Java backend. For this we would need to specify explicit interfaces, which need to be implemented by the scripts. Then the challenge is, how to call the constructors, defined in javascript. For this challenge I don't have a good solution as of yet.

plata commented 6 years ago

The problem with objects is that you must know the class name to create them. I also don't see a nice way currently to get this information in a generic way.

Please have a look at the PR which is linked above. It provides a solution which is at least not as dirty as the current implementation.

madoar commented 6 years ago

About the class name: I think we can expect, that the class is named the same as the file, it's contained in, i.e. the same as is done in Java.

About the PR: I think we can merge the PR but I think we should let this issue remain open, so that we can find a better long term solution.

plata commented 6 years ago

That's fine for me.

The problem is: we do not know the file name. Also, one file can contain more than one class.

madoar commented 6 years ago

Why don't we know the file/resource name? I mean when we load the script, we should be able to set a corresponding field.

In the case that a file contains more than one class, I would suggest to export only the class named the same as the file itself. If multiple classes need to be exported, one file for each class needs to be created. The other classes can then be imported via the include command.

plata commented 6 years ago

Sounds like a plan. Can we be sure that we always have a file name? Could it happen e.g. that we have have some weird repository which does not have file names (like command line input)?

madoar commented 6 years ago

I would expect that we always have some kind of URI, which we use to access the script. In the case of a command line or something similar, we need to generate an URI. From an URI we should be able to extract something like a file name (even if the resource is not a file).

plata commented 6 years ago

To summarize:

To proof the concept, we can update the engine tools implementation.

If it works out, the concept shall be added to the documentation.

madoar commented 6 years ago

Yes. The remaining part is how to use the created object from the Java side of things. I can think of three ways: 1) we can generate some javascript code, which executes the corresponding run method of the object. 2) we implement the javascript classes/objects by extending a Java interface via Java.extend. This should be the cleanest, but I think it can be quite hard to add extra functions to the javascript class, that aren't defined in the interface (which for example is required for wine) 3) we implement the javascript classes/objects like we do now, just that we define somewhere in the docu, what methods are required. Afterwards we need to access the created object via a ScriptObjectMirror, then we can access the methods of the object via the ScriptObjectMirror instance.

plata commented 6 years ago

There's one big issue: If we follow the approach with our current repository structure, all classes will be named "script" (because all scripts are named "script.js").

madoar commented 6 years ago

Yes, that's right. We would need to rename the files.

plata commented 6 years ago

I'm really not sure what impact this will have (or why @qparis decided to name all scripts "script.js" in the first place).

madoar commented 6 years ago

Ok, I've done some tests. I've added a new Java interface:

package org.phoenicis.engines;

public interface ContainerTool {
    void execute(String container);
}

And a test method to the EnginesToolsManager:

    public void testRun(String container) {
        StringBuilder sb = new StringBuilder();
        sb.append("include([\"engines\", \"wine\", \"engine\", \"object\"]);").append("\n");
        sb.append("var ContainerTool = Java.type(\"org.phoenicis.engines.ContainerTool\");").append("\n");
        sb.append("var ConfigureWine = Java.extend(ContainerTool, {").append("\n");
        sb.append("   execute: function(container) {").append("\n");
        sb.append("      new Wine().prefix(container).run(\"winecfg\").wait();").append("\n");
        sb.append("   }").append("\n");
        sb.append("});").append("\n");

        final InteractiveScriptSession interactiveScriptSession = scriptInterpreter.createInteractiveSession();

        interactiveScriptSession.eval(
                sb.toString(),
                ignored -> interactiveScriptSession.eval("new ConfigureWine()", output -> {
                    final ContainerTool toolObject = (ContainerTool) output;
                    toolObject.execute(container);
                }, e -> e.printStackTrace()), e -> e.printStackTrace());
    }

The Javascript looks like this (for all, who don't like the StringBuilder stuff):

include(["engines", "wine", "engine", "object"]);

var ContainerTool = Java.type("org.phoenicis.engines.ContainerTool");
var ConfigureWine = Java.extend(ContainerTool, {
   execute: function(container) {
      new Wine().prefix(container).run("winecfg").wait();
   }
});

If you execute the EnginesToolsManager#testRun method, for example in the constructor of WinePrefixContainerPanel via: engineToolsManager.testRun(containerEntity.getName()); you should see, that a wine configuration window opens.

plata commented 6 years ago

I like the approach because it's very explicit and clear what has to be implemented by a tool. I'm still not sure about the way to get the class name.

madoar commented 6 years ago

I think we should think about changing the methods inside the script sessions.

First, I think it's a good idea to directly pass ScriptDTO to the script sessions, because this object represents, what the script session should execute. With this change it would be a good idea to move the class name identification stuff to the ScriptDTO. I think we can provide a priority hierarchy, which decides how the class name is calculated: 1) the highest priority has an exportedClassName field in the script.json file 2) if no such field exists, we use the name of the resource/file for the class name

In addition I'm not sure, if it's a good idea to simply return an Object. I think a generic signature, that automatically does the casting to the target type would be more comfortable.

So the new signature of the a script session should look like this in my opinion:

public <Result> void eval(ScriptDTO script, Consumer<Result> responseCallback, Consumer<Exception> errorCallback)
plata commented 6 years ago

Should we also change the application scripts to implement on object oriented interface? It would be cleaner but on the other hand the code is more difficult to read so it might prevent less experienced users from writing their own scripts.

madoar commented 6 years ago

Yes, I think it would be best, if the scripts need to implement an interface too, but I think changing the application scripts can wait. First we should concentrate on changing the engines (i.e. wine), the verbs and the tools.

The Java.extend approach seems to have one disadvantage, which I couldn't solve as of now. It seems, that with Java.extend you specify an anonymous class, which exports only the methods defined inside the extended interface/class. If you try to add additional field/methods to the extending class, these fields/methods are only accessible from inside the class and not from outside.

For example:

var Run = Java.type("java.lang.Runnable");
var MyRun = Java.extend(Run, {
    value: "Test",
    run: function() {
        print(this.value);
    }
});

var run = new MyRun();
run.run();
print(run.value);

This code snipped would print:

Test
undefined

on the terminal.

plata commented 6 years ago

Didn't we discuss this already before some time ago? Sounds familiar.

madoar commented 6 years ago

Yes I already wrote about the issue some time ago. I believe it was in one of the issues about the planned changes to the engines.

plata commented 6 years ago

If we use it for applications, we can get rid of the injected TYPE_ID, CATEGORY_ID and APPLICATION_ID. That's also good.

I don't believe the problem you've described above is an issue because you will never be able to execute code outside of the class.

I'm still concerned about the higher complexity of the implementation. Can we add some syntactic sugar which makes it easier?

madoar commented 6 years ago

The visibility of fields and methods outside of the defined class should only be an issue for the engines. For example, to create a new Wine instance, we need to set a lot of setter methods for wine related configuration. These methods shouldn't be a part of the interface, because they are only wine related methods and don't make sense for other types of engines.

For which use cases do you want some syntactic sugar?

plata commented 6 years ago

For this var MyRun = Java.extend(Run, {.

According https://docs.oracle.com/javase/8/docs/technotes/guides/scripting/prog_guide/javascript.html#CIHJCFAH, it should be possible to write something like:

include(["engines", "wine", "engine", "object"]);

var ContainerTool = Java.type("org.phoenicis.engines.ContainerTool");
var ConfigureWine = new ContainerTool() {
   execute: function(container) {
      new Wine().prefix(container).run("winecfg").wait();
   }
}

Or maybe even:

var ConfigureWine = new org.phoenicis.engines.ContainerTool() ...
madoar commented 6 years ago

Is ConfigureWine then a class or an object?

plata commented 6 years ago

The documentation which I've linked says it's an object.

Can you try it in your test code?

madoar commented 6 years ago

ConfigureWine is an object. This means that the following code won't work:

include(["engines", "wine", "engine", "object"]);

var ContainerTool = Java.type("org.phoenicis.engines.ContainerTool");
var ConfigureWine = new ContainerTool() {
   execute: function(container) {
      new Wine().prefix(container).run("winecfg").wait();
   }
}

var instance = new ConfigureWine();

What would work is the following:

include(["engines", "wine", "engine", "object"]);

var ContainerTool = Java.type("org.phoenicis.engines.ContainerTool");
var instance = new ContainerTool() {
   execute: function(container) {
      new Wine().prefix(container).run("winecfg").wait();
   }
}

instance.execute("container");
plata commented 6 years ago

Do we need the ability to create objects? At least for tools and apps I don't think so.

madoar commented 6 years ago

I'm not sure. I think it's better if we are able to create objects whenever we want to and not only automatically during script loading.

plata commented 6 years ago

Ok. Can we store the second argument for extend in a variable first to make it more readable?

madoar commented 6 years ago

Yes, the following works:

var runImplementation = {
    value: "Test",
    run: function() {
        print(this.value);
    }
};

var Run = Java.type("java.lang.Runnable");
var MyRun = Java.extend(Run, runImplementation);

var run = new MyRun();
run.run();
plata commented 6 years ago

And now the same for the installer scripts?

madoar commented 6 years ago

With installer scripts you mean the application scripts? I think we can convert them to the same structure.

Do you have an idea for the engine implementations?

plata commented 6 years ago

Not yet. Step by step. Also I don't see this for alpha yet.

madoar commented 6 years ago

I may have an idea. We could provide a configure method instead of all the setter/getter methods we currently provide. This configure method takes a single object.

The call would then look something like this:

new CustomInstallerScript()
    .name("League of Legends")
    .editor("Riot Games")
    .applicationHomepage("http://leagueoflegends.com/")
    .author("Plata, feanor12, Thog")
    .category("Games")
    .configure({
        wineDistribution: "staging",
        wineVersion: LATEST_STAGING_VERSION
    })
    .installationCommand(function (wizard) { ... })
    .preInstall(function (engine, wizard) { ... })
    .postInstall(function (engine, wizard) { ... })
    .executable("run.bat")
    .go();

We would need to provide the configure method to both the quick scripts and the engines.

plata commented 6 years ago

I don't really see the advantage of this. What problem does it solve?

madoar commented 6 years ago

It's not really necessary for the quick scripts, but solves the field/method visibility problem for the engines. A generic wine object shouldn't define methods windowsVersion or d3dx9, right? Therefore we could define a void configure(Object configuration) method in the Engine interface, which Wine needs to implement. For League of Legends we could set the wine related information then like this:

wine.configure({
   windowsVersion: "winxp",
   verbs: ["d3dx9"],
   overrideDLL: ["native, builtin", ["atl120", "msvcp120", "msvcr120", "vcomp120", "msvcp140"]],
   enableCSMT: true
});
plata commented 6 years ago

Lets discuss this later (I think this declarative approach has some disadvantages e.g. you cannot easily decide to override the DLL in the pre/postInstall step).

First we should decide about the stuff that popped up in the PR: Do we want to implement the installer/engine/tool in json? Proposal by @madoar:

{
    run: function (container) {
        new Wine()
            .prefix(container)
            .run("winecfg")
            .wait();
    }
}

Questions:

plata commented 6 years ago

I will try to summarize our requirements:

  1. API shall extend Java interface such that it is well defined
  2. Javascript objects/classes must be called from Java such that we can pass information to it (e.g. container for tools)
  3. everything that happens shall be explicit (no hidden logic)
  4. API shall be easy to use (also for non-experienced programmers/users)
  5. all APIs shall follow the same mechanism such that you do not have to learn multiple ways (if you understood installers, you know how to write tools)
madoar commented 6 years ago

What exactly do you mean by hidden logic?

Just to give an example for the approach described in #577. In theory we could also think about describing the scripts in a minimalistic way, which would remove all the redundant stuff like the var Installer = Java.extend(Interface, implementationJson); calls from the scripts.

The script for 7-zip would then look like this:

include(["engines", "wine", "quick_script", "online_installer_script"]);

var implementation = {
    run: function () {
        new OnlineInstallerScript()
            .name("7-zip")
            .editor("Igor Pavlov")
            .applicationHomepage("http://www.7-zip.org/")
            .author("ImperatorS79")
            .url("https://www.7-zip.org/a/7z1801.exe")
            .checksum("d56bca4973b1d1aa5915c41dce318b077ce8b5b2")
            .category("Accessories")
            .executable("7zFM.exe")
            .go();
    }
};

When loading the script, Phoenicis would then need to execute it, and afterwards insert the result of the script, i.e. the json object into the command var Implementation = Java.extend(Installer, implementation); new Implementation().

I haven't tested this approach yet, so I'm not sure if this would really work.

madoar commented 6 years ago

What I still don't like about the change is, that we still require a named variable. The best solution in my eyes would be if we could return an unnamed json object, i.e.:

include(["engines", "wine", "quick_script", "online_installer_script"]);

{
    run: function () {
        new OnlineInstallerScript()
            .name("7-zip")
            .editor("Igor Pavlov")
            .applicationHomepage("http://www.7-zip.org/")
            .author("ImperatorS79")
            .url("https://www.7-zip.org/a/7z1801.exe")
            .checksum("d56bca4973b1d1aa5915c41dce318b077ce8b5b2")
            .category("Accessories")
            .executable("7zFM.exe")
            .go();
    }
};
madoar commented 6 years ago

Ok, I got it. The following should work too:

The script

include(["engines", "wine", "quick_script", "online_installer_script"]);

{
    run: function () {
        new OnlineInstallerScript()
            .name("7-zip")
            .editor("Igor Pavlov")
            .applicationHomepage("http://www.7-zip.org/")
            .author("ImperatorS79")
            .url("https://www.7-zip.org/a/7z1801.exe")
            .checksum("d56bca4973b1d1aa5915c41dce318b077ce8b5b2")
            .category("Accessories")
            .executable("7zFM.exe")
            .go();
    }
};

InteractiveScriptSession.java

InteractiveScriptSession.java needs to define a new method, taking an additional previous result:

void eval(Object lastResult, String evaluation, Consumer<Object> responseCallback, Consumer<Exception> errorCallback)

NashornInteractiveSession.java

NashornInteractiveSession.java needs to implement the new method:

    @Override
    public void eval(Object lastResult, String evaluation, Consumer<Object> responseCallback, Consumer<Exception> errorCallback) {
        nashornEngine.put("implementation", lastResult, errorCallback);
        responseCallback.accept(nashornEngine.evalAndReturn(evaluation, errorCallback));
    }

AppsController.java

AppsController.java requires a new method to load the scripts. The method should look similar to the one for loading the engine tools:

    public void runScript(String scriptContent, Runnable doneCallback, Consumer<Exception> errorCallback) {
        final InteractiveScriptSession interactiveScriptSession = scriptInterpreter.createInteractiveSession();

        interactiveScriptSession.eval(
                scriptContent,
                scriptObject -> interactiveScriptSession.eval(scriptObject, "var Implementation = Java.extend(org.phoenicis.scripts.Installer, implementation); new Implementation()", output -> {
                    final Installer installerObject = (Installer) output;
                    installerObject.run();
                }, errorCallback), errorCallback);
    }
plata commented 6 years ago

I like:

I don't like:

If we specify the exported class name in the script.json, we can solve the biggest issue of the current approach.

What do you think?

madoar commented 6 years ago
plata commented 6 years ago

Can you implement this (for applications and tools)? Doesn't seem to be so much, the script changes should be possible with a little regex search/replace.

madoar commented 6 years ago
plata commented 6 years ago

I think this is really a problem:

plata commented 6 years ago

Just saw a description here which avoids variable names by simply returning the implemented object. Could also be an option.

Back to the topic. We could use composition over inheritance. It would work like this: The Javascript Wine class does not extend the Java Engine interface directly. Instead, it has a member WineEngine which implements the interface. All methods which are not part of the interface are implemented in Wine. For the calling code, this would be transparent. This implies that Wine objects are created from Javascript like it is done currently.

madoar commented 6 years ago
madoar commented 6 years ago

Another solution would be to provide a Java interface for the engines, and a Wrapper implementation of it in Java. This would also allow us to let the wine implementation remain as it is.

The approach would then look something like this:

The interface definition:

public interface Engine {
   void preInstall(...);
   void postInstall(...);
   void go(...);

The wrapper implementation:

public class EngineWrapper implements Engine {
   private final ScriptObjectMirror mirror;

   public EngineWrapper(ScriptObjectMirror mirror) {
      this.mirror = mirrror;
   }

   public void preInstall(...) {
      mirror.callMember("preInstall", ...);
   }

   public void postInstall(...) {
      mirror.callMember("postInstall", ...);
   }

   public void go(...) {
      mirror.callMember("go", ...);
   }

When using the engine implementation from the Java side we would just need to create a new EngineWrapper object.

plata commented 6 years ago

I had thought about the Verbs as well and came to the same conclusion like you. However, I dropped because it's semantically problematic (even though it would work): install(Engine engine) suggests that you can call install and pass it an Engine. But: this is not true at all. You must call a specific Verb with a specific Engine (e.g. dotnet40 with Wine). Anything else will not work (at least in general).

For the EngineWrapper approach: How would you get/create an Engine object in JS?

qparis commented 6 years ago

I'm not sure to fully understand your problem, but I suspect that you need a IoC container. Just an intuition: isn't there a way to use Spring?

Basically, my idea would be to add some sort of annotation any javascript instance ; then Spring would inject them to the context when the app is loaded. This would have a lot of advantages:

The downside is that the repository would be read before the complete loading of the spring context ; maybe it would require to have a 3 steps loading process:

  1. The "kernel" context would be loaded: it contains all the tools required to load and read the repositories
  2. The javascript files would be loaded, feeding the context
  3. Any other dependencies would be then injected

I know that it is a bit theoretical, but it would be very elegant IMHO :)