Lua: Add Install module

Allows plugins to install packages they might need.

Change-Id: I4948dd0a6568e093fc35e4486d2e2a084090e103
Reviewed-by: David Schulz <david.schulz@qt.io>
This commit is contained in:
Marcus Tillmanns
2024-05-14 13:47:50 +02:00
parent f54a83ff45
commit eec48b8f8e
9 changed files with 482 additions and 25 deletions

View File

@@ -10,6 +10,7 @@ add_qtc_plugin(Lua
bindings/fetch.cpp
bindings/hook.cpp
bindings/inheritance.h
bindings/install.cpp
bindings/layout.cpp
bindings/messagemanager.cpp
bindings/qtcprocess.cpp

View File

@@ -92,11 +92,12 @@ void addFetchModule()
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) {
callback(QString("%1 (%2)")
callback(QString("%1 (%2):\n%3")
.arg(reply->errorString())
.arg(QString::fromLatin1(
QMetaEnum::fromType<QNetworkReply::NetworkError>()
.valueToKey(reply->error()))));
.valueToKey(reply->error())))
.arg(QString::fromUtf8(reply->readAll())));
return;
}

View File

@@ -0,0 +1,351 @@
// Copyright (C) 2024 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
#include "../luaengine.h"
#include "../luatr.h"
#include <coreplugin/icore.h>
#include <coreplugin/progressmanager/progressmanager.h>
#include <coreplugin/progressmanager/taskprogress.h>
#include <solutions/tasking/networkquery.h>
#include <solutions/tasking/tasktree.h>
#include <utils/algorithm.h>
#include <utils/infobar.h>
#include <utils/networkaccessmanager.h>
#include <utils/stylehelper.h>
#include <utils/unarchiver.h>
#include <QJsonDocument>
#include <QLabel>
#include <QTemporaryFile>
#include <QtConcurrent>
using namespace Core;
using namespace Tasking;
using namespace Utils;
namespace Lua::Internal {
expected_str<QJsonDocument> getPackageInfo(const FilePath &appDataPath)
{
const FilePath packageInfoPath = appDataPath / "package.json";
if (!packageInfoPath.exists())
return QJsonDocument();
expected_str<QByteArray> json = packageInfoPath.fileContents();
if (!json)
return make_unexpected(json.error());
if (json->isEmpty())
return QJsonDocument();
QJsonParseError error;
QJsonDocument doc(QJsonDocument::fromJson(*json, &error));
if (error.error != QJsonParseError::NoError)
return make_unexpected(error.errorString());
if (!doc.isObject())
return make_unexpected(Tr::tr("Package info is not an object"));
return doc;
}
expected_str<QJsonObject> getInstalledPackageInfo(const FilePath &appDataPath, const QString &name)
{
auto packageDoc = getPackageInfo(appDataPath);
if (!packageDoc)
return make_unexpected(packageDoc.error());
QJsonObject root = packageDoc->object();
if (root.contains(name)) {
QJsonValue v = root[name];
if (!v.isObject())
return make_unexpected(Tr::tr("Installed package info is not an object"));
return v.toObject();
}
return QJsonObject();
}
expected_str<QJsonDocument> getOrCreatePackageInfo(const FilePath &appDataPath)
{
expected_str<QJsonDocument> doc = getPackageInfo(appDataPath);
if (doc && doc->isObject())
return doc;
QJsonObject obj;
return QJsonDocument(obj);
}
expected_str<void> savePackageInfo(const FilePath &appDataPath, const QJsonDocument &doc)
{
if (!appDataPath.ensureWritableDir())
return make_unexpected(Tr::tr("Could not create app data directory"));
const FilePath packageInfoPath = appDataPath / "package.json";
return packageInfoPath.writeFileContents(doc.toJson())
.transform_error([](const QString &error) {
return Tr::tr("Could not write to package info: %1").arg(error);
})
.transform([](qint64) { return; });
}
struct InstallOptions
{
QUrl url;
QString name;
QString version;
};
size_t qHash(const InstallOptions &item, size_t seed = 0)
{
return qHash(item.url, seed) ^ qHash(item.name, seed) ^ qHash(item.version, seed);
}
static FilePath destination(const FilePath &appDataPath, const InstallOptions &options)
{
return appDataPath / "packages" / options.name / options.version;
}
static Group installRecipe(
const FilePath &appDataPath,
const QList<InstallOptions> &installOptions,
const sol::protected_function &callback)
{
Storage<QFile> storage;
const LoopList<InstallOptions> installOptionsIt(installOptions);
const auto emitResult = [callback](const QString &error = QString()) {
if (error.isEmpty()) {
LuaEngine::void_safe_call(callback, true);
return DoneResult::Success;
}
LuaEngine::void_safe_call(callback, false, error);
return DoneResult::Error;
};
const auto onDownloadSetup = [installOptionsIt](NetworkQuery &query) {
query.setRequest(QNetworkRequest(installOptionsIt->url));
query.setNetworkAccessManager(NetworkAccessManager::instance());
return SetupResult::Continue;
};
const auto onDownloadDone = [emitResult, storage](const NetworkQuery &query, DoneWith result) {
if (result == DoneWith::Error)
return emitResult(query.reply()->errorString());
if (result == DoneWith::Cancel)
return DoneResult::Error;
QNetworkReply *reply = query.reply();
const auto size = reply->size();
const auto written = storage->write(reply->readAll());
if (written != size)
return emitResult(Tr::tr("Could not write to temporary file"));
storage->close();
return DoneResult::Success;
};
const auto onUnarchiveSetup =
[appDataPath, installOptionsIt, storage, emitResult](Unarchiver &unarchiver) {
const auto sourceAndCommand = Unarchiver::sourceAndCommand(
FilePath::fromUserInput(storage->fileName()));
if (!sourceAndCommand) {
emitResult(sourceAndCommand.error());
return SetupResult::StopWithError;
}
unarchiver.setSourceAndCommand(*sourceAndCommand);
unarchiver.setDestDir(destination(appDataPath, *installOptionsIt));
return SetupResult::Continue;
};
const auto onUnarchiverDone = [appDataPath, installOptionsIt, emitResult](DoneWith result) {
if (result == DoneWith::Error)
return emitResult(Tr::tr("Unarchiving failed"));
if (result == DoneWith::Cancel)
return DoneResult::Error;
expected_str<QJsonDocument> doc = getOrCreatePackageInfo(appDataPath);
if (!doc)
return emitResult(doc.error());
QJsonObject obj = doc->object();
QJsonObject installedPackage;
installedPackage["version"] = installOptionsIt->version;
installedPackage["name"] = installOptionsIt->name;
installedPackage["path"] = destination(appDataPath, *installOptionsIt).toFSPathString();
obj[installOptionsIt->name] = installedPackage;
expected_str<void> res = savePackageInfo(appDataPath, QJsonDocument(obj));
if (!res)
return emitResult(res.error());
return DoneResult::Success;
};
return Group{
storage,
parallelIdealThreadCountLimit,
installOptionsIt,
Group{
onGroupSetup([emitResult, storage, installOptionsIt] {
const QString fileName = installOptionsIt->url.fileName();
const QString ext = fileName.mid(fileName.indexOf('.'));
{
QTemporaryFile tempFile(QDir::tempPath() + "/XXXXXX" + ext);
tempFile.setAutoRemove(false);
tempFile.open();
(*storage).setFileName(tempFile.fileName());
}
if (!storage->open(QIODevice::WriteOnly)) {
emitResult(Tr::tr("Could not open temporary file"));
return SetupResult::StopWithError;
}
return SetupResult::Continue;
}),
NetworkQueryTask(onDownloadSetup, onDownloadDone),
UnarchiverTask(onUnarchiveSetup, onUnarchiverDone),
onGroupDone([storage, emitResult] { storage->remove(); }),
},
onGroupDone([emitResult](DoneWith result) {
if (result == DoneWith::Cancel)
emitResult("Installation was canceled");
else if (result == DoneWith::Success)
emitResult();
}),
};
}
void addInstallModule()
{
class State
{
public:
State() = default;
State(const State &) {}
~State()
{
for (auto tree : m_trees)
delete tree;
}
TaskTree *createTree()
{
auto tree = new TaskTree();
m_trees.append(tree);
QObject::connect(tree, &TaskTree::done, tree, &QObject::deleteLater);
return tree;
};
private:
QList<QPointer<TaskTree>> m_trees;
};
LuaEngine::registerProvider(
"Install", [state = State()](sol::state_view lua) mutable -> sol::object {
sol::table async
= lua.script("return require('async')", "_install_async_").get<sol::table>();
sol::function wrap = async["wrap"];
sol::table install = lua.create_table();
const ScriptPluginSpec pluginSpec = lua.get<ScriptPluginSpec>("PluginSpec");
install["packageInfo"] =
[pluginSpec](const QString &name, sol::this_state l) -> sol::optional<sol::table> {
expected_str<QJsonObject> obj
= getInstalledPackageInfo(pluginSpec.appDataPath, name);
if (!obj)
throw sol::error(obj.error().toStdString());
return sol::table::create_with(
l.lua_state(),
"name",
obj->value("name").toString(),
"version",
obj->value("version").toString(),
"path",
FilePath::fromUserInput(obj->value("path").toString()));
};
install["install_cb"] =
[pluginSpec, &state](
const QString &msg,
const sol::table &installOptions,
const sol::function &callback) {
QList<InstallOptions> installOptionsList;
if (installOptions.size() > 0) {
for (const auto &pair : installOptions) {
const sol::object &value = pair.second;
if (value.get_type() != sol::type::table)
throw sol::error("Install options must be a table");
const sol::table &table = value.as<sol::table>();
const QString name = table["name"];
const QUrl url = QUrl::fromUserInput(table["url"]);
if (url.scheme() != "https")
throw sol::error("Only HTTPS is supported");
const QString version = table["version"];
installOptionsList.append({url, name, version});
}
} else {
const QString name = installOptions["name"];
const QUrl url = QUrl::fromUserInput(installOptions["url"]);
const QString version = installOptions["version"];
if (url.scheme() != "https")
throw sol::error("Only HTTPS is supported");
installOptionsList.append({url, name, version});
}
const Utils::Id infoBarId = Utils::Id::fromString(
"Install" + pluginSpec.name + QString::number(qHash(installOptionsList)));
InfoBarEntry entry(infoBarId, msg, InfoBarEntry::GlobalSuppression::Enabled);
entry.addCustomButton(
Tr::tr("Install"),
[infoBarId, &state, pluginSpec, installOptionsList, callback]() {
auto tree = state.createTree();
auto progress = new TaskProgress(tree);
progress->setDisplayName(Tr::tr("Installing package(s) %1").arg("..."));
tree->setRecipe(
installRecipe(pluginSpec.appDataPath, installOptionsList, callback));
tree->start();
Core::ICore::infoBar()->removeInfo(infoBarId);
});
entry.setCancelButtonInfo(
[callback]() { callback(false, "User denied installation"); });
entry.setDetailsWidgetCreator([pluginSpec, installOptionsList]() -> QWidget * {
const QString markdown
= Tr::tr("The plugin \"**%1**\" would like to install the following "
"package(s):\n\n")
.arg(pluginSpec.name)
+ Utils::transform(installOptionsList, [](const InstallOptions &options) {
return QString("* %1 - %2 (from: [%3](%3))")
.arg(options.name, options.version, options.url.toString());
}).join("\n");
QLabel *list = new QLabel();
list->setTextFormat(Qt::TextFormat::MarkdownText);
list->setText(markdown);
list->setMargin(StyleHelper::SpacingTokens::ExPaddingGapS);
return list;
});
Core::ICore::infoBar()->addInfo(entry);
};
install["install"] = wrap(install["install_cb"]);
return install;
});
}
} // namespace Lua::Internal

View File

@@ -5,6 +5,7 @@
#include "luapluginspec.h"
#include <coreplugin/icore.h>
#include <coreplugin/messagemanager.h>
#include <utils/algorithm.h>
@@ -13,6 +14,7 @@
#include <QJsonArray>
#include <QJsonObject>
#include <QStandardPaths>
using namespace Utils;
@@ -151,6 +153,16 @@ expected_str<void> LuaEngine::prepareSetup(
const QString searchPath = (pluginSpec.location() / "?.lua").toUserOutput();
lua["package"]["path"] = searchPath.toStdString();
const FilePath appDataPath = Core::ICore::userResourcePath() / "plugin-data" / "lua"
/ pluginSpec.location().fileName();
sol::environment env(lua, sol::create, lua.globals());
lua.new_usertype<ScriptPluginSpec>(
"PluginSpec", sol::no_constructor, "name", sol::readonly(&ScriptPluginSpec::name));
lua["PluginSpec"] = ScriptPluginSpec{pluginSpec.name(), appDataPath};
// TODO: only register what the plugin requested
for (const auto &[name, func] : d->m_providers.asKeyValueRange()) {
lua["package"]["preload"][name.toStdString()] = [func = func](const sol::this_state &s) {

View File

@@ -33,6 +33,12 @@ struct CoroutineState
bool isMainThread;
};
struct ScriptPluginSpec
{
QString name;
Utils::FilePath appDataPath;
};
class LUA_EXPORT LuaEngine final : public QObject
{
friend class Internal::LuaPlugin;

View File

@@ -32,6 +32,7 @@ void addLayoutModule();
void addQtModule();
void addCoreModule();
void addHookModule();
void addInstallModule();
class LuaJsExtension : public QObject
{
@@ -75,6 +76,7 @@ public:
addQtModule();
addCoreModule();
addHookModule();
addInstallModule();
Core::JsExpander::registerGlobalObject("Lua", [] { return new LuaJsExtension(); });
}

View File

@@ -0,0 +1,29 @@
---@meta Install
local Install = {}
---@class PackageInfo
---@field name string The name of the package
---@field version string The version of the package
---@field path FilePath The path to the package
local PackageInfo = {}
---@class InstallOptions
---@field name string The name of the package to install
---@field url string The url to fetch the package from
---@field version string The version of the package to install
local InstallOptions = {}
---Install something
---@param msg string The message to display to the user asking for permission to install
---@param options InstallOptions|[InstallOptions] The options to install
---@return boolean Result Whether the installation was successful
---@return string Error The error message if the installation failed.
function Install.install(msg, options) end
---Get the package info
---@param name any The name of the package
---@return PackageInfo
function Install.packageInfo(name) end
return Install