Chris@66: /* -*- c-basic-offset: 4 indent-tabs-mode: nil -*- vi:set ts=8 sts=4 sw=4: */ Chris@66: /* Chris@66: Copyright (c) 2020 Queen Mary, University of London Chris@66: Chris@66: Permission is hereby granted, free of charge, to any person Chris@66: obtaining a copy of this software and associated documentation Chris@66: files (the "Software"), to deal in the Software without Chris@66: restriction, including without limitation the rights to use, copy, Chris@66: modify, merge, publish, distribute, sublicense, and/or sell copies Chris@66: of the Software, and to permit persons to whom the Software is Chris@66: furnished to do so, subject to the following conditions: Chris@66: Chris@66: The above copyright notice and this permission notice shall be Chris@66: included in all copies or substantial portions of the Software. Chris@66: Chris@66: THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, Chris@66: EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF Chris@66: MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND Chris@66: NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY Chris@66: CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF Chris@66: CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION Chris@66: WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. Chris@66: Chris@66: Except as contained in this notice, the names of the Centre for Chris@66: Digital Music and Queen Mary, University of London shall not be Chris@66: used in advertising or otherwise to promote the sale, use or other Chris@66: dealings in this Software without prior written authorization. Chris@66: */ Chris@33: Chris@33: #include Chris@33: #include Chris@33: #include Chris@33: #include Chris@33: Chris@42: #include Chris@42: #include Chris@42: #include Chris@42: #include Chris@46: #include Chris@42: #include Chris@46: #include Chris@51: #include Chris@51: #include Chris@67: #include Chris@67: #include Chris@67: #include Chris@67: #include Chris@42: Chris@41: #include Chris@41: Chris@43: #include Chris@43: #include Chris@43: Chris@33: #include Chris@58: #include Chris@43: #include Chris@43: Chris@67: #include Chris@67: Chris@51: #include "base/Debug.h" Chris@51: Chris@33: using namespace std; Chris@43: using namespace Dataquay; Chris@32: Chris@42: QString Chris@42: getDefaultInstallDirectory() Chris@32: { Chris@41: auto pathList = Vamp::PluginHostAdapter::getPluginPath(); Chris@41: if (pathList.empty()) { Chris@51: SVCERR << "Failed to look up Vamp plugin path" << endl; Chris@42: return QString(); Chris@41: } Chris@41: Chris@42: auto firstPath = *pathList.begin(); Chris@42: QString target = QString::fromUtf8(firstPath.c_str(), firstPath.size()); Chris@42: return target; Chris@42: } Chris@42: Chris@42: QStringList Chris@42: getPluginLibraryList() Chris@42: { Chris@33: QDir dir(":out/"); Chris@33: auto entries = dir.entryList({ "*.so", "*.dll", "*.dylib" }); Chris@33: Chris@33: for (auto e: entries) { Chris@51: SVCERR << e.toStdString() << endl; Chris@33: } Chris@33: Chris@42: return entries; Chris@42: } Chris@33: Chris@50: void Chris@50: loadLibraryRdf(BasicStore &store, QString filename) Chris@50: { Chris@50: QFile f(filename); Chris@50: if (!f.open(QFile::ReadOnly | QFile::Text)) { Chris@51: SVCERR << "Failed to open RDF resource file " Chris@51: << filename.toStdString() << endl; Chris@50: return; Chris@50: } Chris@50: Chris@50: QByteArray content = f.readAll(); Chris@50: f.close(); Chris@50: Chris@50: try { Chris@50: store.importString(QString::fromUtf8(content), Chris@50: Uri("file:" + filename), Chris@50: BasicStore::ImportIgnoreDuplicates); Chris@50: } catch (const RDFException &ex) { Chris@51: SVCERR << "Failed to import RDF resource file " Chris@51: << filename.toStdString() << ": " << ex.what() << endl; Chris@50: } Chris@50: } Chris@50: Chris@43: unique_ptr Chris@43: loadLibrariesRdf() Chris@43: { Chris@43: unique_ptr store(new BasicStore); Chris@43: Chris@50: vector dirs { ":rdf/plugins", ":out" }; Chris@43: Chris@50: for (auto d: dirs) { Chris@50: for (auto e: QDir(d).entryList({ "*.ttl", "*.n3" })) { Chris@50: loadLibraryRdf(*store, d + "/" + e); Chris@43: } Chris@43: } Chris@43: Chris@43: return store; Chris@43: } Chris@43: Chris@43: struct LibraryInfo { Chris@43: QString id; Chris@43: QString fileName; Chris@43: QString title; Chris@43: QString maker; Chris@43: QString description; Chris@47: QStringList pluginTitles; Chris@67: map pluginVersions; // id -> version Chris@43: }; Chris@43: Chris@43: vector Chris@43: getLibraryInfo(const Store &store, QStringList libraries) Chris@43: { Chris@43: /* e.g. Chris@43: Chris@43: plugbase:library a vamp:PluginLibrary ; Chris@43: vamp:identifier "qm-vamp-plugins" ; Chris@43: dc:title "Queen Mary plugin set" Chris@43: */ Chris@43: Chris@43: Triples tt = store.match(Triple(Node(), Chris@43: Uri("a"), Chris@43: store.expand("vamp:PluginLibrary"))); Chris@43: Chris@67: map wanted; // basename -> full lib name Chris@43: for (auto lib: libraries) { Chris@43: wanted[QFileInfo(lib).baseName()] = lib; Chris@43: } Chris@43: Chris@43: vector results; Chris@43: Chris@43: for (auto t: tt) { Chris@43: Chris@43: Node libId = store.complete(Triple(t.subject(), Chris@43: store.expand("vamp:identifier"), Chris@43: Node())); Chris@43: if (libId.type != Node::Literal) { Chris@43: continue; Chris@43: } Chris@43: auto wi = wanted.find(libId.value); Chris@43: if (wi == wanted.end()) { Chris@43: continue; Chris@43: } Chris@43: Chris@50: Node title = store.complete(Triple(t.subject(), Chris@50: store.expand("dc:title"), Chris@50: Node())); Chris@50: if (title.type != Node::Literal) { Chris@50: continue; Chris@50: } Chris@50: Chris@43: LibraryInfo info; Chris@43: info.id = wi->first; Chris@43: info.fileName = wi->second; Chris@50: info.title = title.value; Chris@43: Chris@43: Node maker = store.complete(Triple(t.subject(), Chris@43: store.expand("foaf:maker"), Chris@43: Node())); Chris@43: if (maker.type == Node::Literal) { Chris@43: info.maker = maker.value; Chris@46: } else if (maker != Node()) { Chris@46: maker = store.complete(Triple(maker, Chris@46: store.expand("foaf:name"), Chris@46: Node())); Chris@46: if (maker.type == Node::Literal) { Chris@46: info.maker = maker.value; Chris@46: } Chris@43: } Chris@46: Chris@43: Node desc = store.complete(Triple(t.subject(), Chris@43: store.expand("dc:description"), Chris@43: Node())); Chris@43: if (desc.type == Node::Literal) { Chris@43: info.description = desc.value; Chris@43: } Chris@43: Chris@47: Triples pp = store.match(Triple(t.subject(), Chris@47: store.expand("vamp:available_plugin"), Chris@47: Node())); Chris@47: for (auto p: pp) { Chris@47: Node ptitle = store.complete(Triple(p.object(), Chris@47: store.expand("dc:title"), Chris@47: Node())); Chris@47: if (ptitle.type == Node::Literal) { Chris@47: info.pluginTitles.push_back(ptitle.value); Chris@47: } Chris@67: Chris@67: Node pident = store.complete(Triple(p.object(), Chris@67: store.expand("vamp:identifier"), Chris@67: Node())); Chris@67: Node pversion = store.complete(Triple(p.object(), Chris@67: store.expand("owl:versionInfo"), Chris@67: Node())); Chris@67: if (pident.type == Node::Literal && Chris@67: pversion.type == Node::Literal) { Chris@67: bool ok = false; Chris@67: int version = pversion.value.toInt(&ok); Chris@67: if (ok) { Chris@67: info.pluginVersions[pident.value] = version; Chris@67: } Chris@67: } Chris@47: } Chris@47: Chris@43: results.push_back(info); Chris@50: wanted.erase(libId.value); Chris@43: } Chris@43: Chris@50: for (auto wp: wanted) { Chris@51: SVCERR << "Failed to find any RDF information about library " Chris@51: << wp.second << endl; Chris@50: } Chris@50: Chris@43: return results; Chris@43: } Chris@43: Chris@67: struct TempFileDeleter { Chris@67: ~TempFileDeleter() { Chris@67: if (tempFile != "") { Chris@67: QFile(tempFile).remove(); Chris@67: } Chris@67: } Chris@67: QString tempFile; Chris@67: }; Chris@67: Chris@67: map Chris@67: getInstalledLibraryPluginVersions(QString libraryFilePath) Chris@67: { Chris@67: static QMutex mutex; Chris@67: static QString tempFileName; Chris@67: static TempFileDeleter deleter; Chris@67: static bool initHappened = false, initSucceeded = false; Chris@67: Chris@67: QMutexLocker locker (&mutex); Chris@67: Chris@67: if (!initHappened) { Chris@67: initHappened = true; Chris@67: Chris@67: QTemporaryFile tempFile; Chris@67: tempFile.setAutoRemove(false); Chris@67: if (!tempFile.open()) { Chris@67: SVCERR << "ERROR: Failed to open a temporary file" << endl; Chris@67: return {}; Chris@67: } Chris@67: Chris@67: // We can't make the QTemporaryFile static, as it will hold Chris@67: // the file open and that prevents us from executing it. Hence Chris@67: // the separate deleter. Chris@67: Chris@67: tempFileName = tempFile.fileName(); Chris@67: deleter.tempFile = tempFileName; Chris@67: Chris@67: #ifdef Q_OS_WIN32 Chris@67: QString helperPath = ":out/get-version.exe"; Chris@67: #else Chris@67: QString helperPath = ":out/get-version"; Chris@67: #endif Chris@67: QFile helper(helperPath); Chris@67: if (!helper.open(QFile::ReadOnly)) { Chris@67: SVCERR << "ERROR: Failed to read helper code" << endl; Chris@67: return {}; Chris@67: } Chris@67: QByteArray content = helper.readAll(); Chris@67: helper.close(); Chris@67: Chris@67: if (tempFile.write(content) != content.size()) { Chris@67: SVCERR << "ERROR: Incomplete write to temporary file" << endl; Chris@67: return {}; Chris@67: } Chris@67: tempFile.close(); Chris@67: Chris@67: if (!QFile::setPermissions Chris@67: (tempFileName, Chris@67: QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner)) { Chris@67: SVCERR << "ERROR: Failed to set execute permission on helper " Chris@67: << tempFileName << endl; Chris@67: return {}; Chris@67: } Chris@67: Chris@67: initSucceeded = true; Chris@67: } Chris@67: Chris@67: if (!initSucceeded) { Chris@67: return {}; Chris@67: } Chris@67: Chris@67: QProcess process; Chris@67: process.start(tempFileName, { libraryFilePath }); Chris@67: Chris@67: if (!process.waitForStarted()) { Chris@67: QProcess::ProcessError err = process.error(); Chris@67: if (err == QProcess::FailedToStart) { Chris@67: SVCERR << "Unable to start helper process " << tempFileName << endl; Chris@67: } else if (err == QProcess::Crashed) { Chris@67: SVCERR << "Helper process " << tempFileName Chris@67: << " crashed on startup" << endl; Chris@67: } else { Chris@67: SVCERR << "Helper process " << tempFileName Chris@67: << " failed on startup with error code " << err << endl; Chris@67: } Chris@67: return {}; Chris@67: } Chris@67: process.waitForFinished(); Chris@67: Chris@67: QByteArray stdOut = process.readAllStandardOutput(); Chris@67: QByteArray stdErr = process.readAllStandardError(); Chris@67: Chris@67: QString errStr = QString::fromUtf8(stdErr); Chris@67: if (!errStr.isEmpty()) { Chris@67: SVCERR << "Note: Helper process stderr follows:" << endl; Chris@67: SVCERR << errStr << endl; Chris@67: SVCERR << "Note: Helper process stderr ends" << endl; Chris@67: } Chris@67: Chris@67: QStringList lines = QString::fromUtf8(stdOut).split Chris@67: (QRegExp("[\\r\\n]+"), QString::SkipEmptyParts); Chris@67: map versions; Chris@67: for (QString line: lines) { Chris@67: QStringList parts = line.split(":"); Chris@67: if (parts.size() != 2) { Chris@67: SVCERR << "Unparseable output line: " << line << endl; Chris@67: continue; Chris@67: } Chris@67: bool ok = false; Chris@67: int version = parts[1].toInt(&ok); Chris@67: if (!ok) { Chris@67: SVCERR << "Unparseable version number in line: " << line << endl; Chris@67: continue; Chris@67: } Chris@67: versions[parts[0]] = version; Chris@67: } Chris@67: Chris@67: return versions; Chris@67: } Chris@67: Chris@67: bool isLibraryNewer(map a, map b) Chris@67: { Chris@67: // a and b are maps from plugin id to plugin version for libraries Chris@67: // A and B. (There is no overarching library version number.) We Chris@67: // deem library A to be newer than library B if: Chris@67: // Chris@67: // 1. A contains a plugin id that is also in B, whose version in Chris@67: // A is newer than that in B, or Chris@67: // Chris@67: // 2. B is not newer than A according to rule 1, and neither A or Chris@67: // B is empty, and A contains a plugin id that is not in B, and B Chris@67: // does not contain any plugin id that is not in A Chris@67: // Chris@67: // (The not-empty part of rule 2 is just to avoid false positives Chris@67: // when a library or its metadata could not be read at all.) Chris@67: Chris@67: auto containsANewerPlugin = [](const map &m1, Chris@67: const map &m2) { Chris@67: for (auto p: m1) { Chris@67: if (m2.find(p.first) != m2.end() && Chris@67: p.second > m2.at(p.first)) { Chris@67: return true; Chris@67: } Chris@67: } Chris@67: return false; Chris@67: }; Chris@67: Chris@67: auto containsANovelPlugin = [](const map &m1, Chris@67: const map &m2) { Chris@67: for (auto p: m1) { Chris@67: if (m2.find(p.first) == m2.end()) { Chris@67: return true; Chris@67: } Chris@67: } Chris@67: return false; Chris@67: }; Chris@67: Chris@67: if (containsANewerPlugin(a, b)) { Chris@67: return true; Chris@67: } Chris@67: Chris@67: if (!containsANewerPlugin(b, a) && Chris@67: !a.empty() && Chris@67: !b.empty() && Chris@67: containsANovelPlugin(a, b) && Chris@67: !containsANovelPlugin(b, a)) { Chris@67: return true; Chris@67: } Chris@67: Chris@67: return false; Chris@67: } Chris@67: Chris@67: QString Chris@67: versionsString(const map &vv) Chris@67: { Chris@67: QStringList pv; Chris@67: for (auto v: vv) { Chris@67: pv.push_back(QString("%1:%2").arg(v.first).arg(v.second)); Chris@67: } Chris@67: return "{ " + pv.join(", ") + " }"; Chris@67: } Chris@67: Chris@42: void Chris@67: installLibrary(QString library, LibraryInfo info, QString target) Chris@42: { Chris@52: QString source = ":out"; Chris@52: QFile f(source + "/" + library); Chris@42: QString destination = target + "/" + library; Chris@67: Chris@67: if (QFileInfo(destination).exists()) { Chris@67: auto installed = getInstalledLibraryPluginVersions(destination); Chris@67: SVCERR << "Note: comparing installed plugin versions " Chris@67: << versionsString(installed) Chris@67: << " to packaged versions " Chris@67: << versionsString(info.pluginVersions) Chris@67: << ": isLibraryNewer(installed, packaged) returns " Chris@67: << isLibraryNewer(installed, info.pluginVersions) Chris@67: << endl; Chris@67: } Chris@52: Chris@51: SVCERR << "Copying " << library.toStdString() << " to " Chris@51: << destination.toStdString() << "..." << endl; Chris@42: if (!f.copy(destination)) { Chris@51: SVCERR << "Failed to copy " << library.toStdString() Chris@51: << " to target " << destination.toStdString() << endl; Chris@42: return; Chris@42: } Chris@42: if (!QFile::setPermissions Chris@42: (destination, Chris@42: QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner | Chris@42: QFile::ReadGroup | QFile::ExeGroup | Chris@42: QFile::ReadOther | QFile::ExeOther)) { Chris@51: SVCERR << "Failed to set permissions on " Chris@51: << library.toStdString() << endl; Chris@42: return; Chris@42: } Chris@52: Chris@52: QString base = QFileInfo(library).baseName(); Chris@52: QDir dir(source); Chris@52: auto entries = dir.entryList({ base + "*" }); Chris@52: for (auto e: entries) { Chris@52: if (e == library) continue; Chris@52: QString destination = target + "/" + e; Chris@52: SVCERR << "Copying " << e.toStdString() << " to " Chris@52: << destination.toStdString() << "..." << endl; Chris@52: if (!QFile(source + "/" + e).copy(destination)) { Chris@52: SVCERR << "Failed to copy " << e.toStdString() Chris@52: << " to target " << destination.toStdString() Chris@52: << " (ignoring)" << endl; Chris@52: } Chris@52: } Chris@42: } Chris@42: Chris@67: map Chris@43: getUserApprovedPluginLibraries(vector libraries) Chris@42: { Chris@42: QDialog dialog; Chris@46: Chris@46: auto mainLayout = new QGridLayout; Chris@47: mainLayout->setSpacing(0); Chris@46: dialog.setLayout(mainLayout); Chris@46: Chris@46: int mainRow = 0; Chris@46: Chris@47: auto checkAll = new QCheckBox; Chris@53: checkAll->setChecked(true); Chris@47: mainLayout->addWidget(checkAll, mainRow, 0, Qt::AlignHCenter); Chris@47: ++mainRow; Chris@47: Chris@47: auto checkArrow = new QLabel("▼"); Chris@47: checkArrow->setTextFormat(Qt::RichText); Chris@47: mainLayout->addWidget(checkArrow, mainRow, 0, Qt::AlignHCenter); Chris@47: ++mainRow; Chris@47: Chris@46: auto scroll = new QScrollArea; Chris@47: scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); Chris@46: mainLayout->addWidget(scroll, mainRow, 0, 1, 2); Chris@46: mainLayout->setRowStretch(mainRow, 10); Chris@46: ++mainRow; Chris@46: Chris@46: auto selectionFrame = new QWidget; Chris@46: Chris@46: auto selectionLayout = new QGridLayout; Chris@46: selectionFrame->setLayout(selectionLayout); Chris@46: int selectionRow = 0; Chris@42: Chris@67: map checkBoxMap; // filename -> checkbox Chris@67: map libFileInfo; // filename -> info Chris@43: Chris@67: map> Chris@49: orderedInfo Chris@49: ([](QString k1, QString k2) { Chris@49: return k1.localeAwareCompare(k2) < 0; Chris@49: }); Chris@43: for (auto info: libraries) { Chris@43: orderedInfo[info.title] = info; Chris@43: } Chris@53: Chris@43: for (auto ip: orderedInfo) { Chris@46: Chris@46: auto cb = new QCheckBox; Chris@53: cb->setChecked(true); Chris@53: Chris@47: selectionLayout->addWidget(cb, selectionRow, 0, Chris@47: Qt::AlignTop | Qt::AlignHCenter); Chris@46: Chris@43: LibraryInfo info = ip.second; Chris@47: /* Chris@47: int n = info.pluginTitles.size(); Chris@47: QString contents; Chris@47: Chris@47: if (n > 0) { Chris@47: int max = 4; Chris@47: QStringList titles; Chris@47: for (int i = 0; i < max && i < int(info.pluginTitles.size()); ++i) { Chris@47: titles.push_back(info.pluginTitles[i]); Chris@47: } Chris@47: QString titleText = titles.join(", "); Chris@47: if (max < int(info.pluginTitles.size())) { Chris@47: titleText = QObject::tr("%1 ...").arg(titleText); Chris@47: } Chris@47: contents = QObject::tr("Plugins: %1").arg(titleText); Chris@47: } Chris@47: */ Chris@51: QString text = QObject::tr("%1
%2
%3") Chris@47: .arg(info.title) Chris@47: .arg(info.maker) Chris@47: .arg(info.description); Chris@46: Chris@46: auto label = new QLabel(text); Chris@47: label->setWordWrap(true); Chris@47: label->setMinimumWidth(800); Chris@46: Chris@46: selectionLayout->addWidget(label, selectionRow, 1, Qt::AlignTop); Chris@46: Chris@46: ++selectionRow; Chris@46: Chris@43: checkBoxMap[info.fileName] = cb; Chris@67: libFileInfo[info.fileName] = info; Chris@42: } Chris@42: Chris@46: scroll->setWidget(selectionFrame); Chris@46: Chris@47: QObject::connect(checkAll, &QCheckBox::toggled, Chris@47: [=]() { Chris@47: bool toCheck = checkAll->isChecked(); Chris@47: for (auto p: checkBoxMap) { Chris@47: p.second->setChecked(toCheck); Chris@47: } Chris@47: }); Chris@47: Chris@42: auto bb = new QDialogButtonBox(QDialogButtonBox::Ok | Chris@42: QDialogButtonBox::Cancel); Chris@46: mainLayout->addWidget(bb, mainRow, 0, 1, 2); Chris@46: ++mainRow; Chris@47: Chris@47: int cw = 50; Chris@47: mainLayout->setColumnMinimumWidth(0, cw + 20); //!!! Chris@47: mainLayout->setColumnStretch(1, 10); Chris@47: selectionLayout->setColumnMinimumWidth(0, cw); //!!! Chris@53: selectionLayout->setColumnMinimumWidth(1, 820); //!!! Chris@53: selectionLayout->setColumnStretch(1, 10); Chris@47: Chris@42: QObject::connect(bb, SIGNAL(accepted()), &dialog, SLOT(accept())); Chris@42: QObject::connect(bb, SIGNAL(rejected()), &dialog, SLOT(reject())); Chris@53: Chris@42: if (dialog.exec() == QDialog::Accepted) { Chris@51: SVCERR << "accepted" << endl; Chris@42: } else { Chris@51: SVCERR << "rejected" << endl; Chris@42: } Chris@42: Chris@67: map approved; Chris@42: for (const auto &p: checkBoxMap) { Chris@42: if (p.second->isChecked()) { Chris@67: approved[p.first] = libFileInfo[p.first]; Chris@33: } Chris@42: } Chris@42: Chris@42: return approved; Chris@42: } Chris@42: Chris@42: int main(int argc, char **argv) Chris@42: { Chris@42: QApplication app(argc, argv); Chris@42: Chris@51: QApplication::setOrganizationName("sonic-visualiser"); Chris@51: QApplication::setOrganizationDomain("sonicvisualiser.org"); Chris@51: QApplication::setApplicationName(QApplication::tr("Vamp Plugin Pack Installer")); Chris@51: Chris@51: #ifdef Q_OS_WIN32 Chris@51: QFont font(QApplication::font()); Chris@51: QString preferredFamily = "Segoe UI"; Chris@51: font.setFamily(preferredFamily); Chris@51: if (QFontInfo(font).family() == preferredFamily) { Chris@51: font.setPointSize(10); Chris@51: QApplication::setFont(font); Chris@51: } Chris@51: #endif Chris@51: Chris@42: QString target = getDefaultInstallDirectory(); Chris@42: if (target == "") { Chris@42: return 1; Chris@42: } Chris@42: Chris@42: QStringList libraries = getPluginLibraryList(); Chris@42: Chris@43: auto rdfStore = loadLibrariesRdf(); Chris@43: Chris@43: auto info = getLibraryInfo(*rdfStore, libraries); Chris@43: Chris@67: map toInstall = getUserApprovedPluginLibraries(info); Chris@52: Chris@52: if (!toInstall.empty()) { Chris@52: if (!QDir(target).exists()) { Chris@52: QDir().mkpath(target); Chris@52: } Chris@52: } Chris@42: Chris@42: for (auto lib: toInstall) { Chris@67: installLibrary(lib.first, lib.second, target); Chris@33: } Chris@33: Chris@32: return 0; Chris@32: }