diff --git a/libbs/decompilers/ida/compat.py b/libbs/decompilers/ida/compat.py index b987f55..4652410 100644 --- a/libbs/decompilers/ida/compat.py +++ b/libbs/decompilers/ida/compat.py @@ -263,10 +263,15 @@ def get_types(structs=True, enums=True, typedefs=True) -> typing.Dict[str, Artif if structs and tif.is_struct(): bs_struct = bs_struct_from_tif(tif) - types[bs_struct.name] = bs_struct + # IDA exposes nested types inside anonymous unions/structs as separate + # numbered types whose name is "$PARENT_HASH::member" — that qualified + # form can't be looked up via get_named_type_tid, so skip it. + if bs_struct.name and "::" not in bs_struct.name: + types[bs_struct.name] = bs_struct elif enums and tif.is_enum(): bs_enum = enum_from_tif(tif) - types[bs_enum.name] = bs_enum + if bs_enum is not None: + types[bs_enum.name] = bs_enum return types @@ -1403,10 +1408,11 @@ def _deprecated_get_enum_mmebers(_enum_id, max_size=100) -> typing.Dict[str, int return enum_members -def get_enum_members(_enum: typing.Union["ida_typeinf.tinfo_t", int], max_size=100) -> typing.Dict[str, int]: +def get_enum_members(_enum: typing.Union["ida_typeinf.tinfo_t", int], max_size=100) -> typing.Optional[typing.Dict[str, int]]: """ - _enum can either be an ida_typeinf.tinfo_t or an int (the old enum id system) - + _enum can either be an ida_typeinf.tinfo_t or an int (the old enum id system). + Returns None if the tif reports as an enum but IDA can't fetch its details + (e.g. typedef wrappers that pass tif.is_enum() but aren't real enums). """ if not new_ida_typing_system(): _enum_id: int = _enum @@ -1416,8 +1422,8 @@ def get_enum_members(_enum: typing.Union["ida_typeinf.tinfo_t", int], max_size=1 enum_tif: "ida_typeinf.tinfo_t" = _enum ei = ida_typeinf.enum_type_data_t() if not enum_tif.get_enum_details(ei): - _l.error("IDA failed to get enum details for %s", enum_tif) - return {} + _l.debug("IDA could not get enum details for %s; treating as non-enum", enum_tif) + return None enum_members = {} for e_memb in ei: @@ -1442,6 +1448,8 @@ def enum_from_tif(tif): return None enum_members = get_enum_members(tif) + if enum_members is None: + return None return Enum(enum_name, enum_members) @@ -1459,6 +1467,8 @@ def enum(name) -> typing.Optional[Enum]: enum_name = str(_enum.get_type_name()) if new_enums else idc.get_enum_name(_enum) enum_members = get_enum_members(_enum) + if enum_members is None: + return None return Enum(enum_name, enum_members) @@ -1736,18 +1746,17 @@ def has_older_hexrays_version(): @execute_write def get_decompiler_version() -> typing.Optional[Version]: wait_for_idc_initialization() - try: - _vers = ida_hexrays.get_hexrays_version() - except Exception as e: - _l.critical("Failed to get decompiler version: %s", e) - return None - try: - vers = Version(_vers) - except TypeError: + # init_hexrays_plugin() must succeed before any other ida_hexrays.* call — + # otherwise IDA emits "Hex-Rays Decompiler got called from Python without + # being loaded" warnings (e.g. during early plugin load before Hex-Rays + # finishes wiring up). Returns False if the decompiler is genuinely + # unavailable (headless without license, etc.); the caller should treat + # None as "decompiler unavailable, skip version-gated behavior". + if not ida_hexrays.init_hexrays_plugin(): return None - return vers + return Version(ida_hexrays.get_hexrays_version()) # @@ -1902,6 +1911,10 @@ def run(self, arg): pass def term(self): + try: + self.interface._term_gui_hooks() + except Exception: + _l.exception("Error tearing down GUI hooks") self.interface.decompiler_closed_event() del self.interface diff --git a/libbs/decompilers/ida/hooks.py b/libbs/decompilers/ida/hooks.py index af40964..cb2d6e6 100644 --- a/libbs/decompilers/ida/hooks.py +++ b/libbs/decompilers/ida/hooks.py @@ -539,6 +539,25 @@ def refresh_pseudocode(self, vu): self._send_decompilation_event(vu.cfunc) return 0 + def curpos(self, vu): + # Hex-Rays cursor moved within pseudocode. View_Hooks.view_curpos doesn't + # fire reliably for pseudocode caret motion (esp. arrow-key navigation), + # so mirror it through to the same context-update path the disassembly + # view uses, so "users on current function" updates promptly. + if not (self.interface.force_click_recording or self.interface.artifact_watchers_started): + return 0 + widget = vu.ct if hasattr(vu, "ct") else None + if widget is None: + return 0 + ctx = compat.view_to_bs_context(widget, action=Context.ACT_VIEW_OPEN) + if ctx is None: + return 0 + ctx = self.interface.art_lifter.lift(ctx) + ctx.last_change = datetime.datetime.now(tz=datetime.timezone.utc) + self.interface._gui_active_context = ctx + self.interface.gui_context_changed(ctx) + return 0 + # # helpers # @@ -636,6 +655,12 @@ def view_click(self, view, event): def view_activated(self, view: "TWidget *"): self._handle_view_event(view, action_type=Context.ACT_VIEW_OPEN) + def view_curpos(self, view: "TWidget *"): + # fires when the cursor (current position) moves within the view — + # includes keyboard navigation (G/jump, arrow keys, double-clicking + # in the Functions list, etc.), which view_click/view_activated miss. + self._handle_view_event(view, action_type=Context.ACT_VIEW_OPEN) + def view_mouse_moved(self, view: "TWidget *", event: "view_mouse_event_t"): if self.interface.track_mouse_moves: self._handle_view_event(view, ida_event=event, action_type=Context.ACT_MOUSE_MOVE) diff --git a/libbs/decompilers/ida/interface.py b/libbs/decompilers/ida/interface.py index 98d25e0..9eb6859 100755 --- a/libbs/decompilers/ida/interface.py +++ b/libbs/decompilers/ida/interface.py @@ -96,6 +96,19 @@ def _init_gui_hooks(self): for hook in self._ui_hooks: hook.hook() + def _term_gui_hooks(self): + """ + Symmetric teardown for _init_gui_hooks. Must run before IDAPython tears + down — otherwise a still-registered hook can fire during shutdown + events (e.g. term_database) and try to re-enter a finalized Python. + """ + for hook in self._ui_hooks: + try: + hook.unhook() + except Exception: + _l.exception("Failed to unhook %r", hook) + self._ui_hooks = [] + def _init_gui_plugin(self, *args, **kwargs): self.decompiler_opened_event() plugin_cls_name = self._plugin_name + "_cls" @@ -246,7 +259,7 @@ def start_artifact_watchers(self): super().start_artifact_watchers() # TODO: this is a hack for backwards compatibility and should be removed in IDA 9 idb_hook = IDBHooks(self) - if self.dec_version < Version("8.4"): + if self.decompiler_available and self.dec_version < Version("8.4"): idb_hook.local_types_changed = lambda: 0 else: # this code in this block must exist in 9.0, so don't delete it! @@ -355,7 +368,7 @@ def _global_vars(self, **kwargs) -> Dict[int, GlobalVariable]: # structs def _set_struct(self, struct: Struct, header=True, members=True, **kwargs) -> bool: data_changed = False - if (self.dec_version < Version("8.3")) and "gcc_va_list" in struct.name: + if self.decompiler_available and self.dec_version < Version("8.3") and "gcc_va_list" in struct.name: _l.critical("Syncing the struct %s in IDA Pro 8.2 <= will cause a crash. Skipping...", struct.name) return False diff --git a/libbs/ui/qt_objects.py b/libbs/ui/qt_objects.py index a2101ad..dbabd16 100644 --- a/libbs/ui/qt_objects.py +++ b/libbs/ui/qt_objects.py @@ -12,6 +12,7 @@ QComboBox, QDialog, QFileDialog, + QFormLayout, QGridLayout, QGroupBox, QHBoxLayout, @@ -49,6 +50,7 @@ QToolTip, QStackedLayout, QDateTimeEdit, + QSplitter, ) from PySide6.QtGui import ( QFontDatabase, @@ -80,6 +82,7 @@ QComboBox, QDialog, QFileDialog, + QFormLayout, QGridLayout, QGroupBox, QHBoxLayout, @@ -118,6 +121,7 @@ QToolTip, QStackedLayout, QDateTimeEdit, + QSplitter, ) from PyQt5.QtGui import ( QFontDatabase,