Skip to content

Commit a3362f8

Browse files
authored
Add shortcut parameter to gui_register_ctx_menu (#193)
* Add `shortcut` parameter to `gui_register_ctx_menu` Lets plugins specify a Qt-style keyboard shortcut (e.g. "Ctrl+Shift+D") when registering a context menu / plugin action. Each decompiler backend translates the shortcut to its native form: - IDA: passed as the 4th arg of `idaapi.action_desc_t` ("Ctrl-Shift-D") - Binja: registered via `UIAction.registerAction` + `UIActionHandler.bindAction` - Ghidra: `ActionBuilder.keyBinding("ctrl shift D")` - angr: `QShortcut` on the workspace main window with `ApplicationShortcut` context, kept in `GenericBSAngrManagementPlugin._qshortcuts` to avoid garbage collection * bump and remove dead shortcut register
1 parent e8ece1a commit a3362f8

8 files changed

Lines changed: 98 additions & 11 deletions

File tree

libbs/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
__version__ = "3.4.1"
1+
__version__ = "3.5.0"
22

33

44
import logging

libbs/api/decompiler_interface.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,18 @@ def gui_show_type(self, type_name: str) -> None:
192192
"""
193193
pass
194194

195-
def gui_register_ctx_menu(self, name, action_string, callback_func, category=None) -> bool:
195+
def gui_register_ctx_menu(self, name, action_string, callback_func, category=None, shortcut=None) -> bool:
196+
"""
197+
Register a context menu / plugin action.
198+
199+
:param name: unique identifier for the action
200+
:param action_string: human-readable label shown in the menu
201+
:param callback_func: function to invoke when the action fires
202+
:param category: optional menu category / sub-path
203+
:param shortcut: optional keyboard shortcut in Qt format (e.g. "Ctrl+Shift+D").
204+
Implementations translate this to their native format. When the native
205+
decompiler cannot bind a shortcut programmatically, this is a no-op.
206+
"""
196207
raise NotImplementedError
197208

198209
def gui_ask_for_string(self, question, title="Plugin Question", default="") -> str:
@@ -253,8 +264,11 @@ def _parse_ctx_menu_actions(actions: dict[str, tuple[str, Callable]]) -> List[T
253264
def gui_register_ctx_menu_many(self, actions: dict[str, tuple[str, Callable]]):
254265
parsed_actions = self._parse_ctx_menu_actions(actions)
255266
for action in parsed_actions:
256-
category, name, action_string, callback_func = action
257-
self.gui_register_ctx_menu(name, action_string, callback_func, category=category)
267+
category, name, action_string, callback_func = action[:4]
268+
shortcut = action[4] if len(action) > 4 else None
269+
self.gui_register_ctx_menu(
270+
name, action_string, callback_func, category=category, shortcut=shortcut
271+
)
258272

259273
#
260274
# Override Mandatory API

libbs/decompilers/angr/compat.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ def __init__(self, workspace: Workspace, interface: Optional[AngrInterface] = No
2020
super().__init__(workspace)
2121
# (name, action_string, callback_func, category)
2222
self.context_menu_items = context_menu_items or []
23+
# Keep strong refs to QShortcut objects so Qt doesn't GC them
24+
self._qshortcuts: list = []
2325
if interface is None:
2426
from libbs.decompilers.angr.interface import AngrInterface
2527
self.interface = AngrInterface(
@@ -32,6 +34,34 @@ def __init__(self, workspace: Workspace, interface: Optional[AngrInterface] = No
3234
def teardown(self):
3335
pass
3436

37+
def register_shortcut(self, name: str, shortcut: str, callback_func, deci=None) -> bool:
38+
"""
39+
Register a keyboard shortcut bound to ``callback_func`` on the angr-management
40+
main window. The shortcut is an application-wide QShortcut so it fires from
41+
any focused widget.
42+
"""
43+
from PySide6.QtGui import QShortcut, QKeySequence
44+
from PySide6.QtCore import Qt
45+
from PySide6.QtWidgets import QApplication
46+
47+
parent = getattr(self.workspace, "main_window", None)
48+
if parent is None:
49+
app = QApplication.instance()
50+
if app is not None:
51+
for w in app.topLevelWidgets():
52+
if w.isWindow():
53+
parent = w
54+
break
55+
if parent is None:
56+
l.warning("No Qt main window available; cannot bind shortcut %s", shortcut)
57+
return False
58+
59+
qsc = QShortcut(QKeySequence(shortcut), parent)
60+
qsc.setContext(Qt.ApplicationShortcut)
61+
qsc.activated.connect(lambda: callback_func(None, deci=deci))
62+
self._qshortcuts.append(qsc)
63+
return True
64+
3565
#
3666
# Context Menus
3767
#

libbs/decompilers/angr/interface.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,13 +211,19 @@ def _init_gui_plugin(self, *args, **kwargs):
211211
def gui_goto(self, func_addr):
212212
self.workspace.jump_to(self.art_lifter.lower_addr(func_addr))
213213

214-
def gui_register_ctx_menu(self, name, action_string, callback_func, category=None) -> bool:
214+
def gui_register_ctx_menu(self, name, action_string, callback_func, category=None, shortcut=None) -> bool:
215215
if self.gui_plugin is None:
216216
l.critical("Cannot register context menu item without a GUI plugin.")
217217
return False
218218

219219
self._ctx_menu_items.append((name, action_string, callback_func, category))
220220
self.gui_plugin.context_menu_items = self._ctx_menu_items
221+
222+
if shortcut:
223+
try:
224+
self.gui_plugin.register_shortcut(name, shortcut, callback_func, deci=self)
225+
except Exception as e:
226+
l.warning("Failed to register angr shortcut %r for %s: %s", shortcut, name, e)
221227
return True
222228

223229
def gui_active_context(self) -> Optional[Context]:

libbs/decompilers/binja/interface.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ def gui_goto(self, func_addr) -> None:
131131
func_addr = self.art_lifter.lower_addr(func_addr)
132132
self.bv.offset = func_addr
133133

134-
def gui_register_ctx_menu(self, name, action_string, callback_func, category=None) -> bool:
134+
def gui_register_ctx_menu(self, name, action_string, callback_func, category=None, shortcut=None) -> bool:
135135
# TODO: this needs to have a wrapper function that passes the bv to the current deci
136136
# correct name, category, and action_string for Binja
137137
action_string = action_string.replace("/", "\\")
@@ -143,6 +143,19 @@ def gui_register_ctx_menu(self, name, action_string, callback_func, category=Non
143143
callback_func,
144144
is_valid=self.is_bn_func
145145
)
146+
147+
if shortcut and BN_UI_AVAILABLE:
148+
try:
149+
from binaryninjaui import UIAction, UIActionHandler
150+
action_name = f"{category}\\{action_string}" if category else action_string
151+
UIAction.registerAction(action_name, shortcut)
152+
# UIAction expects a callable taking a UIActionContext
153+
UIActionHandler.globalActions().bindAction(
154+
action_name, UIAction(lambda ctx: callback_func(None))
155+
)
156+
except Exception as e:
157+
l.warning(f"Failed to register Binja shortcut {shortcut!r} for {name}: {e}")
158+
146159
return True
147160

148161
def gui_ask_for_string(self, question, title="Plugin Question", default="") -> str:

libbs/decompilers/ghidra/hooks.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,23 @@ def create_data_monitor(deci: "GhidraDecompilerInterface"):
207207
return data_monitor
208208

209209

210-
def create_context_action(name, action_string, callback_func, category=None, plugin_name="libbs_ghidra", tool=None):
210+
def _qt_shortcut_to_ghidra(shortcut: str) -> str:
211+
"""Convert a Qt-style shortcut like "Ctrl+Shift+D" to Ghidra's "ctrl shift D"."""
212+
if not shortcut:
213+
return ""
214+
parts = shortcut.split("+")
215+
out = []
216+
for p in parts[:-1]:
217+
out.append(p.strip().lower())
218+
key = parts[-1].strip()
219+
out.append(key.upper() if len(key) == 1 else key)
220+
return " ".join(out)
221+
222+
223+
def create_context_action(
224+
name, action_string, callback_func, category=None,
225+
plugin_name="libbs_ghidra", tool=None, shortcut=None,
226+
):
211227
from .compat.imports import ProgramLocationActionContext, ActionBuilder
212228
def _invoke(ctx: ProgramLocationActionContext):
213229
threading.Thread(target=callback_func, daemon=True).start()

libbs/decompilers/ghidra/interface.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ def gui_run_on_main_thread(self, func, *args, **kwargs):
152152
self._main_thread_queue.put((func, args, kwargs))
153153
return self._results_queue.get()
154154

155-
def gui_register_ctx_menu(self, name, action_string, callback_func, category=None) -> bool:
155+
def gui_register_ctx_menu(self, name, action_string, callback_func, category=None, shortcut=None) -> bool:
156156
from .hooks import create_context_action
157157

158158
def callback_func_wrap(*args, **kwargs):
@@ -163,7 +163,7 @@ def callback_func_wrap(*args, **kwargs):
163163
raise
164164
create_context_action(
165165
name, action_string, callback_func_wrap, category=(category or "LibBS"),
166-
tool=self.flat_api.getState().getTool()
166+
tool=self.flat_api.getState().getTool(), shortcut=shortcut,
167167
)
168168
return True
169169

libbs/decompilers/ida/interface.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@
3333
_l = logging.getLogger(name=__name__)
3434

3535

36+
def _qt_shortcut_to_ida(shortcut: str) -> str:
37+
"""Convert a Qt-style shortcut like "Ctrl+Shift+D" to IDA's "Ctrl-Shift-D"."""
38+
if not shortcut:
39+
return ""
40+
return shortcut.replace("+", "-")
41+
42+
3643
#
3744
# Controller
3845
#
@@ -113,12 +120,13 @@ def gui_ask_for_string(self, question, title="Plugin Question", default="") -> s
113120
def gui_ask_for_choice(self, question: str, choices: list, title="Plugin Question") -> str:
114121
return ida_ui.ask_choice(question, choices, title=title)
115122

116-
def gui_register_ctx_menu(self, name, action_string, callback_func, category=None) -> bool:
123+
def gui_register_ctx_menu(self, name, action_string, callback_func, category=None, shortcut=None) -> bool:
124+
ida_shortcut = _qt_shortcut_to_ida(shortcut) if shortcut else ""
117125
action = idaapi.action_desc_t(
118126
name,
119127
action_string,
120128
compat.GenericAction(name, callback_func, deci=self),
121-
"",
129+
ida_shortcut,
122130
action_string,
123131
199
124132
)

0 commit comments

Comments
 (0)