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 <mr-tz@users.noreply.github.com>
This commit is contained in:
Willi Ballenthin
2025-11-03 13:29:06 +01:00
committed by GitHub
parent 5ea63770ba
commit cb2e2323f9
11 changed files with 105 additions and 23 deletions

1
.gitignore vendored
View File

@@ -122,6 +122,7 @@ scripts/perf/*.zip
*/.DS_Store */.DS_Store
Pipfile Pipfile
Pipfile.lock Pipfile.lock
uv.lock
/cache/ /cache/
.github/binja/binaryninja .github/binja/binaryninja
.github/binja/download_headless.py .github/binja/download_headless.py

View File

@@ -40,6 +40,7 @@
### capa Explorer IDA Pro plugin ### capa Explorer IDA Pro plugin
- add `ida-plugin.json` for inclusion in the IDA Pro plugin repository @williballenthin - 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 ### Development

View File

@@ -14,9 +14,9 @@
import ida_kernwin import ida_kernwin
from PyQt5 import QtCore
from capa.ida.plugin.error import UserCancelledError 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.ida.extractor import IdaFeatureExtractor
from capa.features.extractors.base_extractor import FunctionHandle from capa.features.extractors.base_extractor import FunctionHandle
@@ -24,7 +24,7 @@ from capa.features.extractors.base_extractor import FunctionHandle
class CapaExplorerProgressIndicator(QtCore.QObject): class CapaExplorerProgressIndicator(QtCore.QObject):
"""implement progress signal, used during feature extraction""" """implement progress signal, used during feature extraction"""
progress = QtCore.pyqtSignal(str) progress = Signal(str)
def update(self, text): def update(self, text):
"""emit progress update """emit progress update

View File

@@ -23,7 +23,6 @@ from pathlib import Path
import idaapi import idaapi
import ida_kernwin import ida_kernwin
import ida_settings import ida_settings
from PyQt5 import QtGui, QtCore, QtWidgets
import capa.main import capa.main
import capa.rules 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.model import CapaExplorerDataModel
from capa.ida.plugin.proxy import CapaExplorerRangeProxyModel, CapaExplorerSearchProxyModel from capa.ida.plugin.proxy import CapaExplorerRangeProxyModel, CapaExplorerSearchProxyModel
from capa.ida.plugin.extractor import CapaExplorerFeatureExtractor 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 from capa.features.extractors.base_extractor import FunctionHandle
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -1358,7 +1358,7 @@ class CapaExplorerForm(idaapi.PluginForm):
@param state: checked state @param state: checked state
""" """
if state == QtCore.Qt.Checked: if state:
self.limit_results_to_function(idaapi.get_func(idaapi.get_screen_ea())) self.limit_results_to_function(idaapi.get_func(idaapi.get_screen_ea()))
else: else:
self.range_model_proxy.reset_address_range_filter() 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): 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()) self.view_rulegen_features.filter_items_by_ea(idaapi.get_screen_ea())
else: else:
self.view_rulegen_features.show_all_items() self.view_rulegen_features.show_all_items()

View File

@@ -18,10 +18,10 @@ from typing import Iterator, Optional
import idc import idc
import idaapi import idaapi
from PyQt5 import QtCore
import capa.ida.helpers import capa.ida.helpers
from capa.features.address import Address, FileOffsetAddress, AbsoluteVirtualAddress 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): def info_to_name(display):
@@ -55,7 +55,7 @@ class CapaExplorerDataItem:
self.flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable self.flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
if self._can_check: 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: if self.pred:
self.pred.appendChild(self) self.pred.appendChild(self)

View File

@@ -18,7 +18,6 @@ from collections import deque
import idc import idc
import idaapi import idaapi
from PyQt5 import QtGui, QtCore
import capa.rules import capa.rules
import capa.ida.helpers import capa.ida.helpers
@@ -42,6 +41,7 @@ from capa.ida.plugin.item import (
CapaExplorerInstructionViewItem, CapaExplorerInstructionViewItem,
) )
from capa.features.address import Address, AbsoluteVirtualAddress 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 color used in IDA window
DEFAULT_HIGHLIGHT = 0xE6C700 DEFAULT_HIGHLIGHT = 0xE6C700
@@ -269,7 +269,7 @@ class CapaExplorerDataModel(QtCore.QAbstractItemModel):
visited.add(child_index) visited.add(child_index)
for idx in range(self.rowCount(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): def reset_ida_highlighting(self, item, checked):
"""reset IDA highlight for item """reset IDA highlight for item

View File

@@ -12,10 +12,8 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from PyQt5 import QtCore
from PyQt5.QtCore import Qt
from capa.ida.plugin.model import CapaExplorerDataModel from capa.ida.plugin.model import CapaExplorerDataModel
from capa.ida.plugin.qt_compat import Qt, QtCore
class CapaExplorerRangeProxyModel(QtCore.QSortFilterProxyModel): class CapaExplorerRangeProxyModel(QtCore.QSortFilterProxyModel):

View File

@@ -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"]

View File

@@ -18,7 +18,6 @@ from collections import Counter
import idc import idc
import idaapi import idaapi
from PyQt5 import QtGui, QtCore, QtWidgets
import capa.rules import capa.rules
import capa.engine import capa.engine
@@ -28,6 +27,7 @@ import capa.features.basicblock
from capa.ida.plugin.item import CapaExplorerFunctionItem from capa.ida.plugin.item import CapaExplorerFunctionItem
from capa.features.address import AbsoluteVirtualAddress, _NoAddress from capa.features.address import AbsoluteVirtualAddress, _NoAddress
from capa.ida.plugin.model import CapaExplorerDataModel from capa.ida.plugin.model import CapaExplorerDataModel
from capa.ida.plugin.qt_compat import QtGui, QtCore, Signal, QAction, QtWidgets
MAX_SECTION_SIZE = 750 MAX_SECTION_SIZE = 750
@@ -147,7 +147,7 @@ def calc_item_depth(o):
def build_action(o, display, data, slot): def build_action(o, display, data, slot):
""" """ """ """
action = QtWidgets.QAction(display, o) action = QAction(display, o)
action.setData(data) action.setData(data)
action.triggered.connect(lambda checked: slot(action)) action.triggered.connect(lambda checked: slot(action))
@@ -312,7 +312,7 @@ class CapaExplorerRulegenPreview(QtWidgets.QTextEdit):
class CapaExplorerRulegenEditor(QtWidgets.QTreeWidget): class CapaExplorerRulegenEditor(QtWidgets.QTreeWidget):
updated = QtCore.pyqtSignal() updated = Signal()
def __init__(self, preview, parent=None): def __init__(self, preview, parent=None):
""" """ """ """

View File

@@ -74,7 +74,7 @@ dependencies = [
# comments and context. # comments and context.
"pyyaml>=6", "pyyaml>=6",
"colorama>=0.4", "colorama>=0.4",
"ida-settings>=2.1.0,<3", # v3 has breaking changes "ida-settings>=3.1.0",
"ruamel.yaml>=0.18", "ruamel.yaml>=0.18",
"pefile>=2023.2.7", "pefile>=2023.2.7",
"pyelftools>=0.31", "pyelftools>=0.31",
@@ -104,7 +104,7 @@ dependencies = [
"networkx>=3", "networkx>=3",
"dnfile>=0.15.0", "dnfile>=0.17.0",
] ]
dynamic = ["version"] dynamic = ["version"]
@@ -162,11 +162,13 @@ build = [
"build==1.2.2" "build==1.2.2"
] ]
scripts = [ scripts = [
# can (optionally) be more lenient on dependencies here
# see comment on dependencies for more context
"jschema_to_python==1.2.3", "jschema_to_python==1.2.3",
"psutil==7.0.0", "psutil==7.1.2",
"stix2==3.0.1", "stix2==3.0.1",
"sarif_om==1.0.4", "sarif_om==1.0.4",
"requests==2.32.3", "requests>=2.32.4",
] ]
[tool.deptry] [tool.deptry]
@@ -198,7 +200,8 @@ known_first_party = [
"idc", "idc",
"java", "java",
"netnode", "netnode",
"PyQt5" "PyQt5",
"PySide6"
] ]
[tool.deptry.per_rule_ignores] [tool.deptry.per_rule_ignores]

View File

@@ -10,11 +10,11 @@ annotated-types==0.7.0
colorama==0.4.6 colorama==0.4.6
cxxfilt==0.3.0 cxxfilt==0.3.0
dncil==1.0.2 dncil==1.0.2
dnfile==0.16.4 dnfile==0.17.0
funcy==2.0 funcy==2.0
humanize==4.13.0 humanize==4.13.0
ida-netnode==3.0 ida-netnode==3.0
ida-settings==2.1.0 ida-settings==3.2.2
intervaltree==3.1.0 intervaltree==3.1.0
markdown-it-py==4.0.0 markdown-it-py==4.0.0
mdurl==0.1.2 mdurl==0.1.2
@@ -38,7 +38,7 @@ python-flirt==0.9.2
pyyaml==6.0.2 pyyaml==6.0.2
rich==14.2.0 rich==14.2.0
ruamel-yaml==0.18.6 ruamel-yaml==0.18.6
ruamel-yaml-clib==0.2.8 ruamel-yaml-clib==0.2.14
setuptools==80.9.0 setuptools==80.9.0
six==1.17.0 six==1.17.0
sortedcontainers==2.4.0 sortedcontainers==2.4.0