Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow JS plugin environment to run sub-process #2783

Closed
konsumer opened this issue Apr 21, 2020 · 16 comments
Closed

Allow JS plugin environment to run sub-process #2783

konsumer opened this issue Apr 21, 2020 · 16 comments
Labels
feature It's a feature, not a bug.
Milestone

Comments

@konsumer
Copy link
Contributor

It would be handy if we could spawn a CLI program, for really simple plugins that just wrap some other existing tool. This would allow people to mostly write plugins in whatever language they like, or whatever is already made, as most languages have a way to make a CLI program (stdio/stdout, file reading/writing, etc.)

This is related to #2780 and I'd like to add it in a similar fashion to ScriptFileInfo.

Some examples of plugin systems that use CLI programs to do fancy stuff, that we might get inspiration from:

  • inkscape has a nice system where you define meta-data in an XML file. They have a whole system where you can define the UI and it will pass what was selected as options to your CLI program, so you can effectively write super-integrated plugins, with your logic in any language you like (as long as you have the local runtime for it.) This means that python is very common, but for web-related tools I have made a few inkscape extensions in nodejs. In this system, you just directly write to the file, and receive the SVG source on stdin.
  • protoc - you write your plugin in whatever language you like and put it in your path. stdin & stdout are in a pre-defined binary format (protobuf, which you need in your language anyway to parse protobuf files) and you output what you want the plugin to write to. It feeds your script a pre-parsed protobuf object, so you just have to be able to read basic protobuf messages to write a plugin.

I imagine in it's simplest form, it would just be a thin wrapper for QProcess. So you could something like this (without any async stuff):

/* global tiled, TextFile, Process */

tiled.registerMapFormat('Subprocess Demo', {
  name: 'Subprocess Demo format',
  extension: 'txt',

  write: function (map, fileName) {
    const n = new Process()
    n.start(`ls ${fileName}`)
    n.waitForFinished()
    // some plugins might just write the file directly, themselves
    const out = n.readAllStandardOutput()
    const file = new TextFile(fileName, TextFile.WriteOnly)
    file.write(out)
    file.commit()
  }
})

In this form, if the user liked go, ruby, python, nodejs, C#, C++, etc, better than Qt-JS, they could use it, as long as their users can run their thing. They still have to write a little wrapper in Qt-JS, but I think that's ok (and sort of takes the place of XML meta-data file in inkscape plugins.) Plugin-makers that wish to maintain maximum compatibility for end-users (no installing nodejs, python, being a windows/posix user, etc) should opt for all Qt-JS plugins, but if the plugin needs to do stuff in some other language, it's possible.

What do you think?

@konsumer
Copy link
Contributor Author

konsumer commented Apr 21, 2020

I think one other thing that would be handy is a way to get the location of the current script file (like node's __dirname), so you could use the path:

/* global tiled, TextFile, Process */

tiled.registerMapFormat('Subprocess Demo', {
  name: 'Subprocess Demo format',
  extension: 'txt',

  write: function (map, fileName) {
    const n = new Process()
     // is there a __dirname work-alike for Qt? 
     n.start(`${__dirname}/myCoolPlugin ${fileName}`)
     // need a way to pump JSON map into stdin, here...
     n.waitForFinished()
     // the plugin will write fileName, on it's own
  }
})

You can do cross-platform support by making a file myCoolPlugin.bat in plugin-dir for windows people, and a myCoolPlugin with a posix-shebang and +x permission. This is sort of how node's bin works. You could also add code to check tiled.platform and tiled.arch to work out how to run the right thing. This would be great for including a go/c++/etc pre-built binary in the plugin dir, for easier installs.

/* global tiled, TextFile, Process */

tiled.registerMapFormat('Subprocess Demo', {
  name: 'Subprocess Demo format',
  extension: 'txt',

  write: function (map, fileName) {
    const n = new Process()
     n.start(`${__dirname}/${tiled.platform}/${tiled.arch}/myCoolPlugin ${fileName}`)
     n.waitForFinished()
  }
})

@bjorn bjorn added the feature It's a feature, not a bug. label Apr 21, 2020
@bjorn bjorn changed the title [feature] allow JS plugin environment to run sub-process Allow JS plugin environment to run sub-process Apr 21, 2020
@bjorn
Copy link
Member

bjorn commented Apr 21, 2020

I think adding a thin QProcess wrapper would suffice for now. I would take the Process service in Qbs as an example in terms of API.

Regarding defining UI in XML or integrating protobuf, I think that goes a little too far right now. Though eventually, I would like to enable building UIs in extensions. I'm not sure if it's worth implementing this with QWidget-based API or whether we could rather make this possible by using QML / Qt Quick.

@konsumer
Copy link
Contributor Author

konsumer commented Apr 21, 2020

Regarding defining UI in XML or integrating protobuf, I think that goes a little too far right now.

No, I just meant that for example protoc uses a known protocol on stdin/out (like in our case that could be JSON) and inkscape implements their plugins primarily in scripts, with a little meta-data. I meant more to think about these as inspiration. I think with a Process object we'd be able to do all of the same stuff with a pretty small wrapper script.

I would like to enable building UIs in extensions. I'm not sure if it's worth implementing this with QWidget-based API or whether we could rather make this possible by using QML / Qt Quick.

Yeh, I don't know the details around this, but it seems like it should be pretty built-in to the scripting environment to spawn a config dialog or whatever. I think we should go as thin as possible, and rather than parsing some data-format (like XML) let the user make the widget, however that works easiest in the Qt javascript env.

Would this kind of thing already work in the plugin API?:

const dialog = Qt.createComponent("config.qml")

Again, maybe __dirname would be handy here.

@bjorn
Copy link
Member

bjorn commented Apr 22, 2020

Would this kind of thing already work in the plugin API?:

const dialog = Qt.createComponent("config.qml")

Hmm, it actually works, but you need to create a window and actually instantiate the component. I created a file called window.qml in my extensions folder with the following contents:

import QtQuick.Window 2.12

Window {
    x: 100; y: 100; width: 100; height: 100
}

Then, in the Tiled console, I typed:

windowComponent = Qt.createComponent("ext:window.qml")
window = windowComponent.createObject()
window.visible = true

But, while this will work with a local build of Tiled, it does not work with the currently released builds because the relevant Qt Quick libraries are not shipped.

Again, maybe __dirname would be handy here.

Maybe, but I'm not sure how this would work. It is easy to set it globally before evaluating a script file, but it will not work when it is used in a function when that function is called later.

@konsumer
Copy link
Contributor Author

konsumer commented Apr 22, 2020

It is easy to set it globally before evaluating a script file, but it will not work when it is used in a function when that function is called later.

I tried this (adding __filename to globals:

QJSValue ScriptManager::evaluate(const QString &program,
                                 const QString &fileName, int lineNumber)
{
    QJSValue globalObject = mEngine->globalObject();
    globalObject.setProperty(QStringLiteral("__filename"), fileName);

    QJSValue result = mEngine->evaluate(program, fileName, lineNumber);
    checkError(result, program);
    return result;
}

This might be the wrong way, but it outputted another plugin's file:

tiled.registerMapFormat('Subprocess Demo', {
  name: 'Subprocess Demo format',
  extension: 'txt',

  write: function (map, fileName) {
    console.log('__filename',  __filename)
  }
})
qml: __filename /Users/konsumer/Library/Preferences/Tiled/extensions/tiled-to-godot-export/utils.js

@bjorn
Copy link
Member

bjorn commented Apr 22, 2020

@konsumer Yes, this does not work for the reason I described. :-)

@konsumer
Copy link
Contributor Author

konsumer commented Apr 22, 2020

this works, in the script, which might be a decent solution:

const f = __filename

tiled.registerMapFormat('Subprocess Demo', {
  name: 'Subprocess Demo format',
  extension: 'txt',

  write: function (map, fileName) {
    console.log(f)
  }
})

To avoid confusion, it could be called something other than __filename, like __plugin.

@konsumer
Copy link
Contributor Author

konsumer commented Apr 22, 2020

More on the other idea, I put the qml file in the plugin dir, and used it with the FileInfo branch, and it worked perfectly (on Mac Qt 5.14.1):

/* global tiled, Qt, FileInfo */

const f = __filename

tiled.registerMapFormat('Subprocess Demo', {
  name: 'Subprocess Demo format',
  extension: 'txt',

  write: function (map, fileName) {
    console.log(FileInfo.path(f))
    const windowComponent = Qt.createComponent(`${FileInfo.path(f)}/window.qml`)
    const window = windowComponent.createObject()
    window.visible = true
  }
})

@konsumer
Copy link
Contributor Author

It even worked with an image in the same dir, and more complex dialog:

Screen Shot 2020-04-22 at 5 39 18 AM

I mean, I'm no Qt dialog designer, so it's ugly, but it works great.

@bjorn
Copy link
Member

bjorn commented Apr 22, 2020

@konsumer That's great! So, the main challenge with supporting this will be to adjust the various release packages to include the necessary Qt Quick plugins.

Regarding __filename, I don't think __plugin helps avoiding the confusion which will surely come from the value only being available when the script is evaluated. But, I've just added it and noted this property in the documentation: 0b08577

@konsumer
Copy link
Contributor Author

konsumer commented Apr 22, 2020

So, the main challenge with supporting this will be to adjust the various release packages to include the necessary Qt Quick plugins.

Yeh, for sure. It's cool it works locally, though. I am imagining some really sweet plugin config UIs that are pretty easy to put together.

I don't think __plugin helps avoiding the confusion which will surely come from the value only being available when the script is evaluated

I just meant because node has the__filename and __dirnameglobal names, but they work differently (it's always the current file, no scope trickery.) Maybe it's ok if they don't work exactly the same.

@bjorn
Copy link
Member

bjorn commented Apr 22, 2020

I just meant because node has the__filename and __dirnameglobal names, but they work differently (it's always the current file, no scope trickery.) Maybe it's ok if they don't work exactly the same.

Yeah. And, in my patch I delete the property after evaluation, which makes it throw an error when you try to use it when you can't. I think that'll be enough.

@konsumer
Copy link
Contributor Author

Yeah. And, in my patch I delete the property after evaluation, which makes it throw an error when you try to use it when you can't. I think that'll be enough.

Yep, just saw that. I think you're right.

@konsumer
Copy link
Contributor Author

konsumer commented May 1, 2020

Ok, I think I have packaged all this in Qbs4QJS, specifically Process. Not sure how we should get it in here, though.

Here is a unit-test that runs ls -al /tmp.

@Deepscorn
Copy link

Deepscorn commented Jun 7, 2020

I'm also wondering if any interop can be built using javascript extensions.
Want to expose some javascript extensions functionality to my program and maybe update my program on some tiled events (mouse move, asset save, etc.). So both programs are opened at the same time and somehow exchange information, execute commands.

Was thinking about:

  1. Processes communicate through tcp/ip.
  2. One process writes to file, another reads it and does something based on it's content.
  3. One process launches another, writes something to stdin, another reads it and does something based on content.

What I tried and thought of so far:

  1. setTimeout in javascript extensions - just to make something continuosly (loop). But it does not work - "not defined" error.
  2. just while (true) - I can do anything, but Tiled is freezed (main thread is blocked). Didn't found a way to execute on another thread.
  3. one process launches another and use stdin/out to communicate. Must work in theory. But looks hackish. Such outputs will be mixed with normal outputs (spam in logs). And I don't even want to think about how much I can write/read, before will get memory/performance problems.
  4. XMLHttpRequest - just to check if javascript extension can work asynchrounosly. Tested: synchrounous version works. Async - not
  5. WebSocket - "not defined" error

@bjorn Is it possible to schedule code in javascript extensions in Tiled? Create thread? Work with sockets? Sorry, I'm new to javascript. Need help understanding which way to go.

@konsumer Tell me please, what's your progress? Looks, like you are trying to achieve some form of interop too. But I don't quite understand what you were able to achieve.

Context: I wrote a program (libgdx, java) which works with tmx (postprocessing, updating file and special printing). It launches Tiled to edit tmx. Currently one launches another through CLI (java - Process; tiled - using command which launch mine program through CLI)

Tiled v. 1.3.5

@konsumer
Copy link
Contributor Author

konsumer commented Jun 7, 2020

It's not really interop, just running a sub-process. Maybe it would work better if you made your program just watch for file changes? Then you could reload on change, and I think it would acheive what you are going for. It would be a totally separate program, but update on save.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature It's a feature, not a bug.
Projects
None yet
Development

No branches or pull requests

3 participants