diff --git a/ui/backgroundsortfilterrows.h b/ui/backgroundsortfilterrows.h new file mode 100644 index 0000000000..754ef1bf51 --- /dev/null +++ b/ui/backgroundsortfilterrows.h @@ -0,0 +1,573 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "base/assertions.h" +#include "binaryninjacore.h" + +/*! The non-template portion of `BackgroundSortFilterRows`. + Contains the background job state machine, watcher lifecycle, and abandonment protocol, + which are independent of the row type. + + \ingroup filter +*/ +class BackgroundSortFilterRowsBase +{ +public: + // Model integration points. The reset and insert hooks bracket changes to the display rows, + // matching the corresponding QAbstractItemModel methods. + struct ModelHooks + { + std::function beginResetModel; + std::function endResetModel; + std::function beginInsertRows; + std::function endInsertRows; + // Optional. Called when background filtering starts and finishes. + std::function filteringChanged; + // Optional. Called when background sorting starts and finishes. + std::function sortingChanged; + }; + + explicit BackgroundSortFilterRowsBase(ModelHooks hooks) : m_hooks(std::move(hooks)) {} + virtual ~BackgroundSortFilterRowsBase() { shutdown(); } + + BackgroundSortFilterRowsBase(const BackgroundSortFilterRowsBase&) = delete; + BackgroundSortFilterRowsBase& operator=(const BackgroundSortFilterRowsBase&) = delete; + + bool busy() const { return m_jobActive; } + bool filtering() const { return m_jobActive && m_jobKind == JobKind::Filter; } + bool sorting() const { return m_jobActive && m_jobKind == JobKind::Sort; } + +protected: + enum class JobKind + { + Filter, + Sort, + }; + + void assertOwningThread() const { BN_ASSERT(QThread::currentThread() == m_owningThread); } + + // Abandon any in-flight job, wait for its workers to notice, and destroy the watcher + // immediately so a queued finished signal cannot run a commit handler afterwards. Derived + // destructors must call this before the row storage the workers read is destroyed. + void shutdown() + { + m_filterGeneration.fetch_add(1); + m_future.waitForFinished(); + m_watcher.reset(); + } + + // Like `shutdown`, but leaves the object reusable. Returns it to the idle state and notifies + // that the abandoned job is no longer running. The job's result is discarded, not committed. + void abandonJob() + { + if (!m_jobActive) + return; + shutdown(); + m_jobActive = false; + if (m_jobKind == JobKind::Filter && m_hooks.filteringChanged) + m_hooks.filteringChanged(false); + else if (m_jobKind == JobKind::Sort && m_hooks.sortingChanged) + m_hooks.sortingChanged(false); + } + + // Registers `future` as the running job. Its completion invokes `commitJob` back on the + // owning thread. `filterGeneration` must be the value the job's workers compare against. + void beginJob(JobKind kind, uint64_t filterGeneration, QFuture future) + { + BN_ASSERT(!m_jobActive); + m_jobActive = true; + m_jobKind = kind; + m_jobFilterGeneration = filterGeneration; + m_future = std::move(future); + m_watcher = std::make_unique>(); + QObject::connect( + m_watcher.get(), &QFutureWatcherBase::finished, m_watcher.get(), [this] { finishJob(); }); + if (kind == JobKind::Filter && m_hooks.filteringChanged) + m_hooks.filteringChanged(true); + else if (kind == JobKind::Sort && m_hooks.sortingChanged) + m_hooks.sortingChanged(true); + m_watcher->setFuture(m_future); + } + + // Whether the filter changed after the current or just-finished job captured its generation. + bool filterChangedDuringJob() const { return m_jobFilterGeneration != m_filterGeneration.load(); } + + // Called on the owning thread when the registered job's future finishes. + virtual void commitJob(JobKind kind) = 0; + + ModelHooks m_hooks; + // Bumped when the filtered set becomes stale: on a filter-predicate change, and on teardown. + // Filter workers watch it to drop a superseded filter pass. Sort workers watch it too, because a + // filter change invalidates the display snapshot they are reordering, and commitSortJob reads it + // (via filterChangedDuringJob) to recognize that case and re-filter before re-sorting. + std::atomic m_filterGeneration = 0; + +private: + void finishJob() + { + // This is invoked by the watcher's finished signal so the watcher cannot be deleted + // directly here. + m_watcher.release()->deleteLater(); + m_jobActive = false; + commitJob(m_jobKind); + } + + QThread* m_owningThread = QThread::currentThread(); + uint64_t m_jobFilterGeneration = 0; + bool m_jobActive = false; + JobKind m_jobKind = JobKind::Filter; + std::unique_ptr> m_watcher; + QFuture m_future; +}; + +/*! Append-only row storage for table models that filters and sorts rows on the worker pool. + + Filtering and sorting run as QtConcurrent jobs. Changing the filter abandons a running job at + its next check and re-runs with the latest predicate, so rapid filter changes stay responsive. + Appends and sorts requested while a job is running are deferred and applied when the job + commits, allowing the job to read the rows without locking. + + All methods must be called on the thread the object was created on (the GUI thread for table + models): the job state machine and row vectors are single-thread-confined, with the worker + threads communicating back only through the watcher's queued finished signal, and reading only + the atomic generation counters and the unmutated rows. The filter predicate and comparator are + called concurrently from worker threads and must be thread-safe. + + \ingroup filter +*/ +template +class BackgroundSortFilterRows : public BackgroundSortFilterRowsBase +{ +public: + using Predicate = std::function; + using PredicateFactory = std::function; + using Comparator = std::function; + // Display entries are indices into the master rows. 32 bits suffices: a QAbstractItemModel + // addresses rows with int, so it can never display more than INT_MAX of them. + using Index = uint32_t; + + explicit BackgroundSortFilterRows(ModelHooks hooks) : BackgroundSortFilterRowsBase(std::move(hooks)) {} + + ~BackgroundSortFilterRows() override + { + // The workers read the row storage, so they must be stopped before it is destroyed. + shutdown(); + } + + // Number of rows a view should currently display. Either all rows, or the matches of the + // committed filter. + size_t displayCount() const { return m_display.size(); } + + // The row at display position `index`, resolving through the filter/sort index. + const Row& displayAt(size_t index) const { return m_rows[m_display[index]]; } + + // Count of all rows, ignoring any filter, including rows whose append is deferred. + size_t totalCount() const { return m_rows.size() + m_pendingRows.size(); } + + // Drop all rows and free their storage, returning to the empty state and abandoning any + // running job. The filter and sort settings are retained, so rows appended afterward are + // filtered as before. + void clear() + { + assertOwningThread(); + abandonJob(); + m_hooks.beginResetModel(); + // Assigning fresh vectors releases the capacity rather than retaining it like clear(). + m_rows = std::vector{}; + m_display = std::vector{}; + m_pendingRows = std::vector{}; + m_pendingSort.reset(); + m_maintainSortOnAppend = false; + m_hooks.endResetModel(); + } + + void append(std::vector rows) { appendRows(std::move(rows), true); } + + void setFilter(Predicate filter) + { + if (filter) + setFilterFactory([filter] { return filter; }); + else + setFilterFactory(nullptr); + } + + // Like `setFilter`, but the factory is invoked once per work chunk on the worker thread to + // create that chunk's predicate. Use this when the predicate holds state that is expensive + // to share across threads, e.g. a QRegularExpression, whose internal mutex serializes + // concurrent matches on a shared instance. + void setFilterFactory(PredicateFactory factory) + { + assertOwningThread(); + m_filterFactory = std::move(factory); + m_filter = m_filterFactory ? m_filterFactory() : Predicate(); + m_filterGeneration.fetch_add(1); + // An active job notices the generation change, finishes early, and re-applies on completion. + if (!busy()) + applyCurrentFilter(); + } + + void sort(Comparator comparator) + { + assertOwningThread(); + m_activeSort = comparator; + // Bump the sort generation so a sort already running notices and bails out promptly rather + // than sorting rows whose result this newer request will discard. + m_sortGeneration.fetch_add(1); + if (busy()) + { + m_pendingSort = std::move(comparator); + return; + } + startSortJob(std::move(comparator)); + } + +protected: + void commitJob(JobKind kind) override + { + if (kind == JobKind::Sort) + commitSortJob(); + else + commitFilterJob(); + } + +private: + void appendRows(std::vector rows, bool resortAfterAppend) + { + assertOwningThread(); + if (rows.empty()) + return; + + if (busy()) + { + m_pendingRows.insert(m_pendingRows.end(), std::make_move_iterator(rows.begin()), + std::make_move_iterator(rows.end())); + return; + } + + // Collect the master indices of the new rows that the filter accepts. An unset filter + // accepts all of them. + const Index base = static_cast(m_rows.size()); + std::vector newDisplay; + for (size_t i = 0; i < rows.size(); i++) + if (!m_filter || m_filter(rows[i])) + newDisplay.push_back(base + static_cast(i)); + + if (!newDisplay.empty()) + { + const int oldCount = static_cast(m_display.size()); + m_hooks.beginInsertRows(oldCount, oldCount + static_cast(newDisplay.size()) - 1); + } + + // Retain every row for later filter changes, even those not currently displayed. + m_rows.insert( + m_rows.end(), std::make_move_iterator(rows.begin()), std::make_move_iterator(rows.end())); + m_display.insert(m_display.end(), newDisplay.begin(), newDisplay.end()); + + if (!newDisplay.empty()) + m_hooks.endInsertRows(); + + if (resortAfterAppend && !newDisplay.empty() && m_maintainSortOnAppend && m_activeSort) + startSortJob(m_activeSort); + } + + // Parallel merge sort of `display` (indices into `rows`) by `comparator`. Returns the sorted + // indices, or nullopt if `filterGeneration` stopped matching `currentFilterGeneration` (the + // filter changed) or `sortGeneration` stopped matching `currentSortGeneration` (a newer sort was + // requested) mid-sort, meaning the result should be discarded. + static std::optional> parallelSort(std::vector display, + const std::vector& rows, const Comparator& comparator, uint64_t filterGeneration, + const std::atomic& currentFilterGeneration, uint64_t sortGeneration, + const std::atomic& currentSortGeneration) + { + if (display.size() < 2) + return display; + + struct Abandoned{}; + std::atomic abandoned = false; + // Wraps the comparator to compare rows by index and bail out promptly if the filter or + // sort changes mid-job. + const auto guarded = [&](size_t& comparisons) { + return [&](Index a, Index b) { + if ((++comparisons & 0xfff) == 0 + && (currentFilterGeneration.load(std::memory_order_relaxed) != filterGeneration + || currentSortGeneration.load(std::memory_order_relaxed) != sortGeneration)) + throw Abandoned {}; + return comparator(rows[a], rows[b]); + }; + }; + + // Sort chunks of the indices in parallel, then merge adjacent chunks pairwise, with each + // merge round also running in parallel. A chunk is the half-open range [first, second). + const size_t threadCount = + std::clamp(QThreadPool::globalInstance()->maxThreadCount(), 1, 64); + const size_t chunkSize = std::max(1, (display.size() + threadCount - 1) / threadCount); + using Range = std::pair; + std::vector chunks; + chunks.reserve((display.size() + chunkSize - 1) / chunkSize); + for (size_t begin = 0; begin < display.size(); begin += chunkSize) + chunks.emplace_back(begin, std::min(begin + chunkSize, display.size())); + + QtConcurrent::blockingMap(chunks, [&](const Range& chunk) { + size_t comparisons = 0; + try + { + std::sort(display.begin() + chunk.first, display.begin() + chunk.second, guarded(comparisons)); + } + catch (const Abandoned&) + { + abandoned = true; + } + }); + + while (chunks.size() > 1 && !abandoned) + { + // Pair up adjacent chunks. Each pair (i, i + 1) is merged about their shared boundary + // chunks[i].second. An odd final chunk is already sorted and carries forward unchanged. + std::vector pairs; + std::vector mergedChunks; + pairs.reserve(chunks.size() / 2); + mergedChunks.reserve((chunks.size() + 1) / 2); + for (size_t i = 0; i < chunks.size(); i += 2) + { + if (i + 1 < chunks.size()) + { + pairs.push_back(i); + mergedChunks.emplace_back(chunks[i].first, chunks[i + 1].second); + } + else + { + mergedChunks.push_back(chunks[i]); + } + } + QtConcurrent::blockingMap(pairs, [&](size_t i) { + size_t comparisons = 0; + try + { + std::inplace_merge(display.begin() + chunks[i].first, display.begin() + chunks[i].second, + display.begin() + chunks[i + 1].second, guarded(comparisons)); + } + catch (const Abandoned&) + { + abandoned = true; + } + }); + chunks = std::move(mergedChunks); + } + + if (abandoned) + return std::nullopt; + return display; + } + + void startSortJob(Comparator comparator) + { + const uint64_t filterGeneration = m_filterGeneration.load(); + const uint64_t sortGeneration = m_sortGeneration.load(); + m_sortResult = std::make_shared>(); + auto result = m_sortResult; + + // The display indices are not mutated while the job is active (appends and sorts are + // deferred), so the worker sorts a copy and reads m_rows without locking. An abandoned sort + // leaves the shared result empty, which commitSortJob never reads. + auto future = QtConcurrent::run( + [this, filterGeneration, sortGeneration, comparator, result, display = m_display]() mutable { + if (auto sorted = parallelSort(std::move(display), m_rows, comparator, filterGeneration, + m_filterGeneration, sortGeneration, m_sortGeneration)) + *result = std::move(*sorted); + }); + beginJob(JobKind::Sort, filterGeneration, std::move(future)); + } + + void commitSortJob() + { + auto result = std::move(m_sortResult); + + // The filter changed while sorting. Re-apply it and queue the sort to run afterwards. + if (filterChangedDuringJob()) + { + if (m_hooks.sortingChanged) + m_hooks.sortingChanged(false); + applyCurrentFilter(); + return; + } + + // A newer sort was requested while this one ran. Skip straight to it. + if (m_pendingSort) + { + auto next = std::move(*m_pendingSort); + m_pendingSort.reset(); + drainPendingRows(); + startSortJob(std::move(next)); + return; + } + + // Rows appended while this sort was running were not part of the worker's display snapshot. + // Add them and rerun the active sort over the full display set. + if (!m_pendingRows.empty()) + { + drainPendingRows(); + if (m_activeSort) + startSortJob(m_activeSort); + else if (m_hooks.sortingChanged) + m_hooks.sortingChanged(false); + return; + } + + m_hooks.beginResetModel(); + m_display = std::move(*result); + // Only start maintaining the sort across future appends once a sort has committed over + // actual rows. This keeps the initial bulk load cheap: the sort indicator is set on the + // empty model and rows stream in arrival order without re-sorting each batch, until the + // caller issues the final sort over the complete content. + m_maintainSortOnAppend = m_activeSort && !m_rows.empty(); + m_hooks.endResetModel(); + + if (m_hooks.sortingChanged) + m_hooks.sortingChanged(false); + } + + void applyCurrentFilter() + { + drainPendingRows(); + + if (!m_filter) + { + m_hooks.beginResetModel(); + // With no filter the display is every row, in master order. + m_display.resize(m_rows.size()); + std::iota(m_display.begin(), m_display.end(), Index{0}); + m_hooks.endResetModel(); + if (m_hooks.filteringChanged) + m_hooks.filteringChanged(false); + applySortAfterDisplayChange(); + return; + } + + const uint64_t filterGeneration = m_filterGeneration.load(); + PredicateFactory factory = m_filterFactory; + + constexpr size_t chunkSize = 1024 * 1024; + std::vector> chunks; + for (size_t begin = 0; begin < m_rows.size(); begin += chunkSize) + chunks.emplace_back(begin, std::min(begin + chunkSize, m_rows.size())); + + m_filterResult = QtConcurrent::mapped(chunks, + [this, filterGeneration, factory](const std::pair& chunk) { + std::vector matches; + if (m_filterGeneration.load() != filterGeneration) + return matches; + // Materialize the chunk's own predicate so predicate state is never shared across + // worker threads. + const Predicate filter = factory(); + for (size_t i = chunk.first; i < chunk.second; i++) + { + // Bail out promptly if the filter changes mid-chunk. + if ((i & 0xfff) == 0 && m_filterGeneration.load() != filterGeneration) + return matches; + if (filter(m_rows[i])) + matches.push_back(static_cast(i)); + } + return matches; + }); + beginJob(JobKind::Filter, filterGeneration, m_filterResult); + } + + void commitFilterJob() + { + auto result = std::move(m_filterResult); + + // The filter changed while the job ran. Run it again with the current predicate. + if (filterChangedDuringJob()) + { + applyCurrentFilter(); + return; + } + + std::vector matches; + for (const auto& chunkMatches : result.results()) + matches.insert(matches.end(), chunkMatches.begin(), chunkMatches.end()); + + m_hooks.beginResetModel(); + m_display = std::move(matches); + m_hooks.endResetModel(); + + drainPendingRows(); + if (m_hooks.filteringChanged) + m_hooks.filteringChanged(false); + applySortAfterDisplayChange(); + } + + void drainPendingRows() + { + if (m_pendingRows.empty()) + return; + auto pending = std::move(m_pendingRows); + m_pendingRows = {}; + appendRows(std::move(pending), false); + } + + void applyPendingSort() + { + if (!m_pendingSort) + return; + auto comparator = std::move(*m_pendingSort); + m_pendingSort.reset(); + sort(std::move(comparator)); + } + + void applySortAfterDisplayChange() + { + if (m_pendingSort) + { + applyPendingSort(); + return; + } + if (m_activeSort) + startSortJob(m_activeSort); + } + + PredicateFactory m_filterFactory; + // Materialized from m_filterFactory for matching appended rows on the main thread. + Predicate m_filter; + + // The master copy of every row, append-only and never reordered. + std::vector m_rows; + // Indices into m_rows that are currently displayed, in display order. This is the only thing + // filtering and sorting rearrange, so neither copies the rows. + std::vector m_display; + + // The sort job's output indices, read by its commit handler. Shared with the worker so the + // future's own result store, which would be a second copy, is avoided. + std::shared_ptr> m_sortResult; + // The filter job's future, whose per-chunk results its commit handler concatenates. + QFuture> m_filterResult; + + Comparator m_activeSort; + // Bumped on every sort request and watched only by sort workers, so a running sort bails out + // when a newer comparator arrives. Kept separate from m_filterGeneration so commitSortJob can + // tell a superseded sort (skip straight to the new comparator) apart from a filter change (which + // must re-filter first); one shared counter would force a full re-filter on every column re-sort. + std::atomic m_sortGeneration = 0; + std::vector m_pendingRows; + std::optional m_pendingSort; + // Set once a sort commits over a non-empty row set, after which appended rows re-run the sort to + // keep the display ordered. Left false during the initial bulk load so streamed rows append in + // arrival order rather than triggering a sort per batch. + bool m_maintainSortOnAppend = false; +}; diff --git a/view/sharedcache/api/python/sharedcache.py b/view/sharedcache/api/python/sharedcache.py index cac4050e4b..3f28976440 100644 --- a/view/sharedcache/api/python/sharedcache.py +++ b/view/sharedcache/api/python/sharedcache.py @@ -92,6 +92,71 @@ def image_to_api(image: CacheImage) -> sccore.BNSharedCacheImage: regionStarts=core_region_starts ) +@dataclasses.dataclass +class CacheString: + string_type: sccore.StringTypeEnum + address: int + raw_length: int + text: str + region_start: int + image_start: int + + def __str__(self): + return repr(self) + + def __repr__(self): + return f"" + + +def string_from_api(string: sccore.BNSharedCacheString) -> CacheString: + return CacheString( + string_type=string.stringType, + address=string.address, + raw_length=string.rawLength, + text=string.text, + region_start=string.regionStart, + image_start=string.imageStart + ) + + +class CacheStringScanner: + def __init__(self, handle: sccore.BNSharedCacheStringScannerHandle): + self.handle = handle + + def __del__(self): + if self.handle is not None: + sccore.BNFreeSharedCacheStringScanner(self.handle) + + def start(self) -> bool: + return sccore.BNSharedCacheStringScannerStart(self.handle) + + @property + def is_complete(self) -> bool: + return sccore.BNSharedCacheStringScannerIsComplete(self.handle) + + @property + def progress(self) -> (int, int): + current = ctypes.c_ulonglong() + total = ctypes.c_ulonglong() + sccore.BNSharedCacheStringScannerGetProgress(self.handle, current, total) + return current.value, total.value + + @property + def string_count(self) -> int: + return sccore.BNSharedCacheStringScannerGetStringCount(self.handle) + + def take_strings(self, max_count: int = 0xffffffffffffffff) -> [CacheString]: + count = ctypes.c_ulonglong() + value = sccore.BNSharedCacheStringScannerTakeStrings(self.handle, max_count, count) + if value is None: + return [] + result = [] + for i in range(count.value): + result.append(string_from_api(value[i])) + sccore.BNSharedCacheFreeStringList(value, count) + return result + + def symbol_from_api(symbol: sccore.BNSharedCacheSymbol) -> CacheSymbol: return CacheSymbol( symbol_type=symbol.symbolType, @@ -291,6 +356,9 @@ def symbols(self) -> [CacheSymbol]: sccore.BNSharedCacheFreeSymbolList(value, count) return result + def create_string_scanner(self) -> CacheStringScanner: + return CacheStringScanner(sccore.BNSharedCacheControllerCreateStringScanner(self.handle)) + def _get_shared_cache(instance: binaryninja.PythonScriptingInstance): if instance.interpreter.active_view is None: diff --git a/view/sharedcache/api/python/sharedcache_enums.py b/view/sharedcache/api/python/sharedcache_enums.py index d772adda31..1dd767d52b 100644 --- a/view/sharedcache/api/python/sharedcache_enums.py +++ b/view/sharedcache/api/python/sharedcache_enums.py @@ -26,6 +26,13 @@ class SharedCacheRegionType(enum.IntEnum): SharedCacheRegionTypeNonImage = 3 +class StringType(enum.IntEnum): + AsciiString = 0 + Utf16String = 1 + Utf32String = 2 + Utf8String = 3 + + class SymbolBinding(enum.IntEnum): NoBinding = 0 LocalBinding = 1 diff --git a/view/sharedcache/api/sharedcache.cpp b/view/sharedcache/api/sharedcache.cpp index d90f27994b..1367fa2fa0 100644 --- a/view/sharedcache/api/sharedcache.cpp +++ b/view/sharedcache/api/sharedcache.cpp @@ -53,6 +53,9 @@ CacheRegion RegionFromApi(BNSharedCacheRegion apiRegion) region.size = apiRegion.size; region.flags = apiRegion.flags; region.type = apiRegion.regionType; + // A zeroed imageStart means the region is not associated with an image. + if (apiRegion.imageStart != 0) + region.imageStart = apiRegion.imageStart; return region; } @@ -111,6 +114,19 @@ CacheSymbol SymbolFromApi(BNSharedCacheSymbol apiSymbol) return symbol; } +CacheString StringFromApi(const BNSharedCacheString& apiString) +{ + CacheString string; + string.type = apiString.stringType; + string.address = apiString.address; + string.rawLength = apiString.rawLength; + string.text = apiString.text; + string.regionStart = apiString.regionStart; + if (apiString.imageStart != 0) + string.imageStart = apiString.imageStart; + return string; +} + std::string SharedCacheAPI::GetRegionTypeAsString(const BNSharedCacheRegionType &type) { switch (type) @@ -359,3 +375,77 @@ std::vector SharedCacheController::GetSymbols() const BNSharedCacheFreeSymbolList(symbols, count); return result; } + +CacheStringScanner::CacheStringScanner(BNSharedCacheStringScanner* scanner) : m_object(scanner) {} + + +CacheStringScanner::~CacheStringScanner() +{ + if (m_object) + BNFreeSharedCacheStringScanner(m_object); +} + + +CacheStringScanner::CacheStringScanner(CacheStringScanner&& other) noexcept : m_object(other.m_object) +{ + other.m_object = nullptr; +} + + +CacheStringScanner& CacheStringScanner::operator=(CacheStringScanner&& other) noexcept +{ + if (this != &other) + { + if (m_object) + BNFreeSharedCacheStringScanner(m_object); + m_object = other.m_object; + other.m_object = nullptr; + } + return *this; +} + + +bool CacheStringScanner::Start() +{ + return BNSharedCacheStringScannerStart(m_object); +} + + +bool CacheStringScanner::IsComplete() const +{ + return BNSharedCacheStringScannerIsComplete(m_object); +} + + +std::pair CacheStringScanner::GetProgress() const +{ + uint64_t current = 0; + uint64_t total = 0; + BNSharedCacheStringScannerGetProgress(m_object, ¤t, &total); + return {current, total}; +} + + +uint64_t CacheStringScanner::GetStringCount() const +{ + return BNSharedCacheStringScannerGetStringCount(m_object); +} + + +std::vector CacheStringScanner::TakeStrings(uint64_t maxCount) +{ + size_t count; + BNSharedCacheString* strings = BNSharedCacheStringScannerTakeStrings(m_object, maxCount, &count); + std::vector result; + result.reserve(count); + for (size_t i = 0; i < count; i++) + result.emplace_back(StringFromApi(strings[i])); + BNSharedCacheFreeStringList(strings, count); + return result; +} + + +std::unique_ptr SharedCacheController::CreateStringScanner() const +{ + return std::make_unique(BNSharedCacheControllerCreateStringScanner(m_object)); +} diff --git a/view/sharedcache/api/sharedcacheapi.h b/view/sharedcache/api/sharedcacheapi.h index 070b10f630..e92cd99362 100644 --- a/view/sharedcache/api/sharedcacheapi.h +++ b/view/sharedcache/api/sharedcacheapi.h @@ -3,6 +3,8 @@ #include #include "sharedcachecore.h" +#include + template class DSCRefCountObject { void AddRefInternal() { m_refs.fetch_add(1); } @@ -296,6 +298,38 @@ namespace SharedCacheAPI { std::string GetSymbolTypeAsString(const BNSymbolType& type); + struct CacheString + { + BNStringType type; + uint64_t address; + // Length of the string in the cache, in bytes. + size_t rawLength; + // UTF-8 display text, truncated. + std::string text; + uint64_t regionStart; + std::optional imageStart; + }; + + class CacheStringScanner + { + BNSharedCacheStringScanner* m_object = nullptr; + + public: + explicit CacheStringScanner(BNSharedCacheStringScanner* scanner); + ~CacheStringScanner(); + + CacheStringScanner(const CacheStringScanner&) = delete; + CacheStringScanner& operator=(const CacheStringScanner&) = delete; + CacheStringScanner(CacheStringScanner&& other) noexcept; + CacheStringScanner& operator=(CacheStringScanner&& other) noexcept; + + bool Start(); + bool IsComplete() const; + std::pair GetProgress() const; + uint64_t GetStringCount() const; + std::vector TakeStrings(uint64_t maxCount); + }; + class SharedCacheController : public DSCCoreRefCountObject { public: explicit SharedCacheController(BNSharedCacheController* controller); @@ -330,5 +364,7 @@ namespace SharedCacheAPI { std::vector GetImages() const; std::vector GetLoadedImages() const; std::vector GetSymbols() const; + + std::unique_ptr CreateStringScanner() const; }; } diff --git a/view/sharedcache/api/sharedcachecore.h b/view/sharedcache/api/sharedcachecore.h index f8e9c48bcc..fcfb8d062c 100644 --- a/view/sharedcache/api/sharedcachecore.h +++ b/view/sharedcache/api/sharedcachecore.h @@ -61,10 +61,19 @@ extern "C" GlobalBinding = 2, WeakBinding = 3, }; + + enum BNStringType : uint8_t + { + AsciiString = 0, + Utf16String = 1, + Utf32String = 2, + Utf8String = 3, + }; #endif typedef struct BNBinaryView BNBinaryView; typedef struct BNSharedCacheController BNSharedCacheController; + typedef struct BNSharedCacheStringScanner BNSharedCacheStringScanner; typedef enum BNSharedCacheEntryType { SharedCacheEntryTypePrimary, @@ -119,6 +128,18 @@ extern "C" char* name; } BNSharedCacheSymbol; + typedef struct BNSharedCacheString { + BNStringType stringType; + uint64_t address; + // Length of the string in the cache, in bytes. + size_t rawLength; + // UTF-8 display text, truncated. + char* text; + uint64_t regionStart; + // NOTE: If not associated with an image this will be zero. + uint64_t imageStart; + } BNSharedCacheString; + SHAREDCACHE_FFI_API BNSharedCacheController* BNGetSharedCacheController(BNBinaryView* data); SHAREDCACHE_FFI_API BNSharedCacheController* BNNewSharedCacheControllerReference(BNSharedCacheController* controller); @@ -166,6 +187,22 @@ extern "C" SHAREDCACHE_FFI_API void BNSharedCacheFreeEntry(BNSharedCacheEntry entry); SHAREDCACHE_FFI_API void BNSharedCacheFreeEntryList(BNSharedCacheEntry* entries, size_t count); + SHAREDCACHE_FFI_API BNSharedCacheStringScanner* BNSharedCacheControllerCreateStringScanner( + BNSharedCacheController* controller); + SHAREDCACHE_FFI_API void BNFreeSharedCacheStringScanner(BNSharedCacheStringScanner* scanner); + SHAREDCACHE_FFI_API bool BNSharedCacheStringScannerStart(BNSharedCacheStringScanner* scanner); + SHAREDCACHE_FFI_API bool BNSharedCacheStringScannerIsComplete(BNSharedCacheStringScanner* scanner); + SHAREDCACHE_FFI_API void BNSharedCacheStringScannerGetProgress( + BNSharedCacheStringScanner* scanner, uint64_t* current, uint64_t* total); + + SHAREDCACHE_FFI_API uint64_t BNSharedCacheStringScannerGetStringCount(BNSharedCacheStringScanner* scanner); + // Removes and returns up to maxCount of the queued scan results. + SHAREDCACHE_FFI_API BNSharedCacheString* BNSharedCacheStringScannerTakeStrings( + BNSharedCacheStringScanner* scanner, uint64_t maxCount, size_t* count); + + SHAREDCACHE_FFI_API void BNSharedCacheFreeString(BNSharedCacheString string); + SHAREDCACHE_FFI_API void BNSharedCacheFreeStringList(BNSharedCacheString* strings, size_t count); + #ifdef __cplusplus } diff --git a/view/sharedcache/core/CacheStringScanner.cpp b/view/sharedcache/core/CacheStringScanner.cpp new file mode 100644 index 0000000000..1d07c585b8 --- /dev/null +++ b/view/sharedcache/core/CacheStringScanner.cpp @@ -0,0 +1,209 @@ +#include "CacheStringScanner.h" + +#include "binaryninjaapi.h" + +using namespace BinaryNinja; +using namespace BinaryNinja::DSC; + +namespace { + +constexpr size_t kChunkSize = 1024 * 1024; + +// Builds the UTF-8 display text for a string found at `data`, truncated to +// `CacheStringScanner::kMaxDisplayTextLength` bytes of output. +std::string DisplayTextForString(const uint8_t* data, const BNStringReference& ref) +{ + constexpr size_t maxLength = CacheStringScanner::kMaxDisplayTextLength; + switch (ref.type) + { + case AsciiString: + case Utf8String: + return std::string(reinterpret_cast(data), std::min(ref.length, maxLength)); + case Utf16String: + { + char* converted = BNUnicodeUTF16ToUTF8(data, std::min(ref.length, maxLength * 2)); + std::string text(converted); + BNFreeString(converted); + if (text.size() > maxLength) + text.resize(maxLength); + return text; + } + case Utf32String: + { + std::string text; + for (size_t offset = 0; offset + 4 <= ref.length && text.size() < maxLength; offset += 4) + { + char* converted = BNUnicodeUTF32ToUTF8(data + offset); + text += converted; + BNFreeString(converted); + } + if (text.size() > maxLength) + text.resize(maxLength); + return text; + } + } + return {}; +} + +} // namespace + +CacheStringScanner::CacheStringScanner(SharedCache& cache, std::regex regionFilter, Ref logger) + : m_cache(cache), m_regionFilter(std::move(regionFilter)), m_logger(std::move(logger)) +{} + +CacheStringScanner::~CacheStringScanner() +{ + // Tell any outstanding scan jobs to bail out. We cannot block waiting for them to + // drain as it would risk a deadlock during exit. + if (m_state) + m_state->abort = true; +} + +bool CacheStringScanner::Start() +{ + if (m_started.exchange(true)) + return false; + + auto state = std::make_shared(); + state->vm = m_cache.GetVirtualMemory(); + state->logger = m_logger; + state->detector.emplace(StringDetectionParameters::FromSettings(Settings::Instance())); + + uint64_t totalBytes = 0; + for (const auto& [range, region] : m_cache.GetRegions()) + { + // Stub islands hold only trampoline code, and the filtered regions (LINKEDIT by default) + // hold symbol tables whose name strings are already presented by the symbols UI. + if (region.type == CacheRegionType::StubIsland) + continue; + if (std::regex_match(region.name, m_regionFilter)) + continue; + state->regions.push_back(region); + totalBytes += region.size; + } + state->bytesTotal = totalBytes; + m_state = state; + + if (state->regions.empty()) + { + state->complete = true; + return true; + } + + { + std::lock_guard lock(state->completionMutex); + state->remainingRegions = state->regions.size(); + } + for (size_t i = 0; i < state->regions.size(); i++) + { + // Scanning starts only when the user opens the Strings tab and is watching it fill, so it + // runs at priority above the normal-priority background analysis of off-screen code. + WorkerPriorityEnqueue([state, i] { + ScanRegion(*state, state->regions[i]); + FinishRegion(*state); + }, "Scanning shared cache strings"); + } + return true; +} + +void CacheStringScanner::FinishRegion(ScanState& state) +{ + std::lock_guard lock(state.completionMutex); + if (--state.remainingRegions == 0) + state.complete = true; +} + +void CacheStringScanner::GetProgress(uint64_t& current, uint64_t& total) const +{ + if (!m_state) + { + current = 0; + total = 0; + return; + } + current = m_state->bytesScanned.load(); + total = m_state->bytesTotal.load(); +} + +size_t CacheStringScanner::GetStringCount() const +{ + if (!m_state) + return 0; + std::lock_guard lock(m_state->resultsMutex); + return m_state->producedCount; +} + +std::vector CacheStringScanner::TakeStrings(size_t maxCount) +{ + if (!m_state) + return {}; + auto& strings = m_state->strings; + std::lock_guard lock(m_state->resultsMutex); + const size_t count = std::min(maxCount, strings.size()); + const size_t first = strings.size() - count; + std::vector result( + std::make_move_iterator(strings.begin() + first), std::make_move_iterator(strings.end())); + strings.erase(strings.begin() + first, strings.end()); + + // Shrink the buffer if it both proportionally and substantially larger than the live data. + static constexpr size_t kMinReclaimableSlack = 1 << 20; + if (strings.capacity() > 2 * strings.size() + kMinReclaimableSlack) + strings.shrink_to_fit(); + + return result; +} + +void CacheStringScanner::ScanRegion(ScanState& state, const CacheRegion& region) +{ + const uint64_t imageStart = region.imageStart.value_or(0); + const uint64_t end = region.start + region.size; + + BNStringReference lastFound {}; + for (uint64_t cur = region.start; cur < end; ) + { + if (state.abort) + return; + + const size_t blockLen = static_cast(std::min(kChunkSize, end - cur)); + const size_t dataLen = static_cast(std::min(blockLen + BN_MAX_STRING_LENGTH, end - cur)); + + std::span data; + try + { + data = state.vm->ReadSpan(cur, dataLen); + } + catch (std::exception& e) + { + // This happens if we have not mapped in all the relevant entries. + state.logger->LogErrorF("Failed to read region {:#x} while scanning for strings: {}", region.start, e.what()); + state.bytesScanned += end - cur; + return; + } + + const auto refs = state.detector->DetectStrings(data.data(), dataLen, blockLen, cur, &lastFound); + + std::vector batch; + batch.reserve(refs.size()); + for (const auto& ref : refs) + { + CacheString str; + str.type = ref.type; + str.address = ref.start; + str.rawLength = ref.length; + str.text = DisplayTextForString(data.data() + (ref.start - cur), ref); + str.regionStart = region.start; + str.imageStart = imageStart; + batch.push_back(std::move(str)); + } + + { + std::lock_guard lock(state.resultsMutex); + state.producedCount += batch.size(); + state.strings.insert(state.strings.end(), std::make_move_iterator(batch.begin()), + std::make_move_iterator(batch.end())); + } + + state.bytesScanned += blockLen; + cur += blockLen; + } +} diff --git a/view/sharedcache/core/CacheStringScanner.h b/view/sharedcache/core/CacheStringScanner.h new file mode 100644 index 0000000000..2945497a47 --- /dev/null +++ b/view/sharedcache/core/CacheStringScanner.h @@ -0,0 +1,103 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "binaryninjaapi.h" +#include "SharedCache.h" + +namespace BinaryNinja::DSC { + + // A string found in the cache by `CacheStringScanner`. + struct CacheString + { + BNStringType type; + uint64_t address; + // Length of the string in the cache, in bytes. + size_t rawLength; + // UTF-8 display text, truncated to `CacheStringScanner::kMaxDisplayTextLength` bytes. + std::string text; + // Start address of the region containing the string. + uint64_t regionStart; + // Header address of the image owning the region, or 0 if the region is not part of an image. + uint64_t imageStart; + }; + + // Scans all mapped cache regions for strings using the same string detection as the core + // strings analysis. Each region is scanned as a separate worker pool job. Results queue in job + // completion order. Consumers drain them with `TakeStrings(maxCount)`, so the scanner only + // holds strings that have not yet been handed off. Sort once the scan completes. + class CacheStringScanner + { + public: + static constexpr size_t kMaxDisplayTextLength = 256; + + CacheStringScanner(SharedCache& cache, std::regex regionFilter, Ref logger); + ~CacheStringScanner(); + + CacheStringScanner(const CacheStringScanner&) = delete; + CacheStringScanner& operator=(const CacheStringScanner&) = delete; + + // Enqueues a scan job for each cache region. Idempotent. + // Returns false if the scan was already started. + bool Start(); + + bool IsScanStarted() const { return m_started.load(); } + bool IsScanComplete() const { return m_state && m_state->complete.load(); } + + // Progress in bytes scanned out of total bytes scheduled for scanning. + void GetProgress(uint64_t& current, uint64_t& total) const; + + // Total number of strings detected so far, including those already taken. + size_t GetStringCount() const; + + // Removes and returns up to `maxCount` of the queued strings. Result order is unspecified. + std::vector TakeStrings(size_t maxCount); + + private: + // Everything the scan jobs read and write, owned jointly by the scanner and every queued + // job so that the scanner itself can safely be destroyed while jobs are still queued or + // running. + struct ScanState + { + std::shared_ptr vm; + // The regions to scan, copied out of the cache so the jobs do not reference it. + std::vector regions; + Ref logger; + std::optional detector; + + std::atomic complete = false; + std::atomic abort = false; + + std::mutex completionMutex; + size_t remainingRegions = 0; + + std::atomic bytesScanned = 0; + std::atomic bytesTotal = 0; + + std::mutex resultsMutex; + // Detected strings not yet taken. TakeStrings drains from the back, which is O(1) per + // element. Order does not matter as the consumer sorts the full set once scanning + // completes. + std::vector strings; + // Total detected, including taken strings. Not reduced by TakeStrings. + uint64_t producedCount = 0; + }; + + static void ScanRegion(ScanState& state, const CacheRegion& region); + static void FinishRegion(ScanState& state); + + // Used only by `Start`, which copies the scan inputs into `ScanState`. + SharedCache& m_cache; + std::regex m_regionFilter; + Ref m_logger; + + std::atomic m_started = false; + std::shared_ptr m_state; + }; + +} // namespace BinaryNinja::DSC diff --git a/view/sharedcache/core/SharedCacheController.cpp b/view/sharedcache/core/SharedCacheController.cpp index 8ed011bd39..8f543ff85f 100644 --- a/view/sharedcache/core/SharedCacheController.cpp +++ b/view/sharedcache/core/SharedCacheController.cpp @@ -316,3 +316,8 @@ void SharedCacheController::ProcessObjCForLoadedImages(BinaryView& view) } } } + +std::unique_ptr SharedCacheController::CreateStringScanner() +{ + return std::make_unique(m_cache, m_regionFilter, m_logger); +} diff --git a/view/sharedcache/core/SharedCacheController.h b/view/sharedcache/core/SharedCacheController.h index c468bee5ec..07c5ce7887 100644 --- a/view/sharedcache/core/SharedCacheController.h +++ b/view/sharedcache/core/SharedCacheController.h @@ -1,8 +1,10 @@ #pragma once +#include #include #include +#include "CacheStringScanner.h" #include "SharedCache.h" #include "refcountobject.h" #include "ffi_global.h" @@ -66,5 +68,7 @@ namespace BinaryNinja::DSC { // Re-run the ObjC processor for loaded images to restore Objective-C metadata. void ProcessObjCForLoadedImages(BinaryView& view); + + std::unique_ptr CreateStringScanner(); }; } // namespace BinaryNinja::DSC diff --git a/view/sharedcache/core/ffi.cpp b/view/sharedcache/core/ffi.cpp index 7ad10f9bb6..b916972d3a 100644 --- a/view/sharedcache/core/ffi.cpp +++ b/view/sharedcache/core/ffi.cpp @@ -4,6 +4,12 @@ using namespace BinaryNinja; using namespace BinaryNinja::DSC; +struct BNSharedCacheStringScanner +{ + DSCRef controller; + std::unique_ptr scanner; +}; + BNSharedCacheImage ImageToApi(const CacheImage& image) { BNSharedCacheImage apiImage; @@ -105,6 +111,18 @@ CacheSymbol SymbolFromApi(const BNSharedCacheSymbol& apiSymbol) return symbol; } +BNSharedCacheString StringToApi(const CacheString& string) +{ + BNSharedCacheString apiString; + apiString.stringType = string.type; + apiString.address = string.address; + apiString.rawLength = string.rawLength; + apiString.text = BNAllocStringWithLength(string.text.c_str(), string.text.size()); + apiString.regionStart = string.regionStart; + apiString.imageStart = string.imageStart; + return apiString; +} + BNSharedCacheEntryType EntryTypeToApi(const CacheEntryType& entryType) { switch (entryType) @@ -431,4 +449,57 @@ extern "C" BNSharedCacheFreeEntry(entries[i]); delete[] entries; } + + BNSharedCacheStringScanner* BNSharedCacheControllerCreateStringScanner(BNSharedCacheController* controller) + { + return new BNSharedCacheStringScanner {controller->object, controller->object->CreateStringScanner()}; + } + + void BNFreeSharedCacheStringScanner(BNSharedCacheStringScanner* scanner) + { + delete scanner; + } + + bool BNSharedCacheStringScannerStart(BNSharedCacheStringScanner* scanner) + { + return scanner->scanner->Start(); + } + + bool BNSharedCacheStringScannerIsComplete(BNSharedCacheStringScanner* scanner) + { + return scanner->scanner->IsScanComplete(); + } + + void BNSharedCacheStringScannerGetProgress(BNSharedCacheStringScanner* scanner, uint64_t* current, uint64_t* total) + { + scanner->scanner->GetProgress(*current, *total); + } + + uint64_t BNSharedCacheStringScannerGetStringCount(BNSharedCacheStringScanner* scanner) + { + return scanner->scanner->GetStringCount(); + } + + BNSharedCacheString* BNSharedCacheStringScannerTakeStrings( + BNSharedCacheStringScanner* scanner, uint64_t maxCount, size_t* count) + { + const auto strings = scanner->scanner->TakeStrings(maxCount); + *count = strings.size(); + BNSharedCacheString* apiStrings = new BNSharedCacheString[*count]; + for (size_t i = 0; i < *count; i++) + apiStrings[i] = StringToApi(strings[i]); + return apiStrings; + } + + void BNSharedCacheFreeString(BNSharedCacheString string) + { + BNFreeString(string.text); + } + + void BNSharedCacheFreeStringList(BNSharedCacheString* strings, size_t count) + { + for (size_t i = 0; i < count; i++) + BNSharedCacheFreeString(strings[i]); + delete[] strings; + } }; diff --git a/view/sharedcache/ui/CMakeLists.txt b/view/sharedcache/ui/CMakeLists.txt index a0450722a6..96c24db95b 100644 --- a/view/sharedcache/ui/CMakeLists.txt +++ b/view/sharedcache/ui/CMakeLists.txt @@ -4,7 +4,7 @@ project(sharedcacheui) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) -find_package(Qt6 COMPONENTS Core Gui Widgets REQUIRED) +find_package(Qt6 COMPONENTS Core Concurrent Gui Widgets REQUIRED) file(GLOB SOURCES CONFIGURE_DEPENDS *.cpp *.h) list(FILTER SOURCES EXCLUDE REGEX moc_.*) @@ -93,6 +93,6 @@ get_recursive_include_dirs(sharedcacheapi INCLUDES) target_include_directories(sharedcacheui PRIVATE ${INCLUDES}) -target_link_libraries(sharedcacheui sharedcacheapi sharedcache binaryninjaui Qt6::Core Qt6::Gui Qt6::Widgets) +target_link_libraries(sharedcacheui sharedcacheapi sharedcache binaryninjaui Qt6::Core Qt6::Concurrent Qt6::Gui Qt6::Widgets) diff --git a/view/sharedcache/ui/addresstext.h b/view/sharedcache/ui/addresstext.h new file mode 100644 index 0000000000..2b339603b8 --- /dev/null +++ b/view/sharedcache/ui/addresstext.h @@ -0,0 +1,14 @@ +#pragma once + +#include +#include + +// Renders an address the way the triage tables' Address columns display it: lowercase hex, +// zero-padded to `width` digits. +inline std::string AddressText(uint64_t address, uint32_t width) +{ + std::string text(width, '0'); + for (size_t i = text.size(); address != 0 && i > 0; address >>= 4) + text[--i] = "0123456789abcdef"[address & 0xf]; + return text; +} diff --git a/view/sharedcache/ui/dsctriage.cpp b/view/sharedcache/ui/dsctriage.cpp index 1e7ca336a8..c61b3d7439 100644 --- a/view/sharedcache/ui/dsctriage.cpp +++ b/view/sharedcache/ui/dsctriage.cpp @@ -1,8 +1,13 @@ +#include #include +#include #include +#include +#include #include #include "dsctriage.h" #include "globalarea.h" +#include "stringstable.h" #include "symboltable.h" #include "ui/fontsettings.h" @@ -52,8 +57,16 @@ DSCTriageView::DSCTriageView(QWidget* parent, BinaryViewRef data) : QWidget(pare QWidget* defaultWidget = initImageTable(); initSymbolTable(); + initStringsTab(); initCacheInfoTables(); + // The string scan and symbol fetch are expensive, so each panel starts its load only once its + // tab is first shown, and clears its large result set after the tab leaves the screen. + connect(m_triageTabs, &SplitTabWidget::currentChanged, this, [this](QWidget* widget) { + m_stringsPanel->setCurrentTabWidget(widget); + m_symbolsPanel->setCurrentTabWidget(widget); + }); + m_layout = new QVBoxLayout(this); m_layout->addWidget(m_triageTabs); setLayout(m_layout); @@ -73,7 +86,8 @@ DSCTriageView::~DSCTriageView() } -void DSCTriageView::loadImagesWithAddr(const std::vector& addresses, bool includeDependencies) { +void DSCTriageView::loadImagesWithAddr(const std::vector& addresses, bool includeDependencies, + std::optional navigateTo) { auto controller = SharedCacheController::GetController(*m_data); if (!controller) return; @@ -119,21 +133,24 @@ void DSCTriageView::loadImagesWithAddr(const std::vector& addresses, b // Apply the images in a future than update the triage view and run analysis. QPointer> watcher = new QFutureWatcher(this); - connect(watcher, &QFutureWatcher::finished, this, [watcher, this]() { + connect(watcher, &QFutureWatcher::finished, this, [watcher, navigateTo, this]() { if (watcher) { auto loadedImages = watcher->result(); - if (loadedImages.empty()) - return; - - // Update the triage to display the images as loaded. - for (const auto& image : loadedImages) - setImageLoaded(image.headerAddress); + if (!loadedImages.empty()) + { + // Update the triage to display the images as loaded. + for (const auto& image : loadedImages) + setImageLoaded(image.headerAddress); + + // Run analysis. + this->m_data->AddAnalysisOption("linearsweep"); + this->m_data->AddAnalysisOption("pointersweep"); + this->m_data->UpdateAnalysis(); + } - // Run analysis. - this->m_data->AddAnalysisOption("linearsweep"); - this->m_data->AddAnalysisOption("pointersweep"); - this->m_data->UpdateAnalysis(); + if (navigateTo) + navigateToAddress(*navigateTo); } }); QFuture future = QtConcurrent::run([this, controller, images, imageLoadTask]() { @@ -270,6 +287,9 @@ QWidget* DSCTriageView::initImageTable() connect(loadImageFilterEdit, &FilterEdit::textChanged, [this, loadImageFilterEdit](const QString& filter) { m_imageTable->setFilter(filter.toStdString(), loadImageFilterEdit->getFilterOptions()); }); + connect(loadImageFilterEdit, &FilterEdit::optionsChanged, [this, loadImageFilterEdit](FilterOptions options) { + m_imageTable->setFilter(loadImageFilterEdit->text().toStdString(), options); + }); connect(m_imageTable, &FilterableTableView::activated, this, [=, this](const QModelIndex& index) { auto addr = m_imageModel->item(index.row(), 0)->text().toULongLong(nullptr, 16); @@ -315,81 +335,298 @@ QWidget* DSCTriageView::initImageTable() void DSCTriageView::initSymbolTable() { m_symbolTable = new SymbolTableView(this); - - // Apply custom column styling - m_symbolTable->setItemDelegateForColumn(0, new AddressColorDelegate(m_symbolTable)); - - auto symbolFilterEdit = new FilterEdit(m_symbolTable); - symbolFilterEdit->setPlaceholderText("Filter symbols"); - connect(symbolFilterEdit, &FilterEdit::textChanged, [this, symbolFilterEdit](const QString& filter) { - m_symbolTable->setFilter(filter.toStdString(), symbolFilterEdit->getFilterOptions()); + m_symbolsPanel = new TriageTablePanel(this, m_symbolTable, "Filter symbols", "symbols"); + m_symbolsPanel->setLoader([this] { return startSymbolLoad(); }); + m_symbolsPanel->setClearHandler([this] { + // Discard an in-flight symbol fetch rather than letting its results repopulate the + // cleared table. + if (m_symbolsWatcher) + { + m_symbolsWatcher->disconnect(); + m_symbolsWatcher->deleteLater(); + } }); - auto loadSymbolImageButton = new QPushButton(); + m_symbolsPanel->addFilterToggle(":/icons/images/folder.png", "Match Image Names", + [this](bool checked) { m_symbolTable->symbolsModel()->setMatchImageNames(checked); }); + + auto loadSymbolImageButton = m_symbolsPanel->addSelectionButton("Load Image"); connect(loadSymbolImageButton, &QPushButton::clicked, [this](bool) { auto selected = m_symbolTable->selectionModel()->selectedRows(); std::vector addresses; for (const auto& row : selected) - addresses.push_back(row.data().toString().toULongLong(nullptr, 16)); + addresses.push_back(m_symbolTable->getSymbolAtRow(row.row()).address); loadImagesWithAddr(addresses); }); - loadSymbolImageButton->setText("Load Image"); - - // Shows the current selected rows image name. - auto currentImageLabel = new QLabel(this); - currentImageLabel->setText(""); - currentImageLabel->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); - connect(m_symbolTable->selectionModel(), &QItemSelectionModel::currentRowChanged, this, [this, currentImageLabel](const QModelIndex ¤t, const QModelIndex &) { - auto symbol = m_symbolTable->getSymbolAtRow(current.row()); + + connect(m_symbolTable, &SymbolTableView::activated, this, [this](const QModelIndex& index){ + auto symbol = m_symbolTable->getSymbolAtRow(index.row()); + auto controller = SharedCacheController::GetController(*this->m_data); if (!controller) return; + auto image = controller->GetImageContaining(symbol.address); - if (image) - currentImageLabel->setText("Image: " + QString::fromStdString(image->name)); - else - currentImageLabel->setText(""); + if (!image.has_value()) + return; + + if (controller->IsImageLoaded(*image)) + { + navigateToAddress(symbol.address); + return; + } + + promptToLoadImage(image->name, image->headerAddress, symbol.address); }); - auto symbolFooterLayout = new QHBoxLayout; - symbolFooterLayout->addWidget(loadSymbolImageButton); - symbolFooterLayout->addWidget(currentImageLabel); - symbolFooterLayout->setAlignment(Qt::AlignLeft); + m_triageTabs->addTab(m_symbolsPanel, "Symbols"); + m_triageTabs->setCanCloseTab(m_symbolsPanel, false); +} - auto symbolLayout = new QVBoxLayout; - symbolLayout->addWidget(symbolFilterEdit); - symbolLayout->addWidget(m_symbolTable); - symbolLayout->addLayout(symbolFooterLayout); - auto symbolWidget = new QWidget; - symbolWidget->setLayout(symbolLayout); +bool DSCTriageView::startSymbolLoad() +{ + // The controller is not available until view init has finished. Retry on the next activation. + auto controller = SharedCacheController::GetController(*m_data); + if (!controller) + return false; + + m_symbolTable->setNameSources(*controller); + m_symbolsPanel->statusLabel()->setText("Loading…"); + + typedef std::vector SymbolList; + QPointer> watcher = new QFutureWatcher(this); + m_symbolsWatcher = watcher; + connect(watcher, &QFutureWatcher::finished, this, [watcher, this]() { + if (!watcher) + return; + m_symbolTable->symbolsModel()->appendRows(watcher->result()); + m_symbolsPanel->finishLoad(); + watcher->deleteLater(); + }); + QFuture future = QtConcurrent::run([controller]() { return controller->GetSymbols(); }); + watcher->setFuture(future); + connect(this, &QObject::destroyed, this, [watcher]() { + if (watcher && watcher->isRunning()) { + watcher->cancel(); + watcher->waitForFinished(); + } + }); + return true; +} + + +void DSCTriageView::promptToLoadImage(const std::string& imageName, uint64_t address, uint64_t navigateTo) +{ + auto dialog = new QMessageBox(this); + dialog->setText("Load " + QString::fromStdString(imageName) + "?"); + auto loadButton = dialog->addButton("Load Image", QMessageBox::AcceptRole); + dialog->addButton(QMessageBox::Cancel); + dialog->setDefaultButton(loadButton); + + connect(dialog, &QMessageBox::buttonClicked, this, [=, this](QAbstractButton* button) + { + if (button == loadButton) + loadImagesWithAddr({address}, false, navigateTo); + }); + + dialog->exec(); +} - connect(m_symbolTable, &SymbolTableView::activated, this, [=, this](const QModelIndex& index){ - auto symbol = m_symbolTable->getSymbolAtRow(index.row()); - auto dialog = new QMessageBox(this); +void DSCTriageView::initStringsTab() +{ + m_stringsTable = new StringsTableView(this); + m_stringsPanel = new TriageTablePanel(this, m_stringsTable, "Filter strings", "strings"); + m_stringsPanel->setLoader([this] { return startStringScan(); }); + m_stringsPanel->setClearHandler([this] { + m_stringsPollTimer->stop(); + m_stringScanner.reset(); + }); + // Strings hidden by the visibility toggles are not part of the searched population, so they + // do not appear in the count unless a text filter narrows it. + m_stringsPanel->setBaselineCount( + [this] { return m_stringsTable->stringsModel()->baselineStringCount(); }); + + m_stringsPanel->addFilterToggle(":/icons/images/folder.png", "Match Image Names", + [this](bool checked) { m_stringsTable->stringsModel()->setMatchImageNames(checked); }); + // Strings in regions that belong to no image (dyld data and other non-image regions) are + // rarely of interest, so they are hidden unless this is toggled on. + m_stringsPanel->addFilterToggle(":/icons/images/stack.png", "Show Non-Image Strings", + [this](bool checked) { m_stringsTable->stringsModel()->setShowNonImageStrings(checked); }); + + auto loadStringImageButton = m_stringsPanel->addSelectionButton("Load Image"); + connect(loadStringImageButton, &QPushButton::clicked, [this](bool) { + auto selected = m_stringsTable->selectionModel()->selectedRows(); + std::vector imageAddresses; + for (const auto& row : selected) + { + const auto string = m_stringsTable->getStringAtRow(row.row()); + if (string.imageStart) + imageAddresses.push_back(string.address); + else + loadStringRegion(string, std::nullopt); + } + loadImagesWithAddr(imageAddresses); + }); + + connect(m_stringsTable, &StringsTableView::activated, this, [this](const QModelIndex& index) { + const auto string = m_stringsTable->getStringAtRow(index.row()); auto controller = SharedCacheController::GetController(*this->m_data); if (!controller) return; - auto image = controller->GetImageContaining(symbol.address); - if (!image.has_value()) + // Strings outside any image (e.g. the coalesced selector pool) load just their region. + if (!string.imageStart) + { + loadStringRegion(string, string.address); return; + } - dialog->setText("Load " + QString::fromStdString(image->name) + "?"); - dialog->setStandardButtons(QMessageBox::Yes | QMessageBox::No); + auto image = controller->GetImageAt(*string.imageStart); + if (!image.has_value()) + return; - connect(dialog, &QMessageBox::buttonClicked, this, [=, this](QAbstractButton* button) + if (controller->IsImageLoaded(*image)) { - if (button == dialog->button(QMessageBox::Yes)) - loadImagesWithAddr({image->headerAddress}); - }); + navigateToAddress(string.address); + return; + } - dialog->exec(); + promptToLoadImage(image->name, string.address, string.address); }); - m_triageTabs->addTab(symbolWidget, "Symbols"); - m_triageTabs->setCanCloseTab(symbolWidget, false); + m_stringsPollTimer = new QTimer(this); + m_stringsPollTimer->setInterval(250); + connect(m_stringsPollTimer, &QTimer::timeout, this, &DSCTriageView::pollStringScan); + + m_triageTabs->addTab(m_stringsPanel, "Strings"); + m_triageTabs->setCanCloseTab(m_stringsPanel, false); +} + + +void DSCTriageView::showEvent(QShowEvent* event) +{ + QWidget::showEvent(event); + m_stringsPanel->setViewVisible(true); + m_symbolsPanel->setViewVisible(true); +} + + +void DSCTriageView::hideEvent(QHideEvent* event) +{ + QWidget::hideEvent(event); + m_stringsPanel->setViewVisible(false); + m_symbolsPanel->setViewVisible(false); +} + + +bool DSCTriageView::startStringScan() +{ + // The controller is not available until view init has finished. Retry on the next activation. + auto controller = SharedCacheController::GetController(*m_data); + if (!controller) + return false; + + m_stringsTable->setNameSources(*controller); + m_stringScanner = controller->CreateStringScanner(); + m_stringScanner->Start(); + m_stringsPanel->statusLabel()->setText("Scanning…"); + m_stringsPollTimer->start(); + return true; +} + + +void DSCTriageView::pollStringScan() +{ + if (!m_stringScanner) + return; + + auto model = m_stringsTable->stringsModel(); + constexpr uint64_t maxBatchSize = 250000; + constexpr qint64 maxIngestMilliseconds = 150; + // Ingest batches until the time budget is spent, then yield so the UI stays responsive. + QElapsedTimer ingestTimer; + ingestTimer.start(); + do + { + auto batch = m_stringScanner->TakeStrings(maxBatchSize); + if (batch.empty()) + break; + model->appendStrings(std::move(batch)); + } while (ingestTimer.elapsed() < maxIngestMilliseconds); + + const bool scanComplete = m_stringScanner->IsComplete(); + const QLocale locale; + if (scanComplete && model->totalRowCount() == m_stringScanner->GetStringCount()) + { + m_stringsPollTimer->stop(); + m_stringScanner.reset(); + m_stringsPanel->finishLoad(); + } + else if (scanComplete) + { + // The scan has finished but the table is still ingesting batched results. + const QString totalStrings = locale.toString(static_cast(m_stringScanner->GetStringCount())); + const QString fetchedStrings = + locale.toString(static_cast(model->totalRowCount())).rightJustified(totalStrings.size()); + m_stringsPanel->statusLabel()->setText(QString("Loading… %1 / %2 strings").arg(fetchedStrings, totalStrings)); + } + else + { + const auto [current, total] = m_stringScanner->GetProgress(); + const QString totalMB = locale.toString(static_cast(total / (1024 * 1024))); + // Pad the growing values so the label width stays stable while scanning. + const QString currentMB = + locale.toString(static_cast(current / (1024 * 1024))).rightJustified(totalMB.size()); + const QString stringCount = + locale.toString(static_cast(model->totalRowCount())).rightJustified(10); + m_stringsPanel->statusLabel()->setText( + QString("Scanning… %1 / %2 MB — %3 strings").arg(currentMB, totalMB, stringCount)); + } +} + + +void DSCTriageView::loadStringRegion(const CacheString& string, std::optional navigateTo) +{ + auto controller = SharedCacheController::GetController(*m_data); + if (!controller) + return; + + auto region = controller->GetRegionAt(string.regionStart); + if (!region.has_value()) + return; + + QPointer> watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcher::finished, this, [watcher, navigateTo, this]() { + if (!watcher) + return; + if (watcher->result()) + m_data->UpdateAnalysis(); + if (navigateTo) + navigateToAddress(*navigateTo); + }); + QFuture future = QtConcurrent::run([this, controller, region]() { + return controller->ApplyRegion(*this->m_data, *region); + }); + watcher->setFuture(future); + connect(this, &QObject::destroyed, this, [watcher]() { + if (watcher && watcher->isRunning()) { + watcher->cancel(); + watcher->waitForFinished(); + } + }); +} + + +void DSCTriageView::navigateToAddress(uint64_t address) +{ + ViewFrame* frame = ViewFrame::viewFrameForWidget(this); + if (!frame) + return; + // Navigating the frame's current view would hit DSCTriageView::navigate, which is not + // navigable. Switch to the linear view of the cache instead. + frame->navigate("Linear:" + frame->getCurrentDataType(), address, true, true); } @@ -457,6 +694,9 @@ void DSCTriageView::initCacheInfoTables() connect(mappingFilterEdit, &FilterEdit::textChanged, [this, mappingFilterEdit](const QString& filter) { m_mappingTable->setFilter(filter.toStdString(), mappingFilterEdit->getFilterOptions()); }); + connect(mappingFilterEdit, &FilterEdit::optionsChanged, [this, mappingFilterEdit](FilterOptions options) { + m_mappingTable->setFilter(mappingFilterEdit->text().toStdString(), options); + }); auto mappingHeaderLayout = new QHBoxLayout; mappingHeaderLayout->addWidget(mappingLabel); @@ -470,6 +710,9 @@ void DSCTriageView::initCacheInfoTables() connect(regionFilterEdit, &FilterEdit::textChanged, [this, regionFilterEdit](const QString& filter) { m_regionTable->setFilter(filter.toStdString(), regionFilterEdit->getFilterOptions()); }); + connect(regionFilterEdit, &FilterEdit::optionsChanged, [this, regionFilterEdit](FilterOptions options) { + m_regionTable->setFilter(regionFilterEdit->text().toStdString(), options); + }); auto regionHeaderLayout = new QHBoxLayout; regionHeaderLayout->addWidget(regionLabel); @@ -593,6 +836,8 @@ void DSCTriageView::RefreshData() // TODO: This should use `QSortFilterProxyModel`, but that's a bigger change. m_mappingTable->setSortingEnabled(true); - - m_symbolTable->populateSymbols(*m_data); + // Symbols and strings are loaded lazily when their tabs are shown. + // Discard any loaded content so the reload picks up the refreshed cache information. + m_symbolsPanel->resetContent(); + m_stringsPanel->resetContent(); } diff --git a/view/sharedcache/ui/dsctriage.h b/view/sharedcache/ui/dsctriage.h index 03eea6be1c..8f9bd08006 100644 --- a/view/sharedcache/ui/dsctriage.h +++ b/view/sharedcache/ui/dsctriage.h @@ -1,6 +1,8 @@ +#include #include #include #include +#include #include #include #include @@ -8,7 +10,9 @@ #include #include #include +#include #include "filter.h" +#include "stringstable.h" #include "symboltable.h" #include "ui/fontsettings.h" #include "uicontext.h" @@ -191,6 +195,13 @@ class DSCTriageView : public QWidget, public View, public UIContextNotification QStandardItemModel* m_imageModel; SymbolTableView* m_symbolTable; + TriageTablePanel* m_symbolsPanel; + QPointer>> m_symbolsWatcher; + + StringsTableView* m_stringsTable; + TriageTablePanel* m_stringsPanel; + QTimer* m_stringsPollTimer; + std::unique_ptr m_stringScanner; FilterableTableView* m_regionTable; @@ -212,11 +223,23 @@ class DSCTriageView : public QWidget, public View, public UIContextNotification void OnAfterOpenFile(UIContext* context, FileContext* file, ViewFrame* frame) override; void RefreshData(); +protected: + void showEvent(QShowEvent* event) override; + void hideEvent(QHideEvent* event) override; + private: - void loadImagesWithAddr(const std::vector& addresses, bool includeDependencies = false); + void loadImagesWithAddr(const std::vector& addresses, bool includeDependencies = false, + std::optional navigateTo = std::nullopt); void setImageLoaded(uint64_t imageHeaderAddr); + void navigateToAddress(uint64_t address); QWidget* initImageTable(); void initSymbolTable(); + bool startSymbolLoad(); + void initStringsTab(); + bool startStringScan(); + void pollStringScan(); + void promptToLoadImage(const std::string& imageName, uint64_t address, uint64_t navigateTo); + void loadStringRegion(const SharedCacheAPI::CacheString& string, std::optional navigateTo); void initCacheInfoTables(); }; diff --git a/view/sharedcache/ui/stringstable.cpp b/view/sharedcache/ui/stringstable.cpp new file mode 100644 index 0000000000..767f061090 --- /dev/null +++ b/view/sharedcache/ui/stringstable.cpp @@ -0,0 +1,333 @@ +#include "stringstable.h" + +#include +#include +#include + +#include "addresstext.h" +#include "theme.h" + +using namespace SharedCacheAPI; + +namespace { + +enum StringsTableColumn +{ + StringsTableAddressColumn, + StringsTableTypeColumn, + StringsTableLengthColumn, + StringsTableImageColumn, + StringsTableStringColumn, + StringsTableColumnCount, +}; + +QString StringTypeAsString(BNStringType type) +{ + switch (type) + { + case AsciiString: + return QStringLiteral("ASCII"); + case Utf8String: + return QStringLiteral("UTF-8"); + case Utf16String: + return QStringLiteral("UTF-16"); + case Utf32String: + return QStringLiteral("UTF-32"); + default: + return QStringLiteral("Unknown"); + } +} + +QString DisplayTextForString(const CacheString& string) +{ + QString text = QString::fromUtf8(string.text.c_str(), string.text.size()); + text.replace('\n', QStringLiteral("\\n")); + text.replace('\r', QStringLiteral("\\r")); + text.replace('\t', QStringLiteral("\\t")); + return text; +} + + +QString ImageNameForString(const ImageNameLookup::State& names, const CacheString& string) +{ + return ImageNameLookup::displayName(names, string.imageStart, string.regionStart); +} + +} // namespace + + +StringsTableModel::StringsTableModel(QWidget* parent) : TriageTableRowsModel(parent) +{ + // Establish the initial row visibility. Non-image strings are hidden by default. + applyFilter(); +} + + +int StringsTableModel::columnCount(const QModelIndex& parent) const +{ + Q_UNUSED(parent); + return StringsTableColumnCount; +} + + +QVariant StringsTableModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) + return QVariant(); + + switch (role) + { + case Qt::DisplayRole: + { + const auto& string = stringAt(index.row()); + + switch (index.column()) + { + case StringsTableAddressColumn: + return QString::fromStdString(AddressText(string.address, m_addressWidth)); + case StringsTableTypeColumn: + return StringTypeAsString(string.type); + case StringsTableLengthColumn: + return QString::number(string.rawLength); + case StringsTableImageColumn: + return ImageNameForString(*m_names.snapshot(), string); + case StringsTableStringColumn: + return DisplayTextForString(string); + default: + return QVariant(); + } + } + case Qt::ForegroundRole: + switch (index.column()) + { + case StringsTableAddressColumn: + return getThemeColor(AddressColor); + case StringsTableTypeColumn: + return getThemeColor(TypeNameColor); + case StringsTableLengthColumn: + return getThemeColor(NumberColor); + case StringsTableStringColumn: + return getThemeColor(StringColor); + default: + return QVariant(); + } + case Qt::ToolTipRole: + { + if (index.column() != StringsTableImageColumn) + return QVariant(); + const auto& string = stringAt(index.row()); + if (QString tooltip = m_names.tooltip(string.imageStart, string.regionStart); !tooltip.isEmpty()) + return tooltip; + return QVariant(); + } + case Qt::FontRole: + return m_font; + default: + return QVariant(); + } +} + + +QVariant StringsTableModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (role != Qt::DisplayRole || orientation != Qt::Horizontal) + return QVariant(); + + switch (section) + { + case StringsTableAddressColumn: + return QString("Address"); + case StringsTableTypeColumn: + return QString("Type"); + case StringsTableLengthColumn: + return QString("Length"); + case StringsTableImageColumn: + return QString("Image"); + case StringsTableStringColumn: + return QString("String"); + default: + return QVariant(); + } +} + + +void StringsTableModel::sort(int column, Qt::SortOrder order) +{ + Comparator comparator; + + switch (column) + { + case StringsTableAddressColumn: + comparator = [](const CacheString& a, const CacheString& b) { + return a.address < b.address; + }; + break; + case StringsTableTypeColumn: + comparator = [](const CacheString& a, const CacheString& b) { + return a.type < b.type; + }; + break; + case StringsTableLengthColumn: + comparator = [](const CacheString& a, const CacheString& b) { + return a.rawLength < b.rawLength; + }; + break; + case StringsTableImageColumn: + { + // Rank the known image and region names once so that row comparisons are integer-only + // instead of repeated string comparisons. + auto namesSnapshot = m_names.snapshot(); + struct NameEntry + { + const QString* name; + bool isImage; + uint64_t key; + }; + std::vector entries; + entries.reserve(namesSnapshot->imageNames.size() + namesSnapshot->regionNames.size()); + for (const auto& [address, name] : namesSnapshot->imageNames) + entries.push_back({&name, true, address}); + for (const auto& [start, name] : namesSnapshot->regionNames) + entries.push_back({&name, false, start}); + std::sort(entries.begin(), entries.end(), + [](const NameEntry& a, const NameEntry& b) { return *a.name < *b.name; }); + + struct NameRanks + { + std::unordered_map imageRanks; + std::unordered_map regionRanks; + }; + auto ranks = std::make_shared(); + int rank = -1; + const QString* previousName = nullptr; + for (const auto& entry : entries) + { + // Identical names share a rank, matching equality under name comparison. + if (!previousName || *entry.name != *previousName) + { + rank++; + previousName = entry.name; + } + (entry.isImage ? ranks->imageRanks : ranks->regionRanks)[entry.key] = rank; + } + + comparator = [ranks](const CacheString& a, const CacheString& b) { + // Mirrors the Image column's name resolution. Unknown names rank first like + // the empty string. + const auto rankOf = [&ranks](const CacheString& string) { + if (string.imageStart) + { + if (auto it = ranks->imageRanks.find(*string.imageStart); it != ranks->imageRanks.end()) + return it->second; + } + if (auto it = ranks->regionRanks.find(string.regionStart); it != ranks->regionRanks.end()) + return it->second; + return -1; + }; + return rankOf(a) < rankOf(b); + }; + break; + } + case StringsTableStringColumn: + comparator = [](const CacheString& a, const CacheString& b) { + return a.text < b.text; + }; + break; + default: + return; + } + + sortRows(std::move(comparator), order); +} + + +void StringsTableModel::setNameSources(const SharedCacheController& controller) +{ + m_names.build(controller); + m_addressWidth = BNGetAddressRenderedWidth(m_names.maxAddress()); +} + + +void StringsTableModel::appendStrings(std::vector strings) +{ + m_imageStringCount += std::ranges::count_if( + strings, [](const CacheString& string) { return string.imageStart.has_value(); }); + appendRows(std::move(strings)); +} + + +void StringsTableModel::clearRows() +{ + m_imageStringCount = 0; + TriageTableRowsModel::clearRows(); +} + + +void StringsTableModel::setShowNonImageStrings(bool show) +{ + if (m_showNonImageStrings == show) + return; + m_showNonImageStrings = show; + applyFilter(); +} + + +bool StringsTableModel::rowsEquivalent(const CacheString& a, const CacheString& b) const +{ + return a.address == b.address; +} + + +void StringsTableModel::applyFilter() +{ + if (m_filterText.empty() && m_showNonImageStrings) + { + m_rows.setFilter(nullptr); + return; + } + + const auto snapshot = filterSnapshot(); + const uint32_t addressWidth = m_addressWidth; + const auto names = m_names.snapshot(); + + m_rows.setFilterFactory( + [snapshot, addressWidth, names, showNonImageStrings = m_showNonImageStrings]() -> Predicate { + FilterParams params = MakeFilterParams(snapshot); + return [params = std::move(params), addressWidth, names, showNonImageStrings](const CacheString& string) { + if (!showNonImageStrings && !string.imageStart) + return false; + if (params.text.empty()) + return true; + QString imageName; + if (params.matchImageNames) + imageName = ImageNameForString(*names, string); + return MatchesText(params, string.text, string.address, addressWidth, imageName); + }; + }); +} + + +StringsTableView::StringsTableView(QWidget* parent) : TriageTableView(parent) +{ + m_model = new StringsTableModel(this); + setTriageModel(m_model, StringsTableAddressColumn); + applyDefaultColumnWidths(); +} + + +void StringsTableView::setNameSources(const SharedCacheController& controller) +{ + m_model->setNameSources(controller); + applyDefaultColumnWidths(); +} + + +void StringsTableView::applyDefaultColumnWidths() +{ + fitColumn(StringsTableAddressColumn, {QString(m_model->addressWidth(), QChar('0'))}); + fitColumn(StringsTableTypeColumn, + {StringTypeAsString(AsciiString), StringTypeAsString(Utf8String), StringTypeAsString(Utf16String), + StringTypeAsString(Utf32String)}); + fitColumn(StringsTableLengthColumn, {}); + fitColumn(StringsTableImageColumn, {m_model->names().widestImageName()}); +} diff --git a/view/sharedcache/ui/stringstable.h b/view/sharedcache/ui/stringstable.h new file mode 100644 index 0000000000..ceaa4bdfac --- /dev/null +++ b/view/sharedcache/ui/stringstable.h @@ -0,0 +1,68 @@ +#pragma once + +#include "triagetable.h" + + +class StringsTableModel : public TriageTableRowsModel +{ + ImageNameLookup m_names; + bool m_showNonImageStrings = false; + size_t m_imageStringCount = 0; + +protected: + void applyFilter() override; + bool rowsEquivalent( + const SharedCacheAPI::CacheString& a, const SharedCacheAPI::CacheString& b) const override; + +public: + explicit StringsTableModel(QWidget* parent); + + int columnCount(const QModelIndex& parent) const override; + QVariant data(const QModelIndex& index, int role) const override; + QVariant headerData(int section, Qt::Orientation orientation, int role) const override; + void sort(int column, Qt::SortOrder order) override; + + // Build the Image column lookup tables. + void setNameSources(const SharedCacheAPI::SharedCacheController& controller); + + const ImageNameLookup& names() const { return m_names; } + + // Append a batch of scan results, filtering only the new rows. + void appendStrings(std::vector strings); + + void clearRows() override; + + // Whether strings in regions not belonging to any image are shown. + void setShowNonImageStrings(bool show); + + // Count of strings eligible for display under the current visibility options, ignoring any + // text filter. + size_t baselineStringCount() const + { + return m_showNonImageStrings ? totalRowCount() : m_imageStringCount; + } + + const SharedCacheAPI::CacheString& stringAt(int row) const { return rowAt(row); } +}; + + +class StringsTableView : public TriageTableView +{ + StringsTableModel* m_model; + +public: + explicit StringsTableView(QWidget* parent); + + StringsTableModel* stringsModel() const { return m_model; } + + // Build the Image column lookup tables and refit the default column widths. + void setNameSources(const SharedCacheAPI::SharedCacheController& controller); + + SharedCacheAPI::CacheString getStringAtRow(int row) const + { + return m_model->stringAt(row); + } + +protected: + void applyDefaultColumnWidths() override; +}; diff --git a/view/sharedcache/ui/symboltable.cpp b/view/sharedcache/ui/symboltable.cpp index 2a6cb467cb..53c4c29db0 100644 --- a/view/sharedcache/ui/symboltable.cpp +++ b/view/sharedcache/ui/symboltable.cpp @@ -1,61 +1,129 @@ -#include #include "symboltable.h" -#include +#include +#include +#include -#include "ui/fontsettings.h" +#include "addresstext.h" +#include "theme.h" -#include "binaryninjaapi.h" - -using namespace BinaryNinja; using namespace SharedCacheAPI; +namespace { -SymbolTableModel::SymbolTableModel(SymbolTableView* parent) : - QAbstractTableModel(parent), m_parent(parent), m_displaySymbols(&m_symbols) +enum SymbolsTableColumn +{ + SymbolsTableAddressColumn, + SymbolsTableTypeColumn, + SymbolsTableImageColumn, + SymbolsTableNameColumn, + SymbolsTableColumnCount, +}; + +QString SymbolTypeAsString(BNSymbolType type) { - // TODO: Need to implement updating this font if it is changed by the user - m_font = getMonospaceFont(parent); + const std::string name = GetSymbolTypeAsString(type); + return QString::fromUtf8(name.c_str(), name.size()); } -int SymbolTableModel::rowCount(const QModelIndex& parent) const { - Q_UNUSED(parent); - return static_cast(m_displaySymbols->size()); +const SymbolTableModel::AddressRange* RangeContaining( + const std::vector& ranges, uint64_t address) +{ + auto it = std::upper_bound(ranges.begin(), ranges.end(), address, + [](uint64_t address, const SymbolTableModel::AddressRange& range) { + return address < range.start; + }); + if (it == ranges.begin()) + return nullptr; + --it; + if (address >= it->end) + return nullptr; + return &*it; } -int SymbolTableModel::columnCount(const QModelIndex& parent) const { +QString ImageNameForSymbol(const ImageNameLookup::State& names, + const std::vector& ranges, const CacheSymbol& symbol) +{ + const auto* range = RangeContaining(ranges, symbol.address); + if (!range) + return {}; + return ImageNameLookup::displayName(names, range->imageStart, range->start); +} + +} // namespace + + +SymbolTableModel::SymbolTableModel(QWidget* parent) : TriageTableRowsModel(parent) +{ +} + + +int SymbolTableModel::columnCount(const QModelIndex& parent) const +{ Q_UNUSED(parent); - // We have 3 columns: Address, Type, Name - return 3; + return SymbolsTableColumnCount; } -QVariant SymbolTableModel::data(const QModelIndex& index, int role) const { - if (!index.isValid() || (role != Qt::DisplayRole && role != Qt::FontRole)) { +QVariant SymbolTableModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) return QVariant(); - } switch (role) { case Qt::DisplayRole: { - auto symbol = symbolAt(index.row()); - auto symbolType = GetSymbolTypeAsString(symbol.type); + const auto& symbol = symbolAt(index.row()); switch (index.column()) { - case 0: // Address column - return QString("0x%1").arg(symbol.address, 0, 16); // Display address as hexadecimal - case 1: // Type column - return QString::fromUtf8(symbolType.c_str(), symbolType.size()); - case 2: // Name column + case SymbolsTableAddressColumn: + return QString::fromStdString(AddressText(symbol.address, m_addressWidth)); + case SymbolsTableTypeColumn: + return SymbolTypeAsString(symbol.type); + case SymbolsTableImageColumn: + return ImageNameForSymbol(*m_names.snapshot(), *m_ranges, symbol); + case SymbolsTableNameColumn: return QString::fromUtf8(symbol.name.c_str(), symbol.name.size()); default: return QVariant(); } } + case Qt::ForegroundRole: + switch (index.column()) + { + case SymbolsTableAddressColumn: + return getThemeColor(AddressColor); + case SymbolsTableTypeColumn: + return getThemeColor(TypeNameColor); + case SymbolsTableNameColumn: + switch (symbolAt(index.row()).type) + { + case FunctionSymbol: + return getThemeColor(CodeSymbolColor); + case DataSymbol: + return getThemeColor(DataSymbolColor); + default: + return QVariant(); + } + default: + return QVariant(); + } + case Qt::ToolTipRole: + { + if (index.column() != SymbolsTableImageColumn) + return QVariant(); + const auto& symbol = symbolAt(index.row()); + const auto* range = RangeContaining(*m_ranges, symbol.address); + if (!range) + return QVariant(); + if (QString tooltip = m_names.tooltip(range->imageStart, range->start); !tooltip.isEmpty()) + return tooltip; + return QVariant(); + } case Qt::FontRole: return m_font; default: @@ -64,17 +132,20 @@ QVariant SymbolTableModel::data(const QModelIndex& index, int role) const { } -QVariant SymbolTableModel::headerData(int section, Qt::Orientation orientation, int role) const { - if (role != Qt::DisplayRole || orientation != Qt::Horizontal) { +QVariant SymbolTableModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (role != Qt::DisplayRole || orientation != Qt::Horizontal) return QVariant(); - } - switch (section) { - case 0: + switch (section) + { + case SymbolsTableAddressColumn: return QString("Address"); - case 1: + case SymbolsTableTypeColumn: return QString("Type"); - case 2: + case SymbolsTableImageColumn: + return QString("Image"); + case SymbolsTableNameColumn: return QString("Name"); default: return QVariant(); @@ -84,168 +155,139 @@ QVariant SymbolTableModel::headerData(int section, Qt::Orientation orientation, void SymbolTableModel::sort(int column, Qt::SortOrder order) { - beginResetModel(); - - std::function comparator; + Comparator comparator; switch (column) { - case 0: // Address column + case SymbolsTableAddressColumn: comparator = [](const CacheSymbol& a, const CacheSymbol& b) { return a.address < b.address; }; break; - case 1: // Type column + case SymbolsTableTypeColumn: comparator = [](const CacheSymbol& a, const CacheSymbol& b) { - return GetSymbolTypeAsString(a.type) < GetSymbolTypeAsString(b.type); + return a.type < b.type; + }; + break; + case SymbolsTableImageColumn: + { + // Rank the region ranges by their displayed name once so that row comparisons are + // integer-only instead of repeated string comparisons. + auto ranges = m_ranges; + auto namesSnapshot = m_names.snapshot(); + std::vector names(ranges->size()); + for (size_t i = 0; i < ranges->size(); i++) + names[i] = ImageNameLookup::displayName( + *namesSnapshot, (*ranges)[i].imageStart, (*ranges)[i].start); + std::vector byName(ranges->size()); + std::iota(byName.begin(), byName.end(), 0); + std::sort(byName.begin(), byName.end(), + [&names](size_t a, size_t b) { return names[a] < names[b]; }); + + auto ranks = std::make_shared>(ranges->size(), -1); + int rank = -1; + const QString* previousName = nullptr; + for (size_t index : byName) + { + // Identical names share a rank, matching equality under name comparison. + if (!previousName || names[index] != *previousName) + { + rank++; + previousName = &names[index]; + } + (*ranks)[index] = rank; + } + + comparator = [ranges, ranks](const CacheSymbol& a, const CacheSymbol& b) { + // Mirrors the Image column's name resolution. Symbols outside every range rank + // first like the empty string. + const auto rankOf = [ranges, &ranks](const CacheSymbol& symbol) { + const auto* range = RangeContaining(*ranges, symbol.address); + if (!range) + return -1; + return (*ranks)[range - ranges->data()]; + }; + return rankOf(a) < rankOf(b); }; break; - case 2: // Name column + } + case SymbolsTableNameColumn: comparator = [](const CacheSymbol& a, const CacheSymbol& b) { return a.name < b.name; }; break; default: - endResetModel(); return; } - if (order == Qt::DescendingOrder) - { - std::sort(m_displaySymbols->begin(), m_displaySymbols->end(), - [&comparator](const CacheSymbol& a, const CacheSymbol& b) { return comparator(b, a); }); - } - else - { - std::sort(m_displaySymbols->begin(), m_displaySymbols->end(), comparator); - } - - endResetModel(); + sortRows(std::move(comparator), order); } -void SymbolTableModel::updateSymbols(std::vector symbols) +void SymbolTableModel::setNameSources(const SharedCacheController& controller) { - m_symbols = std::move(symbols); - setFilter(m_filter, m_filterOptions); + m_names.build(controller); + m_addressWidth = BNGetAddressRenderedWidth(m_names.maxAddress()); + + auto ranges = std::make_shared>(); + for (const auto& region : controller.GetRegions()) + ranges->push_back({region.start, region.start + region.size, region.imageStart}); + std::sort(ranges->begin(), ranges->end(), + [](const AddressRange& a, const AddressRange& b) { return a.start < b.start; }); + m_ranges = std::move(ranges); } -const CacheSymbol& SymbolTableModel::symbolAt(int row) const +bool SymbolTableModel::rowsEquivalent(const CacheSymbol& a, const CacheSymbol& b) const { - return m_displaySymbols->at(row); + return a.address == b.address && a.name == b.name; } -void SymbolTableModel::setFilter(const std::string& text, FilterOptions options) +void SymbolTableModel::applyFilter() { - m_filter = text; - m_filterOptions = options; - bool caseSensitive = options.testFlag(CaseSensitiveOption); - beginResetModel(); - // Skip filtering if no filter applied. - if (m_filter.empty()) + if (m_filterText.empty()) { - m_filteredSymbols = {}; - m_displaySymbols = &m_symbols; - } - else - { - // Clear the filtered symbols while preserving the capacity - m_filteredSymbols.clear(); - - for (const auto& symbol : m_symbols) - { - const std::string& symbolName = symbol.name; - bool match; - if (caseSensitive) - { - match = (symbolName.find(m_filter) != std::string::npos); - } - else - { - auto it = std::search( - symbolName.begin(), symbolName.end(), - m_filter.begin(), m_filter.end(), - [](char c1, char c2) { return std::tolower(c1) == std::tolower(c2); } - ); - - match = (it != symbolName.end()); - } - - if (match) - { - m_filteredSymbols.push_back(symbol); - } - } - - // If the filtered vector is using less than 25% of its capacity, - // shrink it to reduce memory usage. - if (m_filteredSymbols.size() < m_filteredSymbols.capacity() / 4) - m_filteredSymbols.shrink_to_fit(); - - m_displaySymbols = &m_filteredSymbols; + m_rows.setFilter(nullptr); + return; } - endResetModel(); + const auto snapshot = filterSnapshot(); + const uint32_t addressWidth = m_addressWidth; + const auto names = m_names.snapshot(); + const auto ranges = m_ranges; + + m_rows.setFilterFactory([snapshot, addressWidth, names, ranges]() -> Predicate { + FilterParams params = MakeFilterParams(snapshot); + return [params = std::move(params), addressWidth, names, ranges](const CacheSymbol& symbol) { + QString imageName; + if (params.matchImageNames) + imageName = ImageNameForSymbol(*names, *ranges, symbol); + return MatchesText(params, symbol.name, symbol.address, addressWidth, imageName); + }; + }); } - - -SymbolTableView::SymbolTableView(QWidget* parent) : - QTableView(parent), m_model(new SymbolTableModel(this)) +SymbolTableView::SymbolTableView(QWidget* parent) : TriageTableView(parent) { - // Set up the filter model - setModel(m_model); - - // Configure view settings - horizontalHeader()->setSectionResizeMode(0, QHeaderView::Fixed); - horizontalHeader()->setSectionResizeMode(1, QHeaderView::Fixed); - horizontalHeader()->setSectionResizeMode(2, QHeaderView::Stretch); - setEditTriggers(QAbstractItemView::NoEditTriggers); - setSelectionBehavior(QAbstractItemView::SelectRows); - setSelectionMode(QAbstractItemView::SingleSelection); - verticalHeader()->setVisible(false); - - sortByColumn(0, Qt::AscendingOrder); - setSortingEnabled(true); + m_model = new SymbolTableModel(this); + setTriageModel(m_model, SymbolsTableAddressColumn); + applyDefaultColumnWidths(); } -SymbolTableView::~SymbolTableView() = default; -void SymbolTableView::populateSymbols(BinaryView &view) +void SymbolTableView::setNameSources(const SharedCacheController& controller) { - if (auto controller = SharedCacheController::GetController(view)) { - typedef std::vector SymbolList; - // Retrieve the symbols from the controller in a future than pass that to the model. - QPointer> watcher = new QFutureWatcher(this); - connect(watcher, &QFutureWatcher::finished, this, [watcher, this]() { - if (watcher) - { - auto symbols = watcher->result(); - m_model->updateSymbols(std::move(symbols)); - - // Reapply the current sort after repopulating the model - // TODO: The model should use `QSortFilterProxyModel`, but that's a bigger change. - setSortingEnabled(true); - } - }); - QFuture future = QtConcurrent::run([controller]() { - return controller->GetSymbols(); - }); - watcher->setFuture(future); - connect(this, &QObject::destroyed, this, [watcher]() { - if (watcher && watcher->isRunning()) { - watcher->cancel(); - watcher->waitForFinished(); - } - }); - } + m_model->setNameSources(controller); + applyDefaultColumnWidths(); } -void SymbolTableView::setFilter(const std::string& filter, FilterOptions options) +void SymbolTableView::applyDefaultColumnWidths() { - m_model->setFilter(filter, options); + fitColumn(SymbolsTableAddressColumn, {QString(m_model->addressWidth(), QChar('0'))}); + fitColumn(SymbolsTableTypeColumn, + {SymbolTypeAsString(FunctionSymbol), SymbolTypeAsString(DataSymbol), QStringLiteral("Unknown")}); + fitColumn(SymbolsTableImageColumn, {m_model->names().widestImageName()}); } diff --git a/view/sharedcache/ui/symboltable.h b/view/sharedcache/ui/symboltable.h index bcaef2946f..ff23035d66 100644 --- a/view/sharedcache/ui/symboltable.h +++ b/view/sharedcache/ui/symboltable.h @@ -1,106 +1,66 @@ #pragma once -#include -#include "viewframe.h" +#include "triagetable.h" -#include -#include -#include "filter.h" +#include -#ifndef BINARYNINJA_DSCSYMBOLTABLE_H -#define BINARYNINJA_DSCSYMBOLTABLE_H -class SymbolTableView; - - -class SymbolTableModel : public QAbstractTableModel +class SymbolTableModel : public TriageTableRowsModel { -Q_OBJECT - SymbolTableView* m_parent; - QFont m_font; - std::string m_filter; - FilterOptions m_filterOptions; +public: + // A region's address range, for resolving a symbol's address to the image or region + // containing it. + struct AddressRange + { + uint64_t start; + uint64_t end; + std::optional imageStart; + }; - std::vector m_symbols; - std::vector m_filteredSymbols; +private: + // Region address ranges sorted by start address, for resolving symbol addresses. + std::shared_ptr> m_ranges = std::make_shared>(); + ImageNameLookup m_names; - // A pointer to either m_symbols or m_filteredSymbols, depending on whether a filter is applied. - std::vector *m_displaySymbols = nullptr; +protected: + void applyFilter() override; + bool rowsEquivalent( + const SharedCacheAPI::CacheSymbol& a, const SharedCacheAPI::CacheSymbol& b) const override; public: - explicit SymbolTableModel(SymbolTableView* parent); + explicit SymbolTableModel(QWidget* parent); - int rowCount(const QModelIndex& parent) const override; int columnCount(const QModelIndex& parent) const override; QVariant data(const QModelIndex& index, int role) const override; QVariant headerData(int section, Qt::Orientation orientation, int role) const override; void sort(int column, Qt::SortOrder order) override; - void updateSymbols(std::vector symbols); - void setFilter(const std::string& text, FilterOptions options); + // Build the Image column lookup tables and the address resolution ranges. + void setNameSources(const SharedCacheAPI::SharedCacheController& controller); + + const ImageNameLookup& names() const { return m_names; } - const SharedCacheAPI::CacheSymbol& symbolAt(int row) const; + const SharedCacheAPI::CacheSymbol& symbolAt(int row) const { return rowAt(row); } }; -class SymbolTableView : public QTableView, public FilterTarget +class SymbolTableView : public TriageTableView { -Q_OBJECT - friend class SymbolTableModel; - SymbolTableModel* m_model; public: explicit SymbolTableView(QWidget* parent); - ~SymbolTableView() override; - - // Call this to populate the symbols from the given view. - void populateSymbols(BinaryNinja::BinaryView& view); - - void scrollToFirstItem() override - { - if (model()->rowCount() > 0) { - QModelIndex top = indexAt(rect().topLeft()); - if (top.isValid()) - scrollTo(top); - } - } - void scrollToCurrentItem() override - { - QModelIndex currentIndex = selectionModel()->currentIndex(); - if (currentIndex.isValid()) - scrollTo(currentIndex); - } - - void ensureSelection() override - { - QModelIndex current = selectionModel()->currentIndex(); - if (current.isValid() || model()->rowCount() == 0) - return; - - if (auto top = indexAt(rect().topLeft()); top.isValid()) - { - selectionModel()->select(top, QItemSelectionModel::ClearAndSelect); - setCurrentIndex(top); - } - } + SymbolTableModel* symbolsModel() const { return m_model; } - - void activateSelection() override - { - ensureSelection(); - if (auto current = selectionModel()->currentIndex(); current.isValid()) - emit activated(current); - } + // Build the Image column lookup tables and refit the default column widths. + void setNameSources(const SharedCacheAPI::SharedCacheController& controller); SharedCacheAPI::CacheSymbol getSymbolAtRow(int row) const { return m_model->symbolAt(row); } - void setFilter(const std::string& filter, FilterOptions options) override; +protected: + void applyDefaultColumnWidths() override; }; - - -#endif // BINARYNINJA_DSCSYMBOLTABLE_H diff --git a/view/sharedcache/ui/triagetable.cpp b/view/sharedcache/ui/triagetable.cpp new file mode 100644 index 0000000000..d70c608208 --- /dev/null +++ b/view/sharedcache/ui/triagetable.cpp @@ -0,0 +1,530 @@ +#include "triagetable.h" + +#include +#include + +#include + +#include "addresstext.h" +#include "theme.h" +#include "ui/fontsettings.h" + +using namespace SharedCacheAPI; + + +TriageTableModel::TriageTableModel(QWidget* parent) : QAbstractTableModel(parent) +{ + // TODO: Need to implement updating this font if it is changed by the user + m_font = getMonospaceFont(parent); +} + + +TriageTableModel::FilterParams TriageTableModel::MakeFilterParams(const FilterSnapshot& snapshot) +{ + return {snapshot.text, snapshot.options, + QRegularExpression(QString::fromStdString(snapshot.text), + snapshot.options.testFlag(CaseSensitiveOption) ? QRegularExpression::NoPatternOption + : QRegularExpression::CaseInsensitiveOption), + snapshot.matchImageNames}; +} + + +bool TriageTableModel::MatchesText(const FilterParams& params, const std::string& text, + uint64_t address, uint32_t addressWidth, const QString& imageName) +{ + if (params.options.testFlag(UseRegexOption)) + { + if (params.regex.match(QString::fromUtf8(text.c_str(), text.size())).hasMatch()) + return true; + if (params.regex.match(QString::fromStdString(AddressText(address, addressWidth))).hasMatch()) + return true; + return params.matchImageNames && params.regex.match(imageName).hasMatch(); + } + + const bool caseSensitive = params.options.testFlag(CaseSensitiveOption); + const auto contains = [&](const std::string& haystack) { + if (caseSensitive) + return haystack.find(params.text) != std::string::npos; + const auto lower = [](char c) { + return std::tolower(static_cast(c)); + }; + auto it = std::search(haystack.begin(), haystack.end(), params.text.begin(), params.text.end(), + [lower](char c1, char c2) { return lower(c1) == lower(c2); }); + return it != haystack.end(); + }; + + if (contains(text)) + return true; + if (contains(AddressText(address, addressWidth))) + return true; + return params.matchImageNames && contains(imageName.toStdString()); +} + + +void ImageNameLookup::build(const SharedCacheController& controller) +{ + auto state = std::make_shared(); + for (const auto& image : controller.GetImages()) + { + const auto lastSlash = image.name.find_last_of('/'); + const auto baseName = lastSlash == std::string::npos ? image.name : image.name.substr(lastSlash + 1); + state->imageNames[image.headerAddress] = QString::fromStdString(baseName); + state->imagePaths[image.headerAddress] = QString::fromStdString(image.name); + } + for (const auto& region : controller.GetRegions()) + { + state->regionNames[region.start] = QString::fromStdString(region.name); + state->maxAddress = std::max(state->maxAddress, region.start + region.size); + } + m_state = std::move(state); +} + + +QString ImageNameLookup::displayName(const State& state, std::optional imageStart, + uint64_t regionStart) +{ + if (imageStart) + { + if (auto it = state.imageNames.find(*imageStart); it != state.imageNames.end()) + return it->second; + } + if (auto it = state.regionNames.find(regionStart); it != state.regionNames.end()) + return it->second; + return {}; +} + + +QString ImageNameLookup::tooltip(const State& state, std::optional imageStart, + uint64_t regionStart) +{ + if (imageStart) + { + if (auto it = state.imagePaths.find(*imageStart); it != state.imagePaths.end()) + return it->second; + } + if (auto it = state.regionNames.find(regionStart); it != state.regionNames.end()) + return it->second; + return {}; +} + + +QString ImageNameLookup::displayName(std::optional imageStart, uint64_t regionStart) const +{ + return displayName(*m_state, imageStart, regionStart); +} + + +QString ImageNameLookup::tooltip(std::optional imageStart, uint64_t regionStart) const +{ + return tooltip(*m_state, imageStart, regionStart); +} + + +QString ImageNameLookup::widestImageName() const +{ + QString widest; + for (const auto& [_, name] : m_state->imageNames) + if (name.size() > widest.size()) + widest = name; + return widest; +} + + +void TriageTableModel::setFilter(const std::string& text, FilterOptions options) +{ + m_filterText = text; + m_filterOptions = options; + applyFilter(); +} + + +void TriageTableModel::setMatchImageNames(bool match) +{ + if (m_matchImageNames == match) + return; + m_matchImageNames = match; + if (!m_filterText.empty()) + applyFilter(); +} + + +TriageTableView::TriageTableView(QWidget* parent) : QTableView(parent) +{ + setFont(getMonospaceFont(this)); + setWordWrap(false); + verticalHeader()->setVisible(false); + + // Match the dense row sizing of the regular strings and symbols views. + const int charHeight = (int)(QFontMetricsF(getMonospaceFont(this)).height() + getExtraFontSpacing()); + verticalHeader()->setDefaultSectionSize(charHeight); + verticalHeader()->setSectionResizeMode(QHeaderView::Fixed); + horizontalHeader()->setStretchLastSection(true); + // With row selection, the default section highlighting bolds every column header whenever a + // row is selected. + horizontalHeader()->setHighlightSections(false); + setShowGrid(false); + + setEditTriggers(QAbstractItemView::NoEditTriggers); + setSelectionBehavior(QAbstractItemView::SelectRows); + setSelectionMode(QAbstractItemView::SingleSelection); +} + + +void TriageTableView::setTriageModel(TriageTableModel* model, int sortColumn) +{ + m_model = model; + setModel(model); + + sortByColumn(sortColumn, Qt::AscendingOrder); + setSortingEnabled(true); + + m_busyOverlay = new QLabel(viewport()); + m_busyOverlay->setAlignment(Qt::AlignCenter); + m_busyOverlay->setAutoFillBackground(true); + m_busyOverlay->setContentsMargins(16, 8, 16, 8); + m_busyOverlay->hide(); + const auto updateOverlay = [this](bool) { + if (m_model->isSorting()) + m_busyOverlay->setText(QStringLiteral("Sorting…")); + else if (m_model->isFiltering()) + m_busyOverlay->setText(QStringLiteral("Filtering…")); + m_busyOverlay->setVisible(m_model->isSorting() || m_model->isFiltering()); + positionBusyOverlay(); + }; + connect(m_model, &TriageTableModel::filteringChanged, this, updateOverlay); + connect(m_model, &TriageTableModel::sortingChanged, this, updateOverlay); +} + + +void TriageTableView::fitColumn(int column, const std::vector& contents) +{ + const QFontMetricsF metrics(getMonospaceFont(this)); + // Room for the header's sort indicator. + const int padding = (int)metrics.horizontalAdvance(QStringLiteral(" ")); + qreal width = metrics.horizontalAdvance( + m_model->headerData(column, Qt::Horizontal, Qt::DisplayRole).toString()); + for (const auto& content : contents) + width = std::max(width, metrics.horizontalAdvance(content)); + setColumnWidth(column, (int)width + padding); +} + + +void TriageTableView::savePosition() +{ + const auto current = selectionModel()->currentIndex(); + const auto top = indexAt(QPoint(0, 0)); + m_model->saveRowIdentities(current.isValid() ? current.row() : -1, top.isValid() ? top.row() : -1); +} + + +void TriageTableView::restorePosition() +{ + const auto [selectedRow, topRow] = m_model->takeSavedRows(); + if (selectedRow >= 0) + selectionModel()->setCurrentIndex(m_model->index(selectedRow, 0), + QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + if (topRow >= 0) + scrollTo(m_model->index(topRow, 0), QAbstractItemView::PositionAtTop); +} + + +void TriageTableView::setFilter(const std::string& filter, FilterOptions options) +{ + m_model->setFilter(filter, options); +} + + +void TriageTableView::scrollToFirstItem() +{ + if (model()->rowCount() > 0) + { + QModelIndex top = indexAt(rect().topLeft()); + if (top.isValid()) + scrollTo(top); + } +} + + +void TriageTableView::scrollToCurrentItem() +{ + QModelIndex currentIndex = selectionModel()->currentIndex(); + if (currentIndex.isValid()) + scrollTo(currentIndex); +} + + +void TriageTableView::ensureSelection() +{ + QModelIndex current = selectionModel()->currentIndex(); + if (current.isValid() || model()->rowCount() == 0) + return; + + if (auto top = indexAt(rect().topLeft()); top.isValid()) + { + selectionModel()->select(top, QItemSelectionModel::ClearAndSelect); + setCurrentIndex(top); + } +} + + +void TriageTableView::activateSelection() +{ + ensureSelection(); + if (auto current = selectionModel()->currentIndex(); current.isValid()) + emit activated(current); +} + + +void TriageTableView::resizeEvent(QResizeEvent* event) +{ + QTableView::resizeEvent(event); + if (m_busyOverlay) + positionBusyOverlay(); +} + + +void TriageTableView::positionBusyOverlay() +{ + m_busyOverlay->adjustSize(); + m_busyOverlay->move((viewport()->width() - m_busyOverlay->width()) / 2, + (viewport()->height() - m_busyOverlay->height()) / 2); +} + + +TriageTablePanel::TriageTablePanel(QWidget* parent, TriageTableView* table, + const QString& filterPlaceholder, QString rowNoun) + : QWidget(parent), m_table(table), m_rowNoun(std::move(rowNoun)) +{ + m_filterEdit = new FilterEdit(table); + m_filterEdit->setPlaceholderText(filterPlaceholder); + m_filterEdit->showRegexToggle(true); + connect(m_filterEdit, &FilterEdit::textChanged, this, &TriageTablePanel::applyFilter); + connect(m_filterEdit, &FilterEdit::optionsChanged, this, &TriageTablePanel::applyFilter); + + m_statusLabel = new QLabel(this); + m_statusLabel->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); + // Tabular numbers keep the status text from shifting as its numbers change. + QFont statusFont = m_statusLabel->font(); + statusFont.setFeature("tnum", 1); + m_statusLabel->setFont(statusFont); + + m_footerLayout = new QHBoxLayout; + m_footerLayout->addWidget(m_statusLabel); + m_footerLayout->setAlignment(Qt::AlignLeft); + + auto layout = new QVBoxLayout(this); + layout->addWidget(m_filterEdit); + layout->addWidget(m_table); + layout->addLayout(m_footerLayout); + + // Frees the loaded content once the tab has been hidden for this long. + constexpr int clearDelayMs = 60 * 1000; + m_clearTimer = new QTimer(this); + m_clearTimer->setInterval(clearDelayMs); + m_clearTimer->setSingleShot(true); + connect(m_clearTimer, &QTimer::timeout, this, &TriageTablePanel::clearContent); + + const auto model = m_table->triageModel(); + connect(model, &TriageTableModel::filteringChanged, this, [this](bool) { updateStatusLabel(); }); + connect(model, &TriageTableModel::sortingChanged, this, [this](bool active) { + updateStatusLabel(); + if (!active) + restoreWhenIdle(); + }); +} + + +void TriageTablePanel::applyFilter() +{ + const QString text = m_filterEdit->text(); + const FilterOptions options = m_filterEdit->getFilterOptions(); + if (options.testFlag(UseRegexOption)) + { + const QRegularExpression regex(text); + if (!regex.isValid()) + { + m_filterEdit->setRegexValidationError(regex.errorString()); + return; + } + } + + m_filterEdit->setRegexValidationError({}); + m_table->setFilter(text.toStdString(), options); +} + + +void TriageTablePanel::addFooterWidget(QWidget* widget) +{ + m_footerLayout->insertWidget(m_footerLayout->count() - 1, widget); +} + + +QAction* TriageTablePanel::addFilterToggle(const QString& iconPath, const QString& toolTip, + std::function onToggled) +{ + QPixmap offPixmap; + pixmapForBWMaskIcon(iconPath, &offPixmap); + QPixmap onPixmap; + pixmapForBWMaskIcon(iconPath, &onPixmap, m_filterEdit->palette().color(QPalette::Highlight), "filterOn"); + auto action = m_filterEdit->addAction(QIcon(offPixmap), QLineEdit::TrailingPosition); + action->setCheckable(true); + action->setToolTip(toolTip); + connect(action, &QAction::toggled, this, + [action, offIcon = QIcon(offPixmap), onIcon = QIcon(onPixmap), + onToggled = std::move(onToggled)](bool checked) { + action->setIcon(checked ? onIcon : offIcon); + onToggled(checked); + }); + return action; +} + + +QPushButton* TriageTablePanel::addSelectionButton(const QString& text) +{ + auto button = new QPushButton(text); + button->setEnabled(false); + const auto update = [this, button] { + button->setEnabled(m_table->selectionModel()->hasSelection()); + }; + connect(m_table->selectionModel(), &QItemSelectionModel::selectionChanged, button, update); + connect(m_table->triageModel(), &QAbstractItemModel::modelReset, button, update); + addFooterWidget(button); + return button; +} + + +void TriageTablePanel::setLoader(std::function loader) +{ + m_loader = std::move(loader); +} + + +void TriageTablePanel::setClearHandler(std::function handler) +{ + m_clearHandler = std::move(handler); +} + + +void TriageTablePanel::setBaselineCount(std::function baselineCount) +{ + m_baselineCount = std::move(baselineCount); +} + + +void TriageTablePanel::setViewVisible(bool visible) +{ + m_viewVisible = visible; + updateActive(); +} + + +void TriageTablePanel::setCurrentTabWidget(QWidget* current) +{ + m_tabCurrent = (current == this); + updateActive(); +} + + +void TriageTablePanel::updateActive() +{ + // The content is on screen only when the triage view is visible and this tab is current. + if (m_viewVisible && m_tabCurrent) + { + m_clearTimer->stop(); + if (!m_loadStarted && m_loader) + { + m_loadStarted = m_loader(); + // The loader owns the status label, for its progress text, until the load finishes. + m_loaderOwnsStatus = m_loadStarted; + } + } + else if (m_loadStarted && !m_clearTimer->isActive()) + { + // Begin the countdown from when the content first went off screen. Later transitions + // while still off screen must not extend it. + m_clearTimer->start(); + } +} + + +void TriageTablePanel::clearContent() +{ + if (!m_loadStarted) + return; + + // Clearing abandons any running sort, whose end signal must not consume the saved position. + m_restorePending = false; + if (m_clearHandler) + m_clearHandler(); + m_table->savePosition(); + m_table->triageModel()->clearRows(); + m_loadStarted = false; + m_loaderOwnsStatus = false; + m_statusLabel->setText(""); +} + + +void TriageTablePanel::resetContent() +{ + clearContent(); + updateActive(); +} + + +void TriageTablePanel::finishLoad() +{ + // Restore the saved position once the sort over the now complete content commits. + m_restorePending = true; + m_table->sortByColumn(m_table->horizontalHeader()->sortIndicatorSection(), + m_table->horizontalHeader()->sortIndicatorOrder()); + m_loaderOwnsStatus = false; + updateStatusLabel(); +} + + +void TriageTablePanel::updateStatusLabel() +{ + if (!m_loadStarted || m_loaderOwnsStatus) + return; + + const auto model = m_table->triageModel(); + if (model->isSorting()) + { + m_statusLabel->setText(QStringLiteral("Sorting…")); + return; + } + if (model->isFiltering()) + { + m_statusLabel->setText(QStringLiteral("Filtering…")); + return; + } + + const QLocale locale; + const auto shown = static_cast(model->rowCount(QModelIndex())); + if (!model->hasTextFilter()) + { + m_statusLabel->setText(QString("%1 %2").arg(locale.toString(shown), m_rowNoun)); + return; + } + const auto baseline = + static_cast(m_baselineCount ? m_baselineCount() : model->totalRowCount()); + m_statusLabel->setText( + QString("%1 / %2 %3").arg(locale.toString(shown), locale.toString(baseline), m_rowNoun)); +} + + +void TriageTablePanel::restoreWhenIdle() +{ + if (!m_restorePending) + return; + // A sort's end signal also fires when the sort is abandoned for a filter change or superseded + // by a newer sort, with the follow-up job starting just after, so the check is deferred one + // event-loop turn. Follow-up chains always end with a committed sort. + QTimer::singleShot(0, this, [this] { + const auto model = m_table->triageModel(); + if (!m_restorePending || model->isSorting() || model->isFiltering()) + return; + m_restorePending = false; + m_table->restorePosition(); + }); +} diff --git a/view/sharedcache/ui/triagetable.h b/view/sharedcache/ui/triagetable.h new file mode 100644 index 0000000000..47575e3f5e --- /dev/null +++ b/view/sharedcache/ui/triagetable.h @@ -0,0 +1,351 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include "backgroundsortfilterrows.h" +#include "filter.h" + +/*! Name lookups for a table's Image column, keyed by image header address and region start + address. +*/ +class ImageNameLookup +{ +public: + struct State + { + std::map imageNames; + std::map regionNames; + // Full image paths for the Image column tooltip. + std::map imagePaths; + uint64_t maxAddress = 0; + }; + +private: + std::shared_ptr m_state = std::make_shared(); + +public: + // Build the lookups from the controller's images and regions. + void build(const SharedCacheAPI::SharedCacheController& controller); + + std::shared_ptr snapshot() const { return m_state; } + + static QString displayName( + const State& state, std::optional imageStart, uint64_t regionStart); + static QString tooltip(const State& state, std::optional imageStart, uint64_t regionStart); + + // The Image column display name: the image's base name, or the region's name for rows + // outside any image. + QString displayName(std::optional imageStart, uint64_t regionStart) const; + + // The Image column tooltip: the image's full path, or the region's name for rows outside + // any image. + QString tooltip(std::optional imageStart, uint64_t regionStart) const; + + // The longest image name, for sizing the Image column. + QString widestImageName() const; + + // The end of the highest region, for sizing zero-padded address text. + uint64_t maxAddress() const { return m_state->maxAddress; } +}; + + +/*! Shared portion of the triage view's flat table models: the filter state and the text matching + common to the tables. Row storage lives in the typed subclass `TriageTableRowsModel`. +*/ +class TriageTableModel : public QAbstractTableModel +{ +Q_OBJECT +protected: + struct FilterParams + { + std::string text; + FilterOptions options; + QRegularExpression regex; + bool matchImageNames; + }; + + // The filter state, captured on the GUI thread for the worker-thread predicate factories. + struct FilterSnapshot + { + std::string text; + FilterOptions options; + bool matchImageNames; + }; + + QFont m_font; + + // Rendered width of the highest cache address, for zero-padded address display. + uint32_t m_addressWidth = 16; + + std::string m_filterText; + FilterOptions m_filterOptions; + bool m_matchImageNames = false; + + explicit TriageTableModel(QWidget* parent); + + FilterSnapshot filterSnapshot() const { return {m_filterText, m_filterOptions, m_matchImageNames}; } + + // Materialize a snapshot's matching state. Call once per work chunk on the worker threads: + // QRegularExpression::match takes the instance's mutex, so a shared instance serializes them. + static FilterParams MakeFilterParams(const FilterSnapshot& snapshot); + + // Whether the filter matches the row's primary text, its rendered address, or its image name. + // Pass an empty image name when image name matching is disabled. + static bool MatchesText(const FilterParams& params, const std::string& text, uint64_t address, + uint32_t addressWidth, const QString& imageName); + + // Re-apply the filter state to the rows. + virtual void applyFilter() = 0; + +public: + uint32_t addressWidth() const { return m_addressWidth; } + + void setFilter(const std::string& text, FilterOptions options); + + // Whether the filter also matches against the Image column. Re-applies any active filter. + void setMatchImageNames(bool match); + + bool hasTextFilter() const { return !m_filterText.empty(); } + + virtual bool isFiltering() const = 0; + virtual bool isSorting() const = 0; + + // Count of all rows fetched so far, ignoring any filter. + virtual size_t totalRowCount() const = 0; + + // Drop all rows, freeing their storage, while retaining the filter settings. + virtual void clearRows() = 0; + + // Capture the identities of the given display rows for a `takeSavedRows` lookup after a + // content reload. Pass -1 to leave a slot empty. + virtual void saveRowIdentities(int selectedRow, int topRow) = 0; + // Consume the identities captured by `saveRowIdentities`, returning their current display + // rows as {selected, top}, with -1 where a row is no longer displayed. + virtual std::pair takeSavedRows() = 0; + +Q_SIGNALS: + // Emitted when background filtering starts and finishes. + void filteringChanged(bool active); + // Emitted when background sorting starts and finishes. + void sortingChanged(bool active); +}; + + +/*! Typed row storage for triage table models: rows sorted and filtered on the worker pool, plus + the row identity bookkeeping that restores positions across a content reload. +*/ +template +class TriageTableRowsModel : public TriageTableModel +{ +public: + using Predicate = typename BackgroundSortFilterRows::Predicate; + using PredicateFactory = typename BackgroundSortFilterRows::PredicateFactory; + using Comparator = typename BackgroundSortFilterRows::Comparator; + + int rowCount(const QModelIndex& parent) const override + { + Q_UNUSED(parent); + return static_cast(m_rows.displayCount()); + } + + const Row& rowAt(int row) const { return m_rows.displayAt(row); } + + void appendRows(std::vector rows) { m_rows.append(std::move(rows)); } + + bool isFiltering() const override { return m_rows.filtering(); } + bool isSorting() const override { return m_rows.sorting(); } + + size_t totalRowCount() const override { return m_rows.totalCount(); } + + void clearRows() override { m_rows.clear(); } + + void saveRowIdentities(int selectedRow, int topRow) override + { + m_savedSelection = selectedRow >= 0 ? std::optional(rowAt(selectedRow)) : std::nullopt; + m_savedTopRow = topRow >= 0 ? std::optional(rowAt(topRow)) : std::nullopt; + } + + std::pair takeSavedRows() override + { + int selectedRow = -1; + int topRow = -1; + const int count = static_cast(m_rows.displayCount()); + for (int row = 0; row < count && (selectedRow < 0 || topRow < 0); row++) + { + const Row& candidate = m_rows.displayAt(row); + if (selectedRow < 0 && m_savedSelection && rowsEquivalent(candidate, *m_savedSelection)) + selectedRow = row; + if (topRow < 0 && m_savedTopRow && rowsEquivalent(candidate, *m_savedTopRow)) + topRow = row; + } + m_savedSelection.reset(); + m_savedTopRow.reset(); + return {selectedRow, topRow}; + } + +protected: + explicit TriageTableRowsModel(QWidget* parent) : + TriageTableModel(parent), + m_rows({ + std::bind_front(&TriageTableRowsModel::beginResetModel, this), + std::bind_front(&TriageTableRowsModel::endResetModel, this), + std::bind_front(&TriageTableRowsModel::beginInsertRows, this, QModelIndex()), + std::bind_front(&TriageTableRowsModel::endInsertRows, this), + std::bind_front(&TriageTableRowsModel::filteringChanged, this), + std::bind_front(&TriageTableRowsModel::sortingChanged, this), + }) + {} + + // Whether two rows are the same row, for position save and restore. + virtual bool rowsEquivalent(const Row& a, const Row& b) const = 0; + + // Sort the display rows with `comparator`, reversed for descending order. + void sortRows(Comparator comparator, Qt::SortOrder order) + { + if (order == Qt::DescendingOrder) + m_rows.sort([comparator](const Row& a, const Row& b) { return comparator(b, a); }); + else + m_rows.sort(std::move(comparator)); + } + + // This base member is destroyed after derived model members, so predicates and comparators + // passed to it must capture worker-safe snapshots rather than reading derived model state. + BackgroundSortFilterRows m_rows; + // Rows captured by `saveRowIdentities` for restoring positions across a content reload. + std::optional m_savedSelection; + std::optional m_savedTopRow; +}; + + +/*! Shared behavior for the triage view's tables: dense monospace styling, filter target + plumbing, the busy overlay, and position save and restore across content reloads. +*/ +class TriageTableView : public QTableView, public FilterTarget +{ +Q_OBJECT + TriageTableModel* m_model = nullptr; + QLabel* m_busyOverlay = nullptr; + + void positionBusyOverlay(); + +public: + TriageTableModel* triageModel() const { return m_model; } + + // Capture the identities of the selected row and the first visible row ahead of a content + // reload. + void savePosition(); + // Re-select and re-scroll to the rows captured by `savePosition`, where still displayed. + void restorePosition(); + + void setFilter(const std::string& filter, FilterOptions options) override; + + void scrollToFirstItem() override; + void scrollToCurrentItem() override; + void ensureSelection() override; + void activateSelection() override; + +protected: + explicit TriageTableView(QWidget* parent); + + // Attach the model, set the column the table initially sorts by, and finish the setup that + // depends on the model. Must be called exactly once from the derived constructor. + void setTriageModel(TriageTableModel* model, int sortColumn); + + // Fit the column to the widest of its header and `contents` in the monospace font, plus room + // for the header's sort indicator. The column remains user-resizable. + void fitColumn(int column, const std::vector& contents); + + // Fit each fixed-content column to its possible contents. Called when the name sources + // change. Only the last column stretches. + virtual void applyDefaultColumnWidths() = 0; + + void resizeEvent(QResizeEvent* event) override; +}; + + +/*! A triage tab hosting an expensive table: a filter field above the table and a footer with a + status label. The content loads when the tab first comes on screen, is cleared after the tab + has been off screen for a delay, and the table's position is saved and restored across the + reload. +*/ +class TriageTablePanel : public QWidget +{ +Q_OBJECT + TriageTableView* m_table; + FilterEdit* m_filterEdit; + QHBoxLayout* m_footerLayout; + QLabel* m_statusLabel; + QTimer* m_clearTimer; + QString m_rowNoun; + + std::function m_loader; + std::function m_clearHandler; + std::function m_baselineCount; + + bool m_loadStarted = false; + bool m_viewVisible = false; + bool m_tabCurrent = false; + bool m_restorePending = false; + bool m_loaderOwnsStatus = false; + + void updateActive(); + void applyFilter(); + void updateStatusLabel(); + void restoreWhenIdle(); + +public: + // `rowNoun` names the rows in the footer's counts, e.g. "symbols". + TriageTablePanel(QWidget* parent, TriageTableView* table, const QString& filterPlaceholder, + QString rowNoun); + + TriageTableView* table() const { return m_table; } + FilterEdit* filterEdit() const { return m_filterEdit; } + // The loader owns the status label, for its progress text, until `finishLoad`. + QLabel* statusLabel() const { return m_statusLabel; } + + // Insert a widget ahead of the status label in the footer. + void addFooterWidget(QWidget* widget); + + // Add a checkable icon action to the filter field, highlighting the icon while checked. + QAction* addFilterToggle(const QString& iconPath, const QString& toolTip, + std::function onToggled); + + // Add a footer button that is enabled only while the table has a selection. + QPushButton* addSelectionButton(const QString& text); + + // Starts loading the table's content when the tab first comes on screen. Returns false if + // loading cannot begin yet, retrying on the next activation. + void setLoader(std::function loader); + // Invoked before the rows are cleared, to stop and free the owner's loader state. + void setClearHandler(std::function handler); + // Count of rows eligible for display ignoring any text filter, the denominator of the + // footer's filtered count. Defaults to the model's total row count. + void setBaselineCount(std::function baselineCount); + + // Track whether the triage view is on screen and which triage tab is current. + void setViewVisible(bool visible); + void setCurrentTabWidget(QWidget* current); + + // Call when the load has delivered every row: applies the selected sort, restores the saved + // position once it commits, and returns the status label to the panel. + void finishLoad(); + + // Discard the content immediately and reload it if the tab is on screen. + void resetContent(); + + // Free the content. The filter widgets keep their state and the position is saved, so a + // reload reproduces the same view. + void clearContent(); +};