diff options
| author | Mason Reed <mason@vector35.com> | 2025-08-26 23:59:38 -0400 |
|---|---|---|
| committer | Mason Reed <mason@vector35.com> | 2025-10-01 21:38:39 -0400 |
| commit | ede39aee7e00c40a43b67ca18dd8ab80ee863d85 (patch) | |
| tree | 67c5eda347ece2282e3c888f38066b496e06dec8 /plugins/warp/ui/shared | |
| parent | a1c46813e7f279aa4cfdb9dbb91c45b559ebeacd (diff) | |
[WARP] Enhanced network support
Diffstat (limited to 'plugins/warp/ui/shared')
| -rw-r--r-- | plugins/warp/ui/shared/fetchdialog.cpp | 227 | ||||
| -rw-r--r-- | plugins/warp/ui/shared/fetchdialog.h | 56 | ||||
| -rw-r--r-- | plugins/warp/ui/shared/fetcher.cpp | 109 | ||||
| -rw-r--r-- | plugins/warp/ui/shared/fetcher.h | 85 | ||||
| -rw-r--r-- | plugins/warp/ui/shared/function.cpp | 26 | ||||
| -rw-r--r-- | plugins/warp/ui/shared/misc.cpp | 63 | ||||
| -rw-r--r-- | plugins/warp/ui/shared/misc.h | 32 | ||||
| -rw-r--r-- | plugins/warp/ui/shared/search.cpp | 301 | ||||
| -rw-r--r-- | plugins/warp/ui/shared/search.h | 234 |
9 files changed, 1107 insertions, 26 deletions
diff --git a/plugins/warp/ui/shared/fetchdialog.cpp b/plugins/warp/ui/shared/fetchdialog.cpp new file mode 100644 index 00000000..0230ad51 --- /dev/null +++ b/plugins/warp/ui/shared/fetchdialog.cpp @@ -0,0 +1,227 @@ +#include "fetchdialog.h" + +#include <QDialogButtonBox> +#include <QFormLayout> +#include <QInputDialog> +#include <QLabel> + +#include "action.h" +#include "fetcher.h" + +using namespace BinaryNinja; + +static void AddListItem(QListWidget *list, const QString &value) +{ + if (value.trimmed().isEmpty()) + return; + // Avoid duplicates + for (int i = 0; i < list->count(); ++i) + if (list->item(i)->text().compare(value, Qt::CaseInsensitive) == 0) + return; + list->addItem(value.trimmed()); +} + +WarpFetchDialog::WarpFetchDialog(BinaryViewRef bv, + std::shared_ptr<WarpFetcher> fetcher, + QWidget *parent) + : QDialog(parent), m_fetchProcessor(std::move(fetcher)), m_bv(std::move(bv)) +{ + setWindowTitle("Fetch WARP Functions"); + + auto form = new QFormLayout(); + m_containerCombo = new QComboBox(this); + populateContainers(); + m_containerCombo->addItem("All Containers"); // index 0 for "all" + for (const auto &c: m_containers) + m_containerCombo->addItem(QString::fromStdString(c->GetName())); + + // TODO: Need to add tooltip to explain that a source must have atleast one of these tags to be considered. + + // Tags editor + m_tagsList = new QListWidget(this); + m_addTagBtn = new QPushButton("Add", this); + m_removeTagBtn = new QPushButton("Remove", this); + auto tagBtnRow = new QHBoxLayout(); + tagBtnRow->addWidget(m_addTagBtn); + tagBtnRow->addWidget(m_removeTagBtn); + auto tagCol = new QVBoxLayout(); + tagCol->addWidget(m_tagsList); + tagCol->addLayout(tagBtnRow); + auto tagWrapper = new QWidget(this); + tagWrapper->setLayout(tagCol); + + // Make tags list compact with a fixed maximum height and no vertical expansion + m_tagsList->setSizeAdjustPolicy(QAbstractScrollArea::AdjustToContents); + m_tagsList->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + m_tagsList->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + m_tagsList->setMaximumHeight(120); + m_tagsList->setToolTip("A source must have atleast ONE of these tags to be considered"); + + // Defaults from processor tags + for (const auto &t: m_fetchProcessor->GetTags()) + AddListItem(m_tagsList, QString::fromStdString(t)); + + // Batch size and matcher checkbox + m_batchSize = new QSpinBox(this); + m_batchSize->setRange(10, 1000); + m_batchSize->setValue(100); + m_batchSize->setToolTip("Number of functions to fetch in each batch"); + + m_rerunMatcher = new QCheckBox("Re-run matcher after fetch", this); + m_rerunMatcher->setChecked(true); + + m_clearProcessed = new QCheckBox("Refetch all functions", this); + m_clearProcessed->setToolTip("Clears the processed cache before fetching again, this will refetch all functions in the view"); + m_clearProcessed->setChecked(false); + + form->addRow(new QLabel("Container: "), m_containerCombo); + // TODO: Need to plumb this through to the fetcher, and also likely have a blacklisted or whitelist mode for this dialog. + // TODO: Alos wan to prefill the list of sources from the view/global settings. + // form->addRow(new QLabel("Allowed Sources: "), srcWrapper); + form->addRow(new QLabel("Allowed Tags: "), tagWrapper); + form->addRow(new QLabel("Batch Size: "), m_batchSize); + form->addRow(m_rerunMatcher); + form->addRow(m_clearProcessed); + + auto buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + connect(buttons, &QDialogButtonBox::accepted, this, &WarpFetchDialog::onAccept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + + auto root = new QVBoxLayout(this); + root->addLayout(form); + root->addWidget(buttons); + setLayout(root); + + // Wire buttons + connect(m_addTagBtn, &QPushButton::clicked, this, &WarpFetchDialog::onAddTag); + connect(m_removeTagBtn, &QPushButton::clicked, this, &WarpFetchDialog::onRemoveTag); +} + +void WarpFetchDialog::populateContainers() +{ + m_containers = Warp::Container::All(); +} + +void WarpFetchDialog::onAddTag() +{ + bool ok = false; + const auto text = QInputDialog::getText(this, "Add Tag", "Tag:", QLineEdit::Normal, {}, &ok); + if (ok) + AddListItem(m_tagsList, text); +} + +void WarpFetchDialog::onRemoveTag() +{ + for (auto *item: m_tagsList->selectedItems()) + delete item; +} + +std::vector<Warp::SourceTag> WarpFetchDialog::collectTags() const +{ + std::vector<Warp::SourceTag> out; + out.reserve(m_tagsList->count()); + for (int i = 0; i < m_tagsList->count(); ++i) + out.emplace_back(m_tagsList->item(i)->text().trimmed().toStdString()); + return out; +} + +void WarpFetchDialog::onAccept() +{ + const int idx = m_containerCombo->currentIndex(); + std::optional<size_t> containerIndex; + if (idx > 0) // 0 == All Containers + containerIndex = static_cast<size_t>(idx - 1); + + auto tags = collectTags(); + const auto batch = static_cast<size_t>(m_batchSize->value()); + const bool rerun = m_rerunMatcher->isChecked(); + + // Persist tags to the shared processor for consistency across navigation + m_fetchProcessor->SetTags(tags); + + if (m_clearProcessed->isChecked()) + m_fetchProcessor->ClearProcessed(); + + // Execute the network fetch in batches + runBatchedFetch(containerIndex, tags, batch, rerun); + + accept(); +} + +void WarpFetchDialog::runBatchedFetch(const std::optional<size_t> &containerIndex, + const std::vector<Warp::SourceTag> &tags, + size_t batchSize, + bool rerunMatcher) +{ + if (!m_bv) + return; + // Collect functions in the view and enqueue them to the shared fetcher + std::vector<Ref<Function>> funcs = m_bv->GetAnalysisFunctionList(); + if (funcs.empty()) + return; + const size_t totalFuncs = funcs.size(); + const size_t totalBatches = (totalFuncs + batchSize - 1) / batchSize; + + // Create a background task to show progress in the UI + Ref<BackgroundTask> task = new BackgroundTask("Fetching WARP functions (0 / " + std::to_string(totalBatches) + ")", false); + + auto fetcher = m_fetchProcessor; + auto bv = m_bv; + + // TODO: Too many captures in this thing lol. + WorkerInteractiveEnqueue([fetcher, bv, funcs = std::move(funcs), batchSize, rerunMatcher, task]() mutable { + size_t processed = 0; + size_t batchIndex = 0; + + while (processed < funcs.size()) + { + const size_t remaining = funcs.size() - processed; + const size_t thisBatchCount = std::min(batchSize, remaining); + + for (size_t i = 0; i < thisBatchCount; ++i) + fetcher->AddPendingFunction(funcs[processed + i]); + + fetcher->FetchPendingFunctions(); + + ++batchIndex; + processed += thisBatchCount; + + task->SetProgressText("Fetching WARP functions (" + std::to_string(batchIndex) + " / " + std::to_string((funcs.size() + batchSize - 1) / batchSize) + ")"); + } + + task->Finish(); + // TODO: Print how long it took? + Logger("WARP Fetcher").LogInfo("Finished fetching WARP functions..."); + + if (rerunMatcher && bv) + Warp::RunMatcher(*bv); + }); +} + +void RegisterWarpFetchFunctionsCommand() +{ + // Register a UI action and bind it globally. Add it to the Tools menu. + const QString actionName = "WARP\\Fetch"; + + // TODO: Because we register this in every widget this will happen, this is bad behavior! + if (!UIAction::isActionRegistered(actionName)) + UIAction::registerAction(actionName); + + UIActionHandler::globalActions()->bindAction( + actionName, + UIAction( + [](const UIActionContext &context) { + if (const BinaryViewRef bv = context.binaryView; bv) + { + WarpFetchDialog dlg(bv, WarpFetcher::Global(), nullptr); + dlg.exec(); + } + }, + [](const UIActionContext &context) { + return context.binaryView != nullptr; + } + ) + ); + + Menu::mainMenu("Plugins")->addAction(actionName, "Plugins"); +} diff --git a/plugins/warp/ui/shared/fetchdialog.h b/plugins/warp/ui/shared/fetchdialog.h new file mode 100644 index 00000000..72591a4c --- /dev/null +++ b/plugins/warp/ui/shared/fetchdialog.h @@ -0,0 +1,56 @@ +#pragma once + +#include <QDialog> +#include <QComboBox> +#include <QListWidget> +#include <QSpinBox> +#include <QCheckBox> + +#include "uicontext.h" +#include "viewframe.h" +#include "warp.h" +#include "fetcher.h" + +class WarpFetchDialog : public QDialog +{ + Q_OBJECT + + QComboBox *m_containerCombo; + + QListWidget *m_tagsList; + QPushButton *m_addTagBtn; + QPushButton *m_removeTagBtn; + + QSpinBox *m_batchSize; + QCheckBox *m_rerunMatcher; + QCheckBox *m_clearProcessed; + + std::vector<Warp::Ref<Warp::Container> > m_containers; + + std::shared_ptr<WarpFetcher> m_fetchProcessor; + BinaryViewRef m_bv; + +public: + explicit WarpFetchDialog(BinaryViewRef bv, + std::shared_ptr<WarpFetcher> fetchProcessor, + QWidget *parent = nullptr); + +private slots: + void onAddTag(); + + void onRemoveTag(); + + void onAccept(); + +private: + void populateContainers(); + + std::vector<Warp::SourceTag> collectTags() const; + + void runBatchedFetch(const std::optional<size_t> &containerIndex, + const std::vector<Warp::SourceTag> &tags, + size_t batchSize, + bool rerunMatcher); +}; + +void RegisterWarpFetchFunctionsCommand(); diff --git a/plugins/warp/ui/shared/fetcher.cpp b/plugins/warp/ui/shared/fetcher.cpp new file mode 100644 index 00000000..767932e3 --- /dev/null +++ b/plugins/warp/ui/shared/fetcher.cpp @@ -0,0 +1,109 @@ +#include "fetcher.h" + +#include <QSettings> + +WarpFetcher::WarpFetcher() +{ + m_logger = new BinaryNinja::Logger("WARP Fetcher"); + QSettings qtSettings; + const QString key = "warp/allowedTags"; + + QStringList tags = qtSettings.value(key).toStringList(); + if (tags.isEmpty()) { + tags = QStringList{ "official", "trusted" }; + qtSettings.setValue(key, tags); + qtSettings.sync(); + } + + std::vector<Warp::SourceTag> initialTags; + initialTags.reserve(tags.size()); + for (const auto& t : tags) + initialTags.emplace_back(t.trimmed().toStdString()); + + SetTags(initialTags); +} + +void WarpFetcher::AddPendingFunction(const FunctionRef &func) +{ + std::lock_guard<std::mutex> lock(m_requestMutex); + const auto guid = Warp::GetAnalysisFunctionGUID(*func); + if (!guid.has_value() || m_processedGuids.contains(*guid)) + return; + m_pendingRequests.push_back(func); +} + +std::vector<FunctionRef> WarpFetcher::FlushPendingFunctions() +{ + std::lock_guard<std::mutex> lock(m_requestMutex); + std::vector<FunctionRef> requests = std::move(m_pendingRequests); + m_pendingRequests.clear(); + return requests; +} + +void WarpFetcher::ExecuteCompletionCallback() +{ + BinaryNinja::ExecuteOnMainThread([this]() { + // TODO: Holding the mutex here is dangerous! + std::lock_guard<std::mutex> lock(m_requestMutex); + m_completionCallbacks.erase( + std::ranges::remove_if(m_completionCallbacks, + [](const auto &cb) { return cb() != RemoveCallback; }).begin(), + m_completionCallbacks.end()); + }); +} + +std::shared_ptr<WarpFetcher> WarpFetcher::Global() +{ + static auto global = std::make_shared<WarpFetcher>(); + return global; +} + +void WarpFetcher::FetchPendingFunctions() +{ + m_requestInProgress = true; + const auto requests = FlushPendingFunctions(); + if (requests.empty()) + { + m_logger->LogDebug("No pending requests to fetch... skipping"); + m_requestInProgress = false; + return; + } + + const auto start_time = std::chrono::high_resolution_clock::now(); + + // Because we must fetch for a single target we map the function guids to the associated platform to perform fetches for each. + std::map<PlatformRef, std::vector<Warp::FunctionGUID>> platformMappedGuids; + for (const auto &func: requests) + { + const auto guid = Warp::GetAnalysisFunctionGUID(*func); + if (!guid.has_value()) + continue; + auto platform = func->GetPlatform(); + platformMappedGuids[platform].push_back(guid.value()); + } + + const auto tags = GetTags(); + for (const auto &[platform, guids] : platformMappedGuids) + { + m_logger->LogDebugF("Fetching {} functions for platform {}", guids.size(), platform->GetName()); + auto target = Warp::Target::FromPlatform(*platform); + for (const auto &container: Warp::Container::All()) + container->FetchFunctions(*target, guids, tags); + + std::lock_guard<std::mutex> lock(m_requestMutex); + for (const auto &guid: guids) + m_processedGuids.insert(guid); + } + + m_requestInProgress = false; + ExecuteCompletionCallback(); + const auto end_time = std::chrono::high_resolution_clock::now(); + const std::chrono::duration<double> elapsed_time = end_time - start_time; + m_logger->LogDebug("Fetch batch took %f seconds", elapsed_time.count()); +} + +void WarpFetcher::ClearProcessed() +{ + m_logger->LogInfoF("Clearing {} processed functions from cache...", m_processedGuids.size()); + m_processedGuids.clear(); +} diff --git a/plugins/warp/ui/shared/fetcher.h b/plugins/warp/ui/shared/fetcher.h new file mode 100644 index 00000000..a7195ac4 --- /dev/null +++ b/plugins/warp/ui/shared/fetcher.h @@ -0,0 +1,85 @@ +#pragma once + +#include <atomic> +#include <mutex> +#include <unordered_set> +#include <vector> +#include <functional> +#include <QSettings> + +#include "warp.h" +#include "binaryninjaapi.h" +#include "uitypes.h" + +enum WarpFetchCompletionStatus +{ + KeepCallback, + RemoveCallback, +}; + +// Responsible for fetching data from the containers, to later be queried from the container interface. +class WarpFetcher +{ + LoggerRef m_logger; + + std::mutex m_requestMutex; + std::vector<FunctionRef> m_pendingRequests; + // TODO: Easy way to clear this if user wants to refetch. + std::unordered_set<Warp::FunctionGUID> m_processedGuids; + + // List of callbacks to call when done fetching data, assume that others are using this as well. + std::vector<std::function<WarpFetchCompletionStatus()> > m_completionCallbacks; + +public: + explicit WarpFetcher(); + + // The global fetcher instance, this is used for the fetch dialog and the sidebar. + static std::shared_ptr<WarpFetcher> Global(); + + std::atomic<bool> m_requestInProgress = false; + + // Set the allowed source tags, sources with none of these tags will not be fetched from. + void SetTags(const std::vector<Warp::SourceTag> &tags) + { + std::lock_guard<std::mutex> lock(m_requestMutex); + // TODO: This is kinda a hack, the fetcher instance should not sync through qt settings! + QStringList qtTags = {}; + for (const auto& t : tags) + qtTags.append(QString::fromStdString(t)); + QSettings().setValue("warp/allowedTags", qtTags); + } + + std::vector<Warp::SourceTag> GetTags() const + { + std::lock_guard<std::mutex> lock(const_cast<std::mutex &>(m_requestMutex)); + // TODO: This is kinda a hack, the fetcher instance should not sync through qt settings! + QSettings qtSettings; + QStringList tags = qtSettings.value("warp/allowedTags").toStringList(); + if (tags.isEmpty()) { + // The default tags to allow. + tags = QStringList{ "official", "trusted" }; + qtSettings.setValue("warp/allowedTags", tags); + qtSettings.sync(); + } + std::vector<Warp::SourceTag> initialTags = {}; + for (const auto& t : tags) + initialTags.emplace_back(t.trimmed().toStdString()); + return initialTags; + } + + void AddCompletionCallback(std::function<WarpFetchCompletionStatus()> cb) + { + std::lock_guard<std::mutex> lock(m_requestMutex); + m_completionCallbacks.push_back(std::move(cb)); + } + + void AddPendingFunction(const FunctionRef &func); + + void FetchPendingFunctions(); + + void ClearProcessed(); +private: + std::vector<FunctionRef> FlushPendingFunctions(); + + void ExecuteCompletionCallback(); +}; diff --git a/plugins/warp/ui/shared/function.cpp b/plugins/warp/ui/shared/function.cpp index 88d27cd1..e1609e0b 100644 --- a/plugins/warp/ui/shared/function.cpp +++ b/plugins/warp/ui/shared/function.cpp @@ -14,35 +14,15 @@ WarpFunctionItem::WarpFunctionItem(Warp::Ref<Warp::Function> function, { m_function = function; - // TODO: This needs to be better. Symbol can be nullptr. BinaryNinja::Ref<BinaryNinja::Symbol> symbol = m_function->GetSymbol(*analysisFunction); std::string symbolName = symbol->GetShortName(); setText(QString::fromStdString(symbolName)); - BinaryNinja::InstructionTextToken nameToken = {255, TextToken, symbolName}; // Serialize the tokens to make it accessible via QModelIndex. // We will take these tokens and then user them in our custom item delegate. - TokenData tokenData = {}; - - // TODO: Make this not look like garbage - BinaryNinja::Ref<BinaryNinja::Type> type = m_function->GetType(*analysisFunction); - if (type) - { - BinaryNinja::Ref<BinaryNinja::Platform> platform = analysisFunction->GetPlatform(); - std::vector<BinaryNinja::InstructionTextToken> beforeTokens = type->GetTokensBeforeName(platform); - std::vector<BinaryNinja::InstructionTextToken> afterTokens = type->GetTokensAfterName(platform); - - for (const auto &token: beforeTokens) - tokenData.tokens.emplace_back(token); - tokenData.tokens.emplace_back(255, TextToken, " "); - tokenData.tokens.emplace_back(nameToken); - for (const auto &token: afterTokens) - tokenData.tokens.emplace_back(token); - } else - { - tokenData.tokens.emplace_back(nameToken); - } - + TokenData tokenData = TokenData(symbolName); + if (BinaryNinja::Ref<BinaryNinja::Type> type = m_function->GetType(*analysisFunction)) + tokenData = TokenData(*type, symbolName); setData(QVariant::fromValue(tokenData), Qt::UserRole); } diff --git a/plugins/warp/ui/shared/misc.cpp b/plugins/warp/ui/shared/misc.cpp index 71147079..bbe3ab24 100644 --- a/plugins/warp/ui/shared/misc.cpp +++ b/plugins/warp/ui/shared/misc.cpp @@ -8,6 +8,21 @@ #include "render.h" #include "theme.h" +TokenData::TokenData(const std::string &name) +{ + tokens.emplace_back(255, TextToken, name); +} + +TokenData::TokenData(const BinaryNinja::Type &type, const std::string& name) +{ + for (const auto &token: type.GetTokensBeforeName()) + tokens.emplace_back(token); + tokens.emplace_back(255, TextToken, " "); + tokens.emplace_back(255, TextToken, name); + for (const auto &token: type.GetTokensAfterName()) + tokens.emplace_back(token); +} + void TokenDataDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { painter->save(); @@ -23,7 +38,7 @@ void TokenDataDelegate::paint(QPainter *painter, const QStyleOptionViewItem &opt painter->translate(option.rect.topLeft()); - auto renderContext = RenderContext((QWidget *) option.widget); + auto renderContext = RenderContext(const_cast<QWidget *>(option.widget)); renderContext.init(*painter); HighlightTokenState highlightState; renderContext.drawDisassemblyLine(*painter, 5, 5, {tokenData.tokens.begin(), tokenData.tokens.end()}, @@ -35,7 +50,7 @@ void TokenDataDelegate::paint(QPainter *painter, const QStyleOptionViewItem &opt QSize TokenDataDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const { auto tokenData = index.data(Qt::UserRole).value<TokenData>(); - auto renderContext = RenderContext((QWidget *) option.widget); + auto renderContext = RenderContext(const_cast<QWidget *>(option.widget)); QFontMetrics fontMetrics = QFontMetrics(renderContext.getFont()); QString line = ""; for (const auto &token: tokenData.tokens) @@ -79,3 +94,47 @@ bool GenericTextFilterModel::lessThan(const QModelIndex &sourceLeft, const QMode auto rightData = sourceRight.data().toString(); return QString::localeAwareCompare(leftData, rightData) < 0; } + +ParsedQuery::ParsedQuery(const QString &rawQuery) +{ + query = rawQuery; + qualifiers = {}; + + // Regex capturing: key, and value (bare, quoted and unquoted) + static QRegularExpression re(R"((^|\s)([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(?:\"([^\"]*)\"|'([^']*)'|(\S+)))", + QRegularExpression::CaseInsensitiveOption); + + // Collect matches to remove later (from end to start to keep indices stable) + struct Span + { + qsizetype start; + qsizetype length; + QString key; + QString value; + }; + QVector<Span> spans; + + auto it = re.globalMatch(rawQuery); + while (it.hasNext()) + { + const auto m = it.next(); + const QString key = m.captured(2).toLower(); + // Value can be in group 3 (double quotes), 4 (single quotes), or 5 (bare) + QString val = m.captured(3); + if (val.isNull() || val.isEmpty()) val = m.captured(4); + if (val.isNull() || val.isEmpty()) val = m.captured(5); + spans.push_back(Span{m.capturedStart(0), m.capturedLength(0), key, val}); + } + + // Keep only the last value per key (last occurrence wins, do not accumulate) + for (const auto &s: spans) + qualifiers[s.key] = s.value; + + // Remove matched spans from the text (replace with a single space to preserve boundaries) + std::sort(spans.begin(), spans.end(), [](const Span &a, const Span &b) { return a.start > b.start; }); + for (const auto &s: spans) + query.replace(s.start, s.length, QStringLiteral(" ")); + + // Normalize whitespace + query = query.simplified(); +} diff --git a/plugins/warp/ui/shared/misc.h b/plugins/warp/ui/shared/misc.h index d2e6a1a8..35f640f5 100644 --- a/plugins/warp/ui/shared/misc.h +++ b/plugins/warp/ui/shared/misc.h @@ -5,9 +5,11 @@ #include <QStyledItemDelegate> #include <QTableView> #include <QVector> +#include <utility> #include "binaryninjaapi.h" #include "filter.h" +#include "warp.h" // Used to serialize into the item data for rendering with TokenDataDelegate. struct TokenData @@ -16,6 +18,10 @@ struct TokenData TokenData() = default; + TokenData(const std::string& name); + + TokenData(const BinaryNinja::Type &type, const std::string& name); + TokenData(const std::vector<BinaryNinja::InstructionTextToken> &tokens) { for (const auto &token: tokens) @@ -72,7 +78,7 @@ class GenericTextFilterModel : public QSortFilterProxyModel Q_OBJECT public: - GenericTextFilterModel(QObject *parent): QSortFilterProxyModel(parent) + GenericTextFilterModel(QObject *parent) : QSortFilterProxyModel(parent) { } @@ -82,3 +88,27 @@ public: bool lessThan(const QModelIndex &sourceLeft, const QModelIndex &sourceRight) const override; }; + +// Used to parse qualifiers out of a user-supplied string (or "query") +struct ParsedQuery +{ + // The actual query, without the qualifiers like source:<uuid> + QString query; + // The qualifiers used to build other optional parts of the query. + QHash<QString, QString> qualifiers; + + ParsedQuery(QString query, const QHash<QString, QString> &qualifiers) + : query(std::move(query)), qualifiers(qualifiers) + { + } + + explicit ParsedQuery(const QString &rawQuery); + + [[nodiscard]] std::optional<QString> GetValue(const QString &key) const + { + const auto it = qualifiers.constFind(key); + if (it == qualifiers.constEnd() || it->isEmpty()) + return std::nullopt; + return it.value(); + } +}; diff --git a/plugins/warp/ui/shared/search.cpp b/plugins/warp/ui/shared/search.cpp new file mode 100644 index 00000000..1431e9b6 --- /dev/null +++ b/plugins/warp/ui/shared/search.cpp @@ -0,0 +1,301 @@ +#include "search.h" +#include "misc.h" +#include "../../../../../ui/mainwindow.h" + +QVariant WarpSearchModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) + return {}; + const auto &it = m_items[index.row()]; + + // Provide tokenized type for functions via UserRole for TokenDataDelegate + if (role == Qt::UserRole && index.column() == DisplayCol) + { + if (it && it->GetKind() == WARPContainerSearchItemKindFunction) + if (auto itemType = it->GetType(nullptr)) + return QVariant::fromValue(TokenData(*itemType, it->GetName())); + return {}; + } + + + if (role == Qt::DisplayRole) + { + // TODO: We might want to run the demangler here on the name. + switch (index.column()) + { + case DisplayCol: return QString::fromStdString(it->GetName()); + case KindCol: + { + switch (it->GetKind()) + { + case WARPContainerSearchItemKindFunction: return "Function"; + case WARPContainerSearchItemKindType: return "Type"; + case WARPContainerSearchItemKindSource: return "Source"; + case WARPContainerSearchItemKindSymbol: return "Symbol"; + default: return {}; + } + } + case SourceCol: return QString::fromStdString(it->GetSource().ToString()); + default: return {}; + } + } + return {}; +} + +QVariant WarpSearchModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (orientation == Qt::Horizontal && role == Qt::DisplayRole) + { + switch (section) + { + case DisplayCol: return "Item"; + case KindCol: return "Kind"; + case SourceCol: return "Source"; + default: return {}; + } + } + return {}; +} + +void WarpSearchDelegate::paint(QPainter *p, const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + // TODO: We actually still want the function icon, I think? So this should not early return and instead replace the name. + // If model provided TokenData (e.g., for function types), render via TokenDataDelegate + if (index.data(Qt::UserRole).canConvert<TokenData>()) + { + TokenDataDelegate(parent()).paint(p, option, index); + return; + } + + + QStyleOptionViewItem opt(option); + initStyleOption(&opt, index); + + // We will draw the background/selection via style, and custom-draw icon (left) + text. + const QWidget *w = option.widget; + QStyle *style = w ? w->style() : QApplication::style(); + + // Let style draw the item background/selection etc., but not the default text + QString originalText = opt.text; + opt.text.clear(); + style->drawControl(QStyle::CE_ItemViewItem, &opt, p, w); + + // Retrieve text for DisplayCol + const QString &text = originalText; + + // Retrieve kind text from the hidden KindCol + const QModelIndex kindIndex = index.sibling(index.row(), WarpSearchModel::KindCol); + const QString kind = kindIndex.isValid() ? kindIndex.data(Qt::DisplayRole).toString() : QString(); + + // Map kind -> icon (emoji for simplicity; replace with QIcon/QPixmap if desired) + const QString icon = kindToIcon(kind); + + // Layout rects + const QRect r = opt.rect; + const int marginH = 8; + const int iconSide = 16; + const int spacing = 8; + + // Reserve space on the left for the icon if we have one + QRect iconRect = QRect(r.left() + marginH, r.center().y() - iconSide / 2, iconSide, iconSide); + QRect textRect = r.adjusted((icon.isEmpty() ? marginH : (marginH + iconSide + spacing)), 0, -marginH, 0); + + // Draw icon first (without clipping), then text with clipping + p->save(); + if (!icon.isEmpty()) + { + QFont iconFont = opt.font; + iconFont.setPointSizeF(iconFont.pointSizeF() + 2); + p->setFont(iconFont); + p->setPen( + opt.palette.color(opt.state & QStyle::State_Selected ? QPalette::HighlightedText : QPalette::Text)); + p->drawText(iconRect, Qt::AlignCenter, icon); + } + p->restore(); + + // Draw elided text, vertically centered (with clipping so long strings don't overflow) + p->save(); + p->setClipRect(textRect); + const QFontMetrics fm(opt.font); + const QString elided = fm.elidedText(text, Qt::ElideRight, textRect.width()); + p->setPen(opt.palette.color(opt.state & QStyle::State_Selected ? QPalette::HighlightedText : QPalette::Text)); + p->drawText(textRect, Qt::AlignVCenter | Qt::AlignLeft, elided); + p->restore(); +} + +void WarpSearchRunner::fetchPage(std::size_t offset, std::size_t limit) +{ + const auto session = currentSession(); + auto container = m_container; + auto q = m_queryText; + auto src = m_source; + + // Runs search on Qt threadpool then moves the result to the main thread to update the UI. + using TaskResult = std::pair<std::optional<Warp::ContainerSearchResponse>, QString>; + QFutureWatcher<TaskResult> *watcher = new QFutureWatcher<TaskResult>(this); + const auto future = QtConcurrent::run([container, q, src, offset, limit]() -> TaskResult { + const Warp::ContainerSearchQuery query(q.toStdString(), offset, limit, src); + auto respOpt = container->Search(query); + // TODO: We may want to provide better errors in the future, e.g. BNWARPGetError + if (!respOpt.has_value()) + return {std::nullopt, QStringLiteral("No response from container")}; + return {std::move(respOpt), QString()}; + }); + connect(watcher, &QFutureWatcher<TaskResult>::finished, this, [this, watcher, session]() { + const TaskResult result = watcher->result(); + watcher->deleteLater(); + if (const auto &err = result.second; !err.isEmpty()) + emit pageError(session, err); + else if (const auto &resp = result.first; resp.has_value()) + emit pageReady(session, *resp); + }); + watcher->setFuture(future); +} + +WarpSearchWidget::WarpSearchWidget(Warp::Ref<Warp::Container> container, QWidget *parent) : QWidget(parent) +{ + m_model = new WarpSearchModel(this); + m_runner = new WarpSearchRunner(container, this); + auto *layout = new QVBoxLayout(this); + m_query = new QLineEdit(this); + m_status = new QLabel(this); + m_view = new QTableView(this); + + // TODO: I am not necessarily a fan with how this looks. + m_query->setPlaceholderText("Search… (Optionally filter with source:<uuid>)"); + + // This is where we make the table not look like a table but instead a list. + m_view->setShowGrid(false); + m_view->verticalHeader()->setVisible(false); + m_view->horizontalHeader()->setVisible(false); + m_view->setSelectionBehavior(QAbstractItemView::SelectRows); + m_view->setSelectionMode(QAbstractItemView::SingleSelection); + m_view->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_view->setFocusPolicy(Qt::NoFocus); + + layout->addWidget(m_query); + layout->addWidget(m_status); + layout->addWidget(m_view); + + m_view->setModel(m_model); + + // Use a delegate to draw the DisplayCol text with an icon (optionally) aligned to the left. + m_view->setItemDelegateForColumn(WarpSearchModel::DisplayCol, new WarpSearchDelegate(m_view)); + + // Hide extra columns: only show the combined "Item" column; do not display source GUID. + m_view->setColumnHidden(WarpSearchModel::KindCol, true); + m_view->setColumnHidden(WarpSearchModel::SourceCol, true); + m_view->horizontalHeader()->setSectionResizeMode(WarpSearchModel::DisplayCol, QHeaderView::Stretch); + + // Debounce user input + m_debounce.setSingleShot(true); + m_debounce.setInterval(SEARCH_DEBOUNCE_MS); + connect(m_query, &QLineEdit::textChanged, this, [this](const QString &) { m_debounce.start(); }); + + connect(&m_debounce, &QTimer::timeout, this, [this]() { + const QString raw = m_query->text(); + + // Parse generic qualifiers and clean the free-text query + const auto parsed = ParsedQuery(raw); + + std::optional<Warp::Source> inlineSource = std::nullopt; + if (const auto val = parsed.GetValue("source"); val.has_value()) + inlineSource = Warp::Source::FromString(val.value().toStdString()); + const auto effectiveSource = inlineSource.has_value() ? inlineSource : m_source; + // TODO: Filter for tags and function guid. + + m_runner->startSession(parsed.query, effectiveSource); + m_model->beginNewSearch(SEARCH_PAGE_SIZE); + }); + + + // Infinite scroll trigger + connect(m_model, &WarpSearchModel::fetchMoreRequested, this, [this](std::size_t offset, std::size_t limit) { + m_status->setText(QStringLiteral("Loading… (%1/%2)").arg(m_model->currentCount()).arg(m_model->total())); + m_runner->fetchPage(offset, limit); + }); + + // Append results if session still valid + connect(m_runner, &WarpSearchRunner::pageReady, this, + [this](std::uint64_t, const Warp::ContainerSearchResponse &resp) { + m_model->appendResponse(resp); + m_status->setText( + QStringLiteral("Showing %1 of %2").arg(m_model->currentCount()).arg(m_model->total())); + }); + + // In cases like if the network container server goes down. + connect(m_runner, &WarpSearchRunner::pageError, this, + [this](std::uint64_t, const QString &msg) { + m_status->setText(QStringLiteral("Error: %1").arg(msg)); + }); + + // Add a context menu so that we can actually do actionable things with what we find. + m_view->setContextMenuPolicy(Qt::CustomContextMenu); + connect(m_view, &QTableView::customContextMenuRequested, this, [this](const QPoint &pos) { + const QModelIndex idx = m_view->indexAt(pos); + if (!idx.isValid()) + return; + + // TODO: Getting the current view here is really awful, but i dont care right now. + auto ctx = MainWindow::activeContext(); + auto view = ctx->getCurrentView(); + auto binaryView = view->getData(); + auto viewFrame = ctx->getCurrentViewFrame(); + auto viewLocation = viewFrame->getViewLocation(); + auto func = viewLocation.getFunction(); + + // Retrieve the current architecture from the current function or try the current view. + auto arch = binaryView->GetDefaultArchitecture(); + if (func) + arch = func->GetArchitecture(); + + const int row = idx.row(); + const auto item = m_model->itemAt(row); + const auto itemType = item->GetType(arch); + const auto itemFunc = item->GetFunction(); + + QMenu menu(this); + QAction *copySourceId = menu.addAction(tr("Copy Source")); + QAction *searchSource = menu.addAction(tr("Search Source")); + QAction *applyType = menu.addAction(tr("Apply Type")); + QAction *applyFunction = menu.addAction(tr("Apply to Current Function")); + + // We let users apply the type for types and functions (assuming the function has one) + // if the user applies a function, we actually will set the user type for the current view location function. + // For types, we will just throw it in the user types. + applyType->setEnabled(itemType != nullptr); + applyType->setVisible(applyType->isEnabled()); + + applyFunction->setEnabled(func != nullptr && itemFunc); + applyFunction->setVisible(applyFunction->isEnabled()); + + QAction *chosen = menu.exec(m_view->viewport()->mapToGlobal(pos)); + if (!chosen) + return; + if (chosen == copySourceId) + QApplication::clipboard()->setText(QString::fromStdString(item->GetSource().ToString())); + else if (chosen == searchSource) + { + const QString sourceId = QString::fromStdString(item->GetSource().ToString()); + // TODO: This does not preserve the query that existed prior, doing so may result in duplicate source qualifiers. + m_query->setText(QStringLiteral("source:\"%1\"").arg(sourceId)); + } else if (chosen == applyType) + { + if (func && item->GetKind() == WARPContainerSearchItemKindFunction) + { + func->SetUserType(itemType); + binaryView->UpdateAnalysis(); + } + else + binaryView->DefineUserType(item->GetName(), itemType); + } else if (chosen == applyFunction) + { + itemFunc->Apply(*func); + binaryView->UpdateAnalysis(); + } + }); + + // This will start searching with an empty query once the widget is constructed. This is a decent behavior considering + // we should be heavily caching queries such as an empty one. + m_debounce.start(); +} diff --git a/plugins/warp/ui/shared/search.h b/plugins/warp/ui/shared/search.h new file mode 100644 index 00000000..86c90942 --- /dev/null +++ b/plugins/warp/ui/shared/search.h @@ -0,0 +1,234 @@ +#pragma once + +#include <QAbstractTableModel> +#include <QApplication> +#include <QClipboard> +#include <QFutureWatcher> +#include <QHeaderView> +#include <QLabel> +#include <QLineEdit> +#include <QMenu> +#include <QObject> +#include <QPainter> +#include <QVector> +#include <QString> +#include <QStyledItemDelegate> +#include <QTableView> +#include <QTimer> +#include <QVBoxLayout> +#include <QtConcurrent/QtConcurrentRun> + +#include "uitypes.h" +#include "warp.h" + +// Will search in batches of 50 items. +constexpr auto SEARCH_PAGE_SIZE = 50; +// Will debounce the search for 350 MS. +constexpr auto SEARCH_DEBOUNCE_MS = 350; + +// Table model showing a paginated list of SearchItem. +class WarpSearchModel final : public QAbstractTableModel +{ + Q_OBJECT + +public: + enum Columns : int + { + DisplayCol = 0, + KindCol, + SourceCol, + ColumnCount + }; + + explicit WarpSearchModel(QObject *parent = nullptr) + : QAbstractTableModel(parent) + { + } + + int rowCount(const QModelIndex &parent) const override + { + if (parent.isValid()) return 0; + return m_items.size(); + } + + int columnCount(const QModelIndex &parent) const override + { + Q_UNUSED(parent); + return ColumnCount; + } + + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + QVariant headerData(int section, Qt::Orientation orientation, int role) const override; + + // Begin a new search session: clears all current items and resets counters. + Q_INVOKABLE void beginNewSearch(std::size_t pageSize = SEARCH_PAGE_SIZE) + { + beginResetModel(); + m_items.clear(); + m_total = 0; + m_pageSize = pageSize; + endResetModel(); + emit cleared(); + // Immediately ask for the first page from offset 0 + emit fetchMoreRequested(0, m_pageSize); + } + + // Append results from a Warp::ContainerSearchResponse (infinite scroll, no replacement). + void appendResponse(const Warp::ContainerSearchResponse &resp) + { + // Update total first (so canFetchMore reflects new value) + m_total = resp.total; + if (resp.items.empty()) + { + emit responseUpdated(currentCount(), m_total); + return; + } + const int begin = m_items.size(); + const int end = begin + static_cast<int>(resp.items.size()) - 1; + beginInsertRows(QModelIndex(), begin, end); + for (const auto &refItem: resp.items) + m_items.push_back(refItem); + endInsertRows(); + emit responseUpdated(currentCount(), m_total); + } + + // Qt's infinite scroll hooks: the view will call these when near the end. + bool canFetchMore(const QModelIndex &parent) const override + { + Q_UNUSED(parent); + return static_cast<std::size_t>(m_items.size()) < m_total; + } + + void fetchMore(const QModelIndex &parent) override + { + Q_UNUSED(parent); + if (!canFetchMore({})) + return; + const std::size_t nextOffset = m_items.size(); + emit fetchMoreRequested(nextOffset, m_pageSize); + } + + // Convenience accessors. + std::size_t total() const { return m_total; } + int currentCount() const { return m_items.size(); } + void setPageSize(std::size_t pageSize) { m_pageSize = pageSize; } + + Q_INVOKABLE Warp::Ref<Warp::ContainerSearchItem> itemAt(int row) const + { + if (row < 0 || row >= m_items.size()) + return {}; + return m_items[row]; + } + +signals: + // Emitted when model is cleared for a new query. + void cleared(); + + // Emitted after items are appended or totals updated. + void responseUpdated(int currentCount, std::size_t total); + + // Request the next page; connect this to your async search runner. + void fetchMoreRequested(std::size_t offset, std::size_t limit); + +private: + QVector<Warp::Ref<Warp::ContainerSearchItem> > m_items; + std::size_t m_total = 0; + std::size_t m_pageSize = SEARCH_PAGE_SIZE; +}; + +// A delegate to render the DisplayCol text with an icon (mapped from KindCol) on the right. +class WarpSearchDelegate final : public QStyledItemDelegate +{ + Q_OBJECT + +public: + explicit WarpSearchDelegate(QObject *parent = nullptr) : QStyledItemDelegate(parent) + { + } + + QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override + { + // Base text size + space for an icon on the right + const QSize base = QStyledItemDelegate::sizeHint(option, index); + const int iconSide = 16; + return QSize(base.width(), qMax(base.height(), iconSide + 4)); // keep row height >= icon + } + + void paint(QPainter *p, const QStyleOptionViewItem &option, const QModelIndex &index) const override; + +private: + static QString kindToIcon(const QString &kind) + { + // Example mapping; extend as needed + if (kind.compare(QStringLiteral("Function"), Qt::CaseInsensitive) == 0) + return QStringLiteral("ƒ"); + if (kind.compare(QStringLiteral("Type"), Qt::CaseInsensitive) == 0) + return QStringLiteral("𝑇"); + if (kind.compare(QStringLiteral("Source"), Qt::CaseInsensitive) == 0) + return QStringLiteral("📦"); + // Default: no icon + return {}; + } +}; + +class WarpSearchRunner : public QObject +{ + Q_OBJECT + +public: + explicit WarpSearchRunner(Warp::Ref<Warp::Container> container, QObject *parent = nullptr) + : QObject(parent), m_container(std::move(container)) + { + } + +signals: + // Emitted on the UI thread after the worker finishes + void pageReady(std::uint64_t sessionId, Warp::ContainerSearchResponse resp); + + void pageError(std::uint64_t sessionId, QString message); + +public slots: + // Start a new logical search session (new query or new filters) + void startSession(QString queryText, const std::optional<Warp::Source> &source) + { + m_queryText = std::move(queryText); + m_source = source; + m_sessionId.fetchAndAddRelaxed(1); + } + + // Fetch a page for the current session. Safe to call multiple times (infinite scroll). + void fetchPage(std::size_t offset, std::size_t limit); + +private: + std::uint64_t currentSession() const { return m_sessionId.loadRelaxed(); } + + Warp::Ref<Warp::Container> m_container; + QAtomicInteger<quint64> m_sessionId{0}; + QString m_queryText; + std::optional<Warp::Source> m_source; +}; + +class WarpSearchWidget : public QWidget +{ + Q_OBJECT + +public: + explicit WarpSearchWidget(Warp::Ref<Warp::Container> container, QWidget *parent = nullptr); + + void setSourceFilter(const std::optional<Warp::Source> &src) + { + m_source = src; + m_debounce.start(); + } + +private: + WarpSearchModel *m_model; + WarpSearchRunner *m_runner; + QLineEdit *m_query; + QLabel *m_status; + QTableView *m_view; + QTimer m_debounce; + // Source to filter for if no source is provided in the search. + std::optional<Warp::Source> m_source; +}; |
