aboutsummaryrefslogtreecommitdiffstats
path: root/idatui
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-25 11:51:38 +0200
committerblasty <blasty@local>2026-07-25 11:51:38 +0200
commitf4cb6eba274c411c503cbc8b4ed10fdfc6b70dcb (patch)
treec9bd1e37b16725099e4d0351f91211443a5e2bf1 /idatui
parentprojects: worker pool with memory-budgeted residency (phase 1b) (diff)
downloadida-tui-f4cb6eba274c411c503cbc8b4ed10fdfc6b70dcb.tar.gz
ida-tui-f4cb6eba274c411c503cbc8b4ed10fdfc6b70dcb.tar.xz
ida-tui-f4cb6eba274c411c503cbc8b4ed10fdfc6b70dcb.zip
projects: switch between a project's binaries in the TUI (phase 1c)
Wires the project model + worker pool into the app. Project mode is ADDITIVE — without --project the app is exactly the single-binary tool it was, which is what keeps the 167-check pilot meaningful. * IdaTui(project=...) builds a WorkerPool and opens the project's first binary; _open_worker_client asks the pool instead of spawning directly. * BinaryState snapshots what a switch leaves behind (program, func_index, nav, cur, view prefs, filter). Switching reuses the _after_reconnect shape: swap client+program, rebuild the index, reopen the entry. A still-resident binary restores instantly (Program + index are in memory); an evicted one gets a fresh worker but keeps its nav history, which is just addresses. * ProjectPalette (Ctrl+O, + a "Switch binary…" palette command): the project's binaries with resident/analysed/pinned/active state and memory, filterable. * launch.py --project FILE, creating the project when binaries are also given; stages everything up front so the source tree is never written to. Two bugs found while testing: * _did_auto_land is app-wide, but landing is per-binary: after the first binary landed, a cold switch never landed at all AND left the switch overlay up forever. Reset it per switch. * PRE-EXISTING: the status Static had Textual markup enabled, so a single-word bracket marker parses as a style tag and is silently eaten — [listing] and [pseudocode] have never actually rendered (only [split · listing] survived, because the · makes it an invalid tag). Status is plain text with brackets and symbol names, so markup=False. tests/test_project_ui.py: end-to-end pilot on two real binaries (18 checks) — boot, switcher contents, switch, per-binary index, both workers resident, switch back with state intact, return-to-where-you-were, and the promise that the source tree stays pristine while every artifact lands in the sidecar. Full single-binary suite unchanged at 167/2 (the standing flakes).
Diffstat (limited to 'idatui')
-rw-r--r--idatui/app.py243
-rw-r--r--idatui/launch.py63
2 files changed, 288 insertions, 18 deletions
diff --git a/idatui/app.py b/idatui/app.py
index 2576db5..a436eb4 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -20,7 +20,7 @@ import asyncio
import os
import re
import subprocess
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from rich.segment import Segment
from rich.style import Style
@@ -93,6 +93,24 @@ _ADDR_MARK_STRIP_RE = re.compile(r"\s*/\*\s*0x[0-9A-Fa-f]+\s*\*/")
@dataclass
+class BinaryState:
+ """Everything that makes one project binary's session resumable across a
+ switch. Addresses outlive the worker, so nav history survives eviction; the
+ Program/index only survive while that worker is still resident."""
+
+ label: str
+ program: object | None = None
+ func_index: object | None = None
+ nav: list = field(default_factory=list)
+ cur: object | None = None
+ pref: str = "listing"
+ active: str = "listing"
+ split: bool = False
+ filter_term: str = ""
+ dirty: bool = False
+
+
+@dataclass
class NavEntry:
ea: int
name: str
@@ -2213,6 +2231,93 @@ class StringsPalette(ModalScreen):
self.dismiss(None)
+class ProjectPalette(ModalScreen):
+ """The project's binaries; Enter switches to one. Shows which are resident
+ (a live worker, so switching is instant) vs cold (needs an open)."""
+
+ BINDINGS = [
+ Binding("escape", "close", "Close"),
+ Binding("down,ctrl+n", "cursor_down", show=False),
+ Binding("up,ctrl+p", "cursor_up", show=False),
+ ]
+
+ def __init__(self, entries: list[dict]) -> None:
+ super().__init__()
+ self._entries = entries
+ self._results: list[dict] = []
+
+ def compose(self) -> ComposeResult:
+ with Vertical(id="pal-box"):
+ yield Static(" binaries", id="pal-title")
+ yield Input(placeholder="filter binaries\u2026 \u2191\u2193 select \u00b7 "
+ "Enter switch \u00b7 Esc close", id="pal-input")
+ yield OptionList(id="pal-list")
+
+ def on_mount(self) -> None:
+ self._apply("")
+ self.query_one("#pal-input", Input).focus()
+
+ def on_input_changed(self, event: Input.Changed) -> None:
+ event.stop()
+ self._apply(event.value.strip())
+
+ def on_input_submitted(self, event: Input.Submitted) -> None:
+ event.stop()
+ self.action_choose()
+
+ def _apply(self, query: str) -> None:
+ q = query.lower()
+ rows = [e for e in self._entries
+ if not q or q in e["label"].lower() or q in e["source"].lower()]
+ self._results = rows
+ ol = self.query_one(OptionList)
+ ol.clear_options()
+ opts = []
+ for e in rows:
+ label = Text()
+ label.append("\u25b8 " if e["active"] else " ",
+ _S_MNEM if e["active"] else _S_DIM)
+ label.append(f"{e['label']:<22}", _S_LABEL)
+ if e["resident"]:
+ mb = e.get("memory_mb") or 0
+ label.append(f"resident {mb:>4}MB ", _S_MNEM)
+ elif e["analysed"]:
+ label.append("analysed ", _S_DIM)
+ else:
+ label.append("not opened ", _S_DIM)
+ if e["pinned"]:
+ label.append("pin ", _S_ADDR)
+ label.append(e["source"], _S_DIM)
+ opts.append(Option(label))
+ ol.add_options(opts)
+ if rows:
+ ol.highlighted = 0
+ self.query_one("#pal-title", Static).update(
+ f" binaries: {len(rows)} of {len(self._entries)}")
+
+ def action_cursor_down(self) -> None:
+ ol = self.query_one(OptionList)
+ if ol.option_count:
+ ol.highlighted = min((ol.highlighted or 0) + 1, ol.option_count - 1)
+
+ def action_cursor_up(self) -> None:
+ ol = self.query_one(OptionList)
+ if ol.option_count:
+ ol.highlighted = max((ol.highlighted or 0) - 1, 0)
+
+ def action_choose(self) -> None:
+ i = self.query_one(OptionList).highlighted
+ if i is not None and 0 <= i < len(self._results):
+ self.dismiss(self._results[i]["label"])
+
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
+ if 0 <= event.option_index < len(self._results):
+ self.dismiss(self._results[event.option_index]["label"])
+
+ def action_close(self) -> None:
+ self.dismiss(None)
+
+
# --------------------------------------------------------------------------- #
# Confirmation dialog
# --------------------------------------------------------------------------- #
@@ -2571,6 +2676,8 @@ class IdaCommands(Provider):
("Find symbol…", "fuzzy function finder (Ctrl+N)", app.action_symbols),
("Strings…", "browse every string in the binary (\")",
app.action_strings),
+ ("Switch binary…", "another binary in the project (Ctrl+O)",
+ app.action_switch_binary),
("Follow symbol under cursor", "jump to the referenced symbol (Enter)",
lambda: va("follow")),
("Show xrefs to symbol", "cross-references to the cursor symbol (x)",
@@ -2713,6 +2820,7 @@ class IdaTui(App):
Binding("backslash", "hex", "Hex"),
Binding("s", "toggle_split", "Split", show=False),
Binding("quotation_mark,shift+f12", "strings", "Strings", show=False),
+ Binding("ctrl+o", "switch_binary", "Binaries", show=False),
Binding("g", "goto", "Goto"),
Binding("slash", "filter", "Filter", show=False),
Binding("ctrl+b", "toggle_functions", "Names", show=False),
@@ -2721,9 +2829,22 @@ class IdaTui(App):
Binding("escape", "back", "Back"),
]
- def __init__(self, open_path: str, keepalive: bool = True,
- rpc_path: str | None = None, ttl: int = 1800) -> None:
+ def __init__(self, open_path: str | None = None, keepalive: bool = True,
+ rpc_path: str | None = None, ttl: int = 1800,
+ project=None) -> None:
super().__init__()
+ # Project mode is additive: with no project this is the plain
+ # single-binary app, unchanged.
+ self._project = project
+ self._pool = None
+ self._binary: str | None = None # active project binary (label)
+ self._states: dict[str, BinaryState] = {}
+ self._pending_restore = None # entry to reopen after a switch
+ if project is not None:
+ from .pool import WorkerPool
+ self._pool = WorkerPool(project, ttl=ttl)
+ self._binary = project.refs[0].label
+ open_path = project.refs[0].staged
self._open_path = open_path
self._ttl = ttl
self._do_keepalive = keepalive
@@ -2802,7 +2923,11 @@ class IdaTui(App):
gi.display = False
gi.can_focus = False
yield gi
- yield Static("connecting…", id="status")
+ # markup=False: the status is plain text full of [listing]/[split]/[label]
+ # markers and symbol names that may contain brackets. With Textual markup
+ # on, a single-word marker parses as a style tag and is silently eaten —
+ # which is why [listing] and [pseudocode] never actually rendered.
+ yield Static("connecting\u2026", id="status", markup=False)
yield Footer()
def on_mount(self) -> None:
@@ -2848,6 +2973,8 @@ class IdaTui(App):
# -- status helper ----------------------------------------------------- #
def _status(self, text: str) -> None:
+ if self._binary: # project mode: always say which binary you're in
+ text = f"[{self._binary}] {text}"
try:
self.query_one("#status", Static).update(text)
except Exception: # noqa: BLE001 -- status bar transiently unavailable
@@ -2969,6 +3096,14 @@ class IdaTui(App):
"""Our idalib-worker path: spawn the worker (it opens + analyzes the
binary in its own process) and connect. Returns the client, or None."""
from .worker_client import WorkerClient
+ if self._pool is not None: # project mode: the pool owns the workers
+ label = self._binary or self._project.refs[0].label
+ client = self._pool.get(label, progress=lambda m:
+ self.app.call_from_thread(self._status, m))
+ self._binary = label
+ self._pool.set_active(label)
+ self._open_path = self._project.by_label(label).staged
+ return client
if not self._open_path:
self.app.call_from_thread(
self._status, "the worker backend needs a binary path")
@@ -3018,6 +3153,13 @@ class IdaTui(App):
main() if it exists; else open the symbol picker so you're never staring
at a blank pane. Runs once (guarded by ``_cur``) and never steals focus
from a user who already navigated."""
+ entry = self._pending_restore
+ if entry is not None: # switched back to a binary we'd already explored
+ self._pending_restore = None
+ self._did_auto_land = True
+ self._dismiss_loading()
+ self._open_entry(entry, push=False)
+ return
if self._cur is not None or self._did_auto_land or self._func_index is None:
return
self._did_auto_land = True
@@ -3165,6 +3307,95 @@ class IdaTui(App):
f = self._func_index.by_addr(addr) if self._func_index else None
self._open_function(addr, f.name if f else hex(addr))
+ # -- projects: switching between the binaries of one target ------------ #
+ def action_switch_binary(self) -> None:
+ """Ctrl+O: pick another binary from the project (Ghidra-style)."""
+ if self._pool is None:
+ self._status("not a project — open one with --project")
+ return
+ if self._prompt_active():
+ return
+ self.push_screen(ProjectPalette(self._pool.status()),
+ self._on_binary_chosen)
+
+ def _on_binary_chosen(self, label: str | None) -> None:
+ if label and label != self._binary:
+ self._switch_binary(label)
+
+ def _switch_binary(self, label: str) -> None:
+ # Snapshot what we're leaving so coming back restores the view, then let
+ # the pool hand us a worker (spawning + evicting as the budget dictates).
+ if self._binary is not None:
+ self._states[self._binary] = BinaryState(
+ label=self._binary, program=self.program,
+ func_index=self._func_index, nav=list(self._nav), cur=self._cur,
+ pref=self._pref, active=self._active, split=self._split,
+ filter_term=self._filter_term, dirty=self._dirty)
+ self._loading_screen = LoadingScreen(label, note="switching\u2026")
+ self.push_screen(self._loading_screen)
+ self._do_switch(label)
+
+ @work(thread=True, exclusive=True, group="switch-binary")
+ def _do_switch(self, label: str) -> None:
+ assert self._pool is not None
+ try:
+ client = self._pool.get(label, progress=lambda m:
+ self.app.call_from_thread(self._status, m))
+ except Exception as e: # noqa: BLE001
+ self.app.call_from_thread(self._switch_failed, label, str(e))
+ return
+ st = self._states.get(label)
+ # The Program (and its caches) only survive while that worker does; a
+ # binary that was evicted comes back with a fresh one. Either way the nav
+ # history is just addresses, so it always survives.
+ reuse = (st is not None and st.program is not None
+ and getattr(st.program, "client", None) is client)
+ program = st.program if reuse else Program(client)
+ self.app.call_from_thread(self._after_switch, label, client, program,
+ st, reuse)
+
+ def _after_switch(self, label, client, program, st, reuse) -> None: # type: ignore[no-untyped-def]
+ self.client = client
+ self.program = program
+ self._binary = label
+ self._pool.set_active(label)
+ self._open_path = self._project.by_label(label).staged
+ self._pref = st.pref if st else "listing"
+ self._active = st.active if st else "listing"
+ self._split = st.split if st else False
+ self._filter_term = st.filter_term if st else ""
+ self._dirty = st.dirty if st else False
+ self._nav = list(st.nav) if st else []
+ self._decomp_return = None
+ self._split_eamap, self._split_ea2line, self._split_range = [], {}, None
+ self.query_one(DecompView).loaded_ea = None # belongs to the old binary
+ if reuse and st.func_index is not None:
+ self._cur = st.cur
+ self._func_index = st.func_index
+ self._apply_filter(self._filter_term) # repopulate the names table
+ self._dismiss_loading()
+ if self._cur is not None:
+ self._open_entry(self._cur, push=False)
+ else:
+ self._did_auto_land = False
+ self._auto_land()
+ return
+ # Cold (first visit, or the worker was evicted): rebuild the index, then
+ # land back where we were via _pending_restore.
+ self._cur = None
+ self._func_index = None
+ self._pending_restore = st.cur if st else None
+ # Landing is per-binary: without this the app-wide "already landed" flag
+ # from the first binary would stop the new one landing at all (and leave
+ # the switch overlay up forever).
+ self._did_auto_land = False
+ self._status("loading functions\u2026")
+ self._load_functions()
+
+ def _switch_failed(self, label: str, why: str) -> None:
+ self._dismiss_loading()
+ self._status(f"could not open {label}: {why}")
+
def action_strings(self) -> None:
"""'\"' / Shift+F12: browse every string in the binary (filterable);
Enter jumps to it in the unified listing."""
@@ -4934,5 +5165,7 @@ class IdaTui(App):
self._ka.stop()
if self.program is not None:
self.program.close()
- if self.client is not None:
+ if self._pool is not None:
+ self._pool.close_all() # saves each DB, then closes cleanly
+ elif self.client is not None:
self.client.close()
diff --git a/idatui/launch.py b/idatui/launch.py
index e2845e7..0ae63a1 100644
--- a/idatui/launch.py
+++ b/idatui/launch.py
@@ -45,7 +45,12 @@ def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(
prog="ida-tui",
description="Open a binary in the IDA TUI (private idalib worker).")
- p.add_argument("binary", help="binary to open and analyze")
+ p.add_argument("binary", nargs="*",
+ help="binary to open and analyze (several with --project "
+ "creates/extends that project)")
+ p.add_argument("--project", metavar="FILE",
+ help="open a multi-binary project (created from the given "
+ "binaries if FILE doesn't exist)")
p.add_argument("--ttl", type=int, default=1800,
help="worker idle-TTL seconds (default 1800)")
p.add_argument("--no-keepalive", action="store_true",
@@ -54,17 +59,49 @@ def main(argv: list[str] | None = None) -> int:
help="listen for RPC on this unix socket (puppeteer the TUI)")
args = p.parse_args(argv)
- binary = os.path.abspath(os.path.expanduser(args.binary))
- if not os.path.isfile(binary):
- _log(f"no such file: {binary}")
- return 2
- if not os.access(os.path.dirname(binary), os.W_OK):
- _log(f"directory not writable (IDA writes a .i64 there): "
- f"{os.path.dirname(binary)}")
- return 2
- swept = _sweep_locks(binary) # a crashed worker can leave the DB wedged
- if swept:
- _log(f"cleared {swept} stale lock file(s) from a crashed worker")
+ project = None
+ binary = None
+ if args.project:
+ from .project import Project, ProjectError
+ ppath = os.path.abspath(os.path.expanduser(args.project))
+ try:
+ if os.path.isfile(ppath):
+ project = Project.load(ppath)
+ for b in args.binary: # extend an existing project
+ project.add(b)
+ if args.binary:
+ project.save()
+ elif args.binary:
+ project = Project.create(ppath, args.binary)
+ _log(f"created project {ppath} with {len(project.refs)} binaries")
+ else:
+ _log(f"no such project: {ppath} (pass binaries to create it)")
+ return 2
+ except ProjectError as e:
+ _log(str(e))
+ return 2
+ # Everything IDA writes lives in the project's sidecar, so the source
+ # tree is never touched and never needs to be writable.
+ try:
+ project.stage_all(progress=lambda m: _log(m))
+ except ProjectError as e:
+ _log(str(e))
+ return 2
+ else:
+ if len(args.binary) != 1:
+ _log("give exactly one binary, or use --project for several")
+ return 2
+ binary = os.path.abspath(os.path.expanduser(args.binary[0]))
+ if not os.path.isfile(binary):
+ _log(f"no such file: {binary}")
+ return 2
+ if not os.access(os.path.dirname(binary), os.W_OK):
+ _log(f"directory not writable (IDA writes a .i64 there): "
+ f"{os.path.dirname(binary)}")
+ return 2
+ swept = _sweep_locks(binary) # a crashed worker can leave the DB wedged
+ if swept:
+ _log(f"cleared {swept} stale lock file(s) from a crashed worker")
# Hand off to the TUI (imported late so --help works without textual). It
# spawns the worker behind its loading overlay while auto-analysis runs.
@@ -75,7 +112,7 @@ def main(argv: list[str] | None = None) -> int:
return 1
rpc_path = os.path.abspath(os.path.expanduser(args.rpc)) if args.rpc else None
IdaTui(open_path=binary, keepalive=not args.no_keepalive,
- rpc_path=rpc_path, ttl=args.ttl).run()
+ rpc_path=rpc_path, ttl=args.ttl, project=project).run()
return 0