Files

388 lines
13 KiB
Python

import os
import logging
import weakref
from lighthouse.util.qt import *
from lighthouse.util.misc import plugin_resource
from lighthouse.util.disassembler import disassembler
from lighthouse.composer import ComposingShell
from lighthouse.ui.coverage_table import CoverageTableView, CoverageTableModel, CoverageTableController
from lighthouse.ui.coverage_combobox import CoverageComboBox
from lighthouse.ui.coverage_settings import TableSettingsMenu
logger = logging.getLogger("Lighthouse.UI.Overview")
#------------------------------------------------------------------------------
# Coverage Overview
#------------------------------------------------------------------------------
class CoverageOverview(object):
"""
The Coverage Overview Widget.
"""
def __init__(self, lctx, widget):
self.lctx = lctx
self.widget = widget
self.director = self.lctx.director
self.lctx.coverage_overview = self
self.initialized = False
self._refreshed_callback = None
# see the EventProxy class below for more details
self._events = EventProxy(self)
self.widget.installEventFilter(self._events)
# plugin_resource(os.path.join("icons", "overview.png"))
# initialize the plugin UI
self._ui_init()
# refresh the data UI such that it reflects the most recent data
self.refresh()
# register for cues from the director
self._refreshed_callback = self.director.refreshed(self.refresh)
#--------------------------------------------------------------------------
# Pseudo Widget Functions
#--------------------------------------------------------------------------
@property
def name(self):
if not self.widget:
return "Coverage Overview"
return self.widget.name
@property
def visible(self):
if not self.widget:
return False
return self.widget.visible
def terminate(self):
"""
The CoverageOverview is being hidden / deleted.
"""
if self.widget is None:
return
if self._refreshed_callback:
self.director.unregister_refreshed(self._refreshed_callback)
self._refreshed_callback = None
if self.lctx.coverage_overview is self:
self.lctx.coverage_overview = None
self.initialized = False
self._combobox = None
self._shell = None
self._toolbar = None
self._table_view = None
self._table_controller = None
self._table_model = None
self._settings_button = None
self._settings_menu = None
self._shell_elements = None
self._events = None
self.widget = None
#--------------------------------------------------------------------------
# Initialization - UI
#--------------------------------------------------------------------------
def _ui_init(self):
"""
Initialize UI elements.
"""
# initialize our ui elements
self._ui_init_table()
self._ui_init_toolbar()
self._ui_init_signals()
# layout the populated ui just before showing it
self._ui_layout()
def _ui_init_table(self):
"""
Initialize the coverage table.
"""
self._table_model = CoverageTableModel(self.lctx, self.widget)
self._table_controller = CoverageTableController(self.lctx, self._table_model)
self._table_view = CoverageTableView(self._table_controller, self._table_model, self.widget)
def _ui_init_toolbar(self):
"""
Initialize the coverage toolbar.
"""
# initialize child elements to go on the toolbar
self._ui_init_toolbar_elements()
self._ui_init_settings()
#
# create the 'toolbar', and customize its style. specifically, we are
# interested in tweaking the separator and padding between elements.
#
self._toolbar = QtWidgets.QToolBar()
self._toolbar.setStyle(QtWidgets.QStyleFactory.create("Windows"))
self._toolbar.setStyleSheet('QToolBar{padding:0;margin:0;}')
# populate the toolbar with all our subordinates
self._toolbar.addWidget(self._shell_elements)
self._toolbar.addWidget(self._settings_button)
def _ui_init_toolbar_elements(self):
"""
Initialize the coverage toolbar UI elements.
"""
# the composing shell
self._shell = ComposingShell(
self.lctx,
weakref.proxy(self._table_model),
weakref.proxy(self._table_view)
)
# the coverage combobox
self._combobox = CoverageComboBox(self.director)
# the splitter to make the shell / combobox resizable
self._shell_elements = QtWidgets.QSplitter(QtCore.Qt.Horizontal)
self._shell_elements.setStyleSheet(
"""
QSplitter
{
border: none;
}
QSplitter::handle
{
background-color: #909090;
width: 2px;
height: 2px;
margin: 0 0.5em 0 0.5em
}
QSplitter::handle:horizontal:hover
{
background-color: #3399FF;
}
""")
# add the child items we wish to put the 'splitter' between
# [ composing shell ] [SPLITTER] [ combobox ]
self._shell_elements.addWidget(self._shell)
self._shell_elements.addWidget(self._combobox)
# make the splitter responsive (animate) when hovered
self._shell_elements.handle(1).setAttribute(QtCore.Qt.WA_Hover)
# give the shell expansion preference over the combobox
self._shell_elements.setStretchFactor(0, 1)
def _ui_init_settings(self):
"""
Initialize the overview settings popup.
"""
# settings button
self._settings_button = QtWidgets.QToolButton()
self._settings_button.setIcon(get_qt_icon("SP_DialogResetButton"))
self._settings_button.setStyleSheet("QToolButton::menu-indicator{image: none;}")
# settings menu
self._settings_menu = TableSettingsMenu(self.widget)
def _ui_init_signals(self):
"""
Connect UI signals.
"""
self._settings_menu.connect_signals(self._table_controller, self.lctx)
self._settings_button.clicked.connect(self._ui_show_settings)
def _ui_layout(self):
"""
Layout the major UI elements of the widget.
"""
# layout the major elements of our widget
layout = QtWidgets.QGridLayout()
layout.setSpacing(int(get_dpi_scale()*5))
layout.addWidget(self._table_view)
layout.addWidget(self._toolbar)
# apply the layout to the containing form
self.widget.setLayout(layout)
#--------------------------------------------------------------------------
# Signal Handlers
#--------------------------------------------------------------------------
def _ui_show_settings(self):
"""
Handle a click of the settings button.
"""
delta = QtCore.QPoint(
-1*self._settings_menu.sizeHint().width(),
-1*self._settings_menu.sizeHint().height()
)
center = QtCore.QPoint(
int(self._settings_button.sizeHint().width()/2),
int(self._settings_button.sizeHint().height()/2)
)
where = self._settings_button.mapToGlobal(center+delta)
self._settings_menu.popup(where)
#--------------------------------------------------------------------------
# Refresh
#--------------------------------------------------------------------------
@disassembler.execute_ui
def refresh(self):
"""
Refresh the Coverage Overview.
"""
if not (self._table_model and self._shell and self._combobox):
return
self._table_model.refresh()
self._shell.refresh()
self._combobox.refresh()
@disassembler.execute_ui
def refresh_theme(self):
"""
Update visual elements based on theme change.
"""
if not (self._table_view and self._table_model and self._shell and self._combobox):
return
self._table_view.refresh_theme()
self._table_model.refresh_theme()
self._shell.refresh_theme()
self._combobox.refresh_theme()
#------------------------------------------------------------------------------
# Qt Event Filter
#------------------------------------------------------------------------------
debugger_docked = False
class EventProxy(QtCore.QObject):
#
# NOTE/COMPAT: QtCore.QEvent.Destroy not in IDA7? Just gonna ship our own...
# - https://doc.qt.io/qt-5/qevent.html#Type-enum
#
EventShow = 17
EventDestroy = 16
EventLayoutRequest = 76
EventUpdateLater = 78
def __init__(self, target):
super(EventProxy, self).__init__()
self._target = weakref.proxy(target)
self._first_hit = True
def eventFilter(self, source, event):
#
# hook the destroy event of the coverage overview widget so that we can
# cleanup after ourselves in the interest of stability
#
if int(event.type()) == self.EventDestroy:
source.removeEventFilter(self)
#
# XXX/V35: This is pretty hacky annoying stuff, but the lifetime
# of the CoverageOverview widget is managed internally by binja
# and gets deleted/cleaned up *after* a database is closed.
#
# it's best we just unload the lighthouse context in binja after
# the UI widgets have been destroyed (which aligns with IDA)
#
if disassembler.NAME == "BINJA":
lctx = self._target.lctx
core = lctx.core
core.binja_close_context(lctx.dctx)
# cleanup the UI / qt references for the CoverageOverview elements
self._target.terminate()
#
# this seems to be 'roughly' the last event triggered after the widget
# is done initializing in both IDA and Binja, but prior to the first
# user-triggered 'show' events.
#
# this is mostly to account for the fact that binja 'shows' the widget
# when it is initially created (outside of our control). this was
# causing lighthouse to automatically cache database metadata when
# every database was opened ...
#
elif int(event.type()) == self.EventLayoutRequest:
self._target.initialized = True
#
# this is used to hook a little bit after the 'show' event of the
# coverage overview. this is the most universal signal that the
# user is *actually* trying to use lighthouse in a meaningful way...
#
# we will use this moment first to check if they skipped straight to
# 'go' and opened the coverage overview without the metadata cache
# getting built.
#
# this case should only happen if the user does 'Show Coverage
# Overview' from the binja-controlled Window menu entry...
#
elif int(event.type()) == self.EventUpdateLater:
if self._target.visible and self._first_hit:
self._first_hit = False
if disassembler.NAME == "BINJA":
self._target.lctx.start()
if not self._target.director.metadata.cached:
self._target.director.refresh()
#
# this is an unknown event, but it seems to fire when the widget is
# being saved/restored by a QMainWidget (in IDA). we use this to try
# and ensure the Coverage Overview stays docked when flipping between
# Reversing and Debugging states in IDA.
#
# See issue #16 on github for more information.
#
elif int(event.type()) == 2002 and disassembler.NAME == "IDA":
import idaapi
#
# if the general registers IDA View exists, we make the assumption
# that the user has probably started debugging.
#
debug_mode = bool(idaapi.find_widget("General registers"))
#
# if this is the first time the user has started debugging, dock
# the coverage overview in the debug QMainWidget workspace. its
# dock status / position should persist future debugger launches.
#
global debugger_docked
if debug_mode and not debugger_docked:
idaapi.set_dock_pos(self._target._title, "Structures", idaapi.DP_TAB)
debugger_docked = True
return False