diff options
| author | blasty <blasty@local> | 2026-07-23 23:34:22 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-23 23:34:22 +0200 |
| commit | 8e6dfbf33eede528aaf60769364e060b49ac539e (patch) | |
| tree | bfc68e727538d8fc5a6efb780c063deb4f0a4e0b | |
| parent | fix: keep the pseudocode cursor/scroll when a rename re-decompiles (diff) | |
| download | ida-tui-8e6dfbf33eede528aaf60769364e060b49ac539e.tar.gz ida-tui-8e6dfbf33eede528aaf60769364e060b49ac539e.tar.xz ida-tui-8e6dfbf33eede528aaf60769364e060b49ac539e.zip | |
ux: show TUI chrome + a "loading…" overlay during binary open/analysis
Two parts:
1) A LoadingScreen modal is pushed on mount and dismissed once we land on a
function (or the symbol picker). Its note line mirrors _status, so you see
"opening… analyzing", then "N functions…", instead of staring at empty panes.
Esc hides it if you'd rather watch the status bar.
2) The real fix for the dead air BEFORE the TUI drew anything: the launcher used
to block on idb_open (full analysis of a big binary) *before* starting the
TUI, so the overlay only flashed after the wait was already over. Now the
launcher only adopts an already-open session (instant); otherwise it defers
the open to the TUI via open_path, and _connect does idb_open (with the
crashed-worker lock-sweep + retry moved here) while the chrome + overlay are
on screen. Added a ttl arg to IdaTui for the deferred open.
Verified: overlay pushed on mount, present through open+function-load, dismissed
on landing; TUI-driven open (open_path, db=None) opens echo and lands on main
with notes "opening echo — analyzing…" -> "echo — 128 functions…"; startup/
palette/view_toggle/disasm_nav/search/rename/follow_xrefs/decomp_nav/mouse/
listing_view/continuous_view/func_banners green.
| -rw-r--r-- | idatui/app.py | 97 | ||||
| -rw-r--r-- | idatui/launch.py | 23 |
2 files changed, 104 insertions, 16 deletions
diff --git a/idatui/app.py b/idatui/app.py index 122ffcc..462aaf2 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -1939,6 +1939,34 @@ class ConfirmScreen(ModalScreen): self.dismiss(False) +class LoadingScreen(ModalScreen): + """Startup overlay shown while the binary is opened + analyzed, so a slow + load (big binary) isn't just empty panes and dead air. The app updates the + note line with progress and dismisses it once we land on a function.""" + + BINDINGS = [Binding("escape", "hide", "Hide")] + + def __init__(self, title: str, note: str = "opening\u2026") -> None: + super().__init__() + self._title = title + self._note = note + + def compose(self) -> ComposeResult: + with Vertical(id="loading-box"): + yield Static(f"\u23f3 loading {self._title}", id="loading-title") + yield Static(self._note, id="loading-note") + yield Static("analyzing the binary\u2026 (Esc to hide)", id="loading-help") + + def update_note(self, text: str) -> None: + try: + self.query_one("#loading-note", Static).update(text) + except Exception: # noqa: BLE001 -- not mounted yet / already gone + pass + + def action_hide(self) -> None: + self.dismiss() + + # --------------------------------------------------------------------------- # # Struct editor (C-style local type editor) # --------------------------------------------------------------------------- # @@ -2239,6 +2267,12 @@ class IdaTui(App): background: $panel; padding: 1 2; } #confirm-msg { height: auto; } #confirm-help { height: 1; color: $text-muted; margin-top: 1; } + LoadingScreen { align: center middle; } + #loading-box { width: 64; height: auto; border: thick $accent; + background: $panel; padding: 1 2; } + #loading-title { height: 1; text-style: bold; } + #loading-note { height: 1; color: $text-muted; margin-top: 1; } + #loading-help { height: 1; color: $text-muted; } """ BINDINGS = [ @@ -2255,16 +2289,19 @@ class IdaTui(App): ] def __init__(self, url: str, db: str | None, keepalive: bool = True, - open_path: str | None = None, rpc_path: str | None = None) -> None: + open_path: str | None = None, rpc_path: str | None = None, + ttl: int = 1800) -> None: super().__init__() self._url = url self._db = db self._open_path = open_path + self._ttl = ttl self._do_keepalive = keepalive self._rpc_path = rpc_path self._rpc = None self.client: IDAClient | None = None self.program: Program | None = None + self._loading_screen: LoadingScreen | None = None self._ka = None self._nav: list[NavEntry] = [] self._func_index = None # the unfiltered FunctionIndex (source of truth) @@ -2337,10 +2374,30 @@ class IdaTui(App): # The names pane is an overlay now (Ctrl+N); focus the default code view # (pseudocode) so app bindings work before anything is opened. self.query_one(ListingView).focus() + # Show a loading overlay immediately so a slow open/analysis (big binary) + # isn't just dead air behind empty panes; dismissed once we land. + self._loading_screen = LoadingScreen(self._loading_title()) + self.push_screen(self._loading_screen) self._connect() if self._rpc_path: self._start_rpc() + def _loading_title(self) -> str: + if self._open_path: + return os.path.basename(self._open_path) + if self._db: + return str(self._db) + return "database" + + def _dismiss_loading(self) -> None: + ls = self._loading_screen + self._loading_screen = None + if ls is not None: + try: + ls.dismiss() + except Exception: # noqa: BLE001 -- already popped + pass + def _start_rpc(self) -> None: from .rpc import RpcServer self._rpc = RpcServer(self, self._rpc_path) @@ -2358,6 +2415,8 @@ class IdaTui(App): # -- status helper ----------------------------------------------------- # def _status(self, text: str) -> None: self.query_one("#status", Static).update(text) + if self._loading_screen is not None: # mirror progress into the overlay + self._loading_screen.update_note(text) # -- clipboard --------------------------------------------------------- # def _copy(self, text: str) -> int: @@ -2387,17 +2446,37 @@ class IdaTui(App): client = IDAClient(self._url, db=self._db) client.connect() if self._open_path is not None: - self.app.call_from_thread(self._status, f"opening {self._open_path}…") - import os path = os.path.abspath(os.path.expanduser(self._open_path)) - # Moderate idle TTL: the keepalive heartbeat (below) keeps the - # session alive while the TUI runs; once it exits the worker - # idles out and frees its slot (avoids piling up to max-workers). - res = client.call("idb_open", input_path=path, - idle_ttl_sec=1800, timeout=1800.0) + base = os.path.basename(path) + self.app.call_from_thread( + self._status, + f"opening {base} — analyzing (a first open can take a while)…") + + def _do_open(): + # Moderate idle TTL: the keepalive heartbeat (below) keeps the + # session alive while the TUI runs; once it exits the worker + # idles out and frees its slot (avoids piling to max-workers). + return client.call("idb_open", input_path=path, + idle_ttl_sec=self._ttl, timeout=1800.0) + + res = _do_open() + if not (isinstance(res, dict) and res.get("success")): + # A hard-killed worker can wedge the DB; sweep its stale + # unpacked lock files (never the .i64) and retry once. + try: + from .launch import _sweep_locks + swept = _sweep_locks(path) + except Exception: # noqa: BLE001 + swept = 0 + if swept: + self.app.call_from_thread( + self._status, f"recovering {base} (removed {swept} " + f"stale lock file(s))…") + res = _do_open() if not (isinstance(res, dict) and res.get("success")): err = res.get("error") if isinstance(res, dict) else res self.app.call_from_thread(self._status, f"open failed: {err}") + self.app.call_from_thread(self._dismiss_loading) return client.set_db(res["session"]["session_id"]) elif self._db is None: @@ -2411,6 +2490,7 @@ class IdaTui(App): program = Program(client) except Exception as e: # noqa: BLE001 self.app.call_from_thread(self._status, f"connect failed: {e}") + self.app.call_from_thread(self._dismiss_loading) return self.client = client self.program = program @@ -2456,6 +2536,7 @@ class IdaTui(App): if self._cur is not None or self._did_auto_land or self._func_index is None: return self._did_auto_land = True + self._dismiss_loading() # binary is up; hand the screen back to the views fn = self._entry_func() if fn is not None: self._open_function(fn.addr, fn.name) diff --git a/idatui/launch.py b/idatui/launch.py index c585668..b02bf05 100644 --- a/idatui/launch.py +++ b/idatui/launch.py @@ -202,21 +202,28 @@ def main(argv: list[str] | None = None) -> int: _log("supervisor is down and --no-server was given") return 1 - # 2) resolve the session (open/adopt the binary, or use --db / the sole one) + # 2) resolve the session. If a session is already open on this binary, adopt + # it (instant). Otherwise DON'T open here — defer the (possibly long) + # analysis to the TUI so its chrome + "loading…" overlay are on screen + # during the wait, instead of a blank terminal before the TUI even starts. db = args.db - if binary is not None: + open_path = None + if binary is not None and db is None: try: client = IDAClient(args.url) client.connect() - db = _open_binary(client, binary, args.ttl) - except IDAError as e: - _log(f"could not open {binary}: {e}") - return 1 + db = _existing_session(client, binary) + except Exception: # noqa: BLE001 -- probe only; the TUI will open it + db = None finally: try: client.close() except Exception: # noqa: BLE001 pass + if db: + _log(f"adopting the open session for {os.path.basename(binary)} ({db})") + else: + open_path = binary # TUI opens it with the loading overlay up # 3) hand off to the TUI (imported late so --help works without textual) try: @@ -225,8 +232,8 @@ def main(argv: list[str] | None = None) -> int: _log(f"the TUI needs textual; run with ~/ida-venv/bin/python ({e})") return 1 rpc_path = os.path.abspath(os.path.expanduser(args.rpc)) if args.rpc else None - IdaTui(url=args.url, db=db, keepalive=not args.no_keepalive, - rpc_path=rpc_path).run() + IdaTui(url=args.url, db=db, open_path=open_path, ttl=args.ttl, + keepalive=not args.no_keepalive, rpc_path=rpc_path).run() return 0 |
