From cb2e2323f9dcfd712769173eda921884e47480ab Mon Sep 17 00:00:00 2001 From: Willi Ballenthin Date: Mon, 3 Nov 2025 13:29:06 +0100 Subject: [PATCH] explorer: add support for IDA 9.2 (#2723) * ida: add Qt compatibility layer for PyQt5 and PySide6 Introduce a new module `qt_compat.py` providing a unified import interface and API compatibility for Qt modules. It handles differences between PyQt5 (used in IDA <9.2) and PySide6 (used in IDA >=9.2). Update all plugin modules to import Qt components via this compatibility layer instead of directly importing from PyQt5. This enhances plugin compatibility across different IDA versions. thanks @mike-hunhoff! changelog * qt_compat: use __all__ rather than noqa --------- Co-authored-by: Moritz --- .gitignore | 1 + CHANGELOG.md | 1 + capa/ida/plugin/extractor.py | 4 +- capa/ida/plugin/form.py | 6 +-- capa/ida/plugin/item.py | 4 +- capa/ida/plugin/model.py | 4 +- capa/ida/plugin/proxy.py | 4 +- capa/ida/plugin/qt_compat.py | 79 ++++++++++++++++++++++++++++++++++++ capa/ida/plugin/view.py | 6 +-- pyproject.toml | 13 +++--- requirements.txt | 6 +-- 11 files changed, 105 insertions(+), 23 deletions(-) create mode 100644 capa/ida/plugin/qt_compat.py diff --git a/.gitignore b/.gitignore index 997cef4c..cef85dba 100644 --- a/.gitignore +++ b/.gitignore @@ -122,6 +122,7 @@ scripts/perf/*.zip */.DS_Store Pipfile Pipfile.lock +uv.lock /cache/ .github/binja/binaryninja .github/binja/download_headless.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e01ea6c2..a38839e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ ### capa Explorer IDA Pro plugin - add `ida-plugin.json` for inclusion in the IDA Pro plugin repository @williballenthin +- ida plugin: add Qt compatibility layer for PyQt5 and PySide6 support @williballenthin #2707 ### Development diff --git a/capa/ida/plugin/extractor.py b/capa/ida/plugin/extractor.py index a5b86f4e..a2f24f22 100644 --- a/capa/ida/plugin/extractor.py +++ b/capa/ida/plugin/extractor.py @@ -14,9 +14,9 @@ import ida_kernwin -from PyQt5 import QtCore from capa.ida.plugin.error import UserCancelledError +from capa.ida.plugin.qt_compat import QtCore, Signal from capa.features.extractors.ida.extractor import IdaFeatureExtractor from capa.features.extractors.base_extractor import FunctionHandle @@ -24,7 +24,7 @@ from capa.features.extractors.base_extractor import FunctionHandle class CapaExplorerProgressIndicator(QtCore.QObject): """implement progress signal, used during feature extraction""" - progress = QtCore.pyqtSignal(str) + progress = Signal(str) def update(self, text): """emit progress update diff --git a/capa/ida/plugin/form.py b/capa/ida/plugin/form.py index 36d104c8..800453bb 100644 --- a/capa/ida/plugin/form.py +++ b/capa/ida/plugin/form.py @@ -23,7 +23,6 @@ from pathlib import Path import idaapi import ida_kernwin import ida_settings -from PyQt5 import QtGui, QtCore, QtWidgets import capa.main import capa.rules @@ -51,6 +50,7 @@ from capa.ida.plugin.hooks import CapaExplorerIdaHooks from capa.ida.plugin.model import CapaExplorerDataModel from capa.ida.plugin.proxy import CapaExplorerRangeProxyModel, CapaExplorerSearchProxyModel from capa.ida.plugin.extractor import CapaExplorerFeatureExtractor +from capa.ida.plugin.qt_compat import QtGui, QtCore, QtWidgets from capa.features.extractors.base_extractor import FunctionHandle logger = logging.getLogger(__name__) @@ -1358,7 +1358,7 @@ class CapaExplorerForm(idaapi.PluginForm): @param state: checked state """ - if state == QtCore.Qt.Checked: + if state: self.limit_results_to_function(idaapi.get_func(idaapi.get_screen_ea())) else: self.range_model_proxy.reset_address_range_filter() @@ -1367,7 +1367,7 @@ class CapaExplorerForm(idaapi.PluginForm): def slot_checkbox_limit_features_by_ea(self, state): """ """ - if state == QtCore.Qt.Checked: + if state: self.view_rulegen_features.filter_items_by_ea(idaapi.get_screen_ea()) else: self.view_rulegen_features.show_all_items() diff --git a/capa/ida/plugin/item.py b/capa/ida/plugin/item.py index c8d3bdab..0510d5f9 100644 --- a/capa/ida/plugin/item.py +++ b/capa/ida/plugin/item.py @@ -18,10 +18,10 @@ from typing import Iterator, Optional import idc import idaapi -from PyQt5 import QtCore import capa.ida.helpers from capa.features.address import Address, FileOffsetAddress, AbsoluteVirtualAddress +from capa.ida.plugin.qt_compat import QtCore, qt_get_item_flag_tristate def info_to_name(display): @@ -55,7 +55,7 @@ class CapaExplorerDataItem: self.flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable if self._can_check: - self.flags = self.flags | QtCore.Qt.ItemIsUserCheckable | QtCore.Qt.ItemIsTristate + self.flags = self.flags | QtCore.Qt.ItemIsUserCheckable | qt_get_item_flag_tristate() if self.pred: self.pred.appendChild(self) diff --git a/capa/ida/plugin/model.py b/capa/ida/plugin/model.py index 405718a1..046dc1ea 100644 --- a/capa/ida/plugin/model.py +++ b/capa/ida/plugin/model.py @@ -18,7 +18,6 @@ from collections import deque import idc import idaapi -from PyQt5 import QtGui, QtCore import capa.rules import capa.ida.helpers @@ -42,6 +41,7 @@ from capa.ida.plugin.item import ( CapaExplorerInstructionViewItem, ) from capa.features.address import Address, AbsoluteVirtualAddress +from capa.ida.plugin.qt_compat import QtGui, QtCore # default highlight color used in IDA window DEFAULT_HIGHLIGHT = 0xE6C700 @@ -269,7 +269,7 @@ class CapaExplorerDataModel(QtCore.QAbstractItemModel): visited.add(child_index) for idx in range(self.rowCount(child_index)): - stack.append(child_index.child(idx, 0)) + stack.append(self.index(idx, 0, child_index)) def reset_ida_highlighting(self, item, checked): """reset IDA highlight for item diff --git a/capa/ida/plugin/proxy.py b/capa/ida/plugin/proxy.py index e8b45210..d76b8956 100644 --- a/capa/ida/plugin/proxy.py +++ b/capa/ida/plugin/proxy.py @@ -12,10 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -from PyQt5 import QtCore -from PyQt5.QtCore import Qt - from capa.ida.plugin.model import CapaExplorerDataModel +from capa.ida.plugin.qt_compat import Qt, QtCore class CapaExplorerRangeProxyModel(QtCore.QSortFilterProxyModel): diff --git a/capa/ida/plugin/qt_compat.py b/capa/ida/plugin/qt_compat.py new file mode 100644 index 00000000..7b3858a7 --- /dev/null +++ b/capa/ida/plugin/qt_compat.py @@ -0,0 +1,79 @@ +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Qt compatibility layer for capa IDA Pro plugin. + +Handles PyQt5 (IDA < 9.2) vs PySide6 (IDA >= 9.2) differences. +This module provides a unified import interface for Qt modules and handles +API changes between Qt5 and Qt6. +""" + +try: + # IDA 9.2+ uses PySide6 + from PySide6 import QtGui, QtCore, QtWidgets + from PySide6.QtGui import QAction + + QT_LIBRARY = "PySide6" + Signal = QtCore.Signal +except ImportError: + # Older IDA versions use PyQt5 + try: + from PyQt5 import QtGui, QtCore, QtWidgets + from PyQt5.QtWidgets import QAction + + QT_LIBRARY = "PyQt5" + Signal = QtCore.pyqtSignal + except ImportError: + raise ImportError("Neither PySide6 nor PyQt5 is available. Cannot initialize capa IDA plugin.") + +Qt = QtCore.Qt + + +def qt_get_item_flag_tristate(): + """ + Get the tristate item flag compatible with Qt5 and Qt6. + + Qt5 (PyQt5): Uses Qt.ItemIsTristate + Qt6 (PySide6): Qt.ItemIsTristate was removed, uses Qt.ItemIsAutoTristate + + ItemIsAutoTristate automatically manages tristate based on child checkboxes, + matching the original ItemIsTristate behavior where parent checkboxes reflect + the check state of their children. + + Returns: + int: The appropriate flag value for the Qt version + + Raises: + AttributeError: If the tristate flag cannot be found in the Qt library + """ + if QT_LIBRARY == "PySide6": + # Qt6: ItemIsTristate was removed, replaced with ItemIsAutoTristate + # Try different possible locations (API varies slightly across PySide6 versions) + if hasattr(Qt, "ItemIsAutoTristate"): + return Qt.ItemIsAutoTristate + elif hasattr(Qt, "ItemFlag") and hasattr(Qt.ItemFlag, "ItemIsAutoTristate"): + return Qt.ItemFlag.ItemIsAutoTristate + else: + raise AttributeError( + "Cannot find ItemIsAutoTristate in PySide6. " + + "Your PySide6 version may be incompatible with capa. " + + f"Available Qt attributes: {[attr for attr in dir(Qt) if 'Item' in attr]}" + ) + else: + # Qt5: Use the original ItemIsTristate flag + return Qt.ItemIsTristate + + +__all__ = ["qt_get_item_flag_tristate", "Signal", "QAction", "QtGui", "QtCore", "QtWidgets"] diff --git a/capa/ida/plugin/view.py b/capa/ida/plugin/view.py index ed188a84..a442f4d1 100644 --- a/capa/ida/plugin/view.py +++ b/capa/ida/plugin/view.py @@ -18,7 +18,6 @@ from collections import Counter import idc import idaapi -from PyQt5 import QtGui, QtCore, QtWidgets import capa.rules import capa.engine @@ -28,6 +27,7 @@ import capa.features.basicblock from capa.ida.plugin.item import CapaExplorerFunctionItem from capa.features.address import AbsoluteVirtualAddress, _NoAddress from capa.ida.plugin.model import CapaExplorerDataModel +from capa.ida.plugin.qt_compat import QtGui, QtCore, Signal, QAction, QtWidgets MAX_SECTION_SIZE = 750 @@ -147,7 +147,7 @@ def calc_item_depth(o): def build_action(o, display, data, slot): """ """ - action = QtWidgets.QAction(display, o) + action = QAction(display, o) action.setData(data) action.triggered.connect(lambda checked: slot(action)) @@ -312,7 +312,7 @@ class CapaExplorerRulegenPreview(QtWidgets.QTextEdit): class CapaExplorerRulegenEditor(QtWidgets.QTreeWidget): - updated = QtCore.pyqtSignal() + updated = Signal() def __init__(self, preview, parent=None): """ """ diff --git a/pyproject.toml b/pyproject.toml index 69bb10c2..31f1a03b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ dependencies = [ # comments and context. "pyyaml>=6", "colorama>=0.4", - "ida-settings>=2.1.0,<3", # v3 has breaking changes + "ida-settings>=3.1.0", "ruamel.yaml>=0.18", "pefile>=2023.2.7", "pyelftools>=0.31", @@ -104,7 +104,7 @@ dependencies = [ "networkx>=3", - "dnfile>=0.15.0", + "dnfile>=0.17.0", ] dynamic = ["version"] @@ -162,11 +162,13 @@ build = [ "build==1.2.2" ] scripts = [ + # can (optionally) be more lenient on dependencies here + # see comment on dependencies for more context "jschema_to_python==1.2.3", - "psutil==7.0.0", + "psutil==7.1.2", "stix2==3.0.1", "sarif_om==1.0.4", - "requests==2.32.3", + "requests>=2.32.4", ] [tool.deptry] @@ -198,7 +200,8 @@ known_first_party = [ "idc", "java", "netnode", - "PyQt5" + "PyQt5", + "PySide6" ] [tool.deptry.per_rule_ignores] diff --git a/requirements.txt b/requirements.txt index 0d0dd217..5f372a19 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,11 +10,11 @@ annotated-types==0.7.0 colorama==0.4.6 cxxfilt==0.3.0 dncil==1.0.2 -dnfile==0.16.4 +dnfile==0.17.0 funcy==2.0 humanize==4.13.0 ida-netnode==3.0 -ida-settings==2.1.0 +ida-settings==3.2.2 intervaltree==3.1.0 markdown-it-py==4.0.0 mdurl==0.1.2 @@ -38,7 +38,7 @@ python-flirt==0.9.2 pyyaml==6.0.2 rich==14.2.0 ruamel-yaml==0.18.6 -ruamel-yaml-clib==0.2.8 +ruamel-yaml-clib==0.2.14 setuptools==80.9.0 six==1.17.0 sortedcontainers==2.4.0