aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md8
-rw-r--r--idatui/rpc.py7
-rw-r--r--tools/demo.py325
3 files changed, 339 insertions, 1 deletions
diff --git a/README.md b/README.md
index 88e7e00..86883d4 100644
--- a/README.md
+++ b/README.md
@@ -139,6 +139,14 @@ python -m idatui.drive rename sub_5BE0 foo # goto + rename
→ [`docs/RPC.md`](docs/RPC.md)
+There's a scripted feature tour on top of it, for screen recordings — it spawns
+its own pane on a scratch copy, drives ten scenes through the real prompts, and
+reverts its own edits:
+
+```sh
+python tools/demo.py --spawn # --speed 0.5 to rehearse, --list for the scenes
+```
+
**Splash** — the logo renders as a real image on terminals that speak the kitty
graphics protocol, `logo.ans` everywhere else. Support is detected by *asking the
terminal*, not by sniffing `$TERM` (under a multiplexer, every variable you'd
diff --git a/idatui/rpc.py b/idatui/rpc.py
index 225aeb2..2a63812 100644
--- a/idatui/rpc.py
+++ b/idatui/rpc.py
@@ -1148,7 +1148,12 @@ class RpcServer:
return await self._press(["tab"], _toggled, timeout, "toggle_view")
if method == "hex":
- return await self._press(["backslash"], lambda: app.is_hex,
+ # Backslash TOGGLES the hex view, so the predicate has to be "the
+ # mode flipped", not "we are in hex". Waiting for is_hex meant the
+ # call that LEAVES hex could never be satisfied and always timed
+ # out -- a driver could open the hex view but never close it.
+ was_hex = app.is_hex
+ return await self._press(["backslash"], lambda: app.is_hex != was_hex,
timeout, "hex")
if method == "graph":
return await self._graph(params, timeout)
diff --git a/tools/demo.py b/tools/demo.py
new file mode 100644
index 0000000..5a23a1c
--- /dev/null
+++ b/tools/demo.py
@@ -0,0 +1,325 @@
+#!/usr/bin/env python3
+"""Drive ida-tui through a feature tour, for a screen recording.
+
+Everything here goes through the same RPC layer an agent uses, so the semantic
+verbs type into the real prompts character by character -- which is the whole
+point for a recording: it looks like someone using it, because it is the app
+being used.
+
+ # simplest: it spawns its own pane on a scratch copy and cleans up after
+ python tools/demo.py --spawn
+
+ # or record a session you set up yourself (your pane, your size, your zoom)
+ ./ida-tui /path/to/bash --rpc /tmp/ida.sock
+ python tools/demo.py --sock /tmp/ida.sock
+
+ python tools/demo.py --list # the beats, without running
+ python tools/demo.py --sock S --speed 0.5 # half the pauses (rehearsal)
+ python tools/demo.py --sock S --only graph,split
+
+Edits (rename/comment) are reverted at the end, so the tour is repeatable and
+a scratch database is not left renamed. --spawn works on a COPY of the target
+so the tracked .i64 is never touched at all.
+"""
+from __future__ import annotations
+
+import argparse
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+import time
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from idatui.rpcclient import RpcClient # noqa: E402
+
+REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+DEFAULT_TARGET = os.path.join(REPO, "targets", "bash")
+
+#: Typing speed for the prompts, ms per character. The app's own default is 35;
+#: a little slower reads better on video without dragging.
+TYPE_MS = 45
+
+
+class Demo:
+ """A sequence of beats against one live TUI."""
+
+ def __init__(self, client: RpcClient, speed: float = 1.0, quiet: bool = False):
+ self.c = client
+ self.speed = speed
+ self.quiet = quiet
+ self.undo: list[tuple[str, dict]] = []
+
+ # -- pacing ------------------------------------------------------------ #
+ def beat(self, seconds: float = 1.0) -> None:
+ """Let the viewer read. Scaled by --speed."""
+ time.sleep(max(0.0, seconds * self.speed))
+
+ def say(self, text: str) -> None:
+ if not self.quiet:
+ print(f" \033[36m{text}\033[0m", flush=True)
+
+ def do(self, method: str, **params):
+ """One RPC call. Failures are reported and skipped, never fatal: a demo
+ that dies halfway through a take is worse than one that misses a beat."""
+ try:
+ return self.c.call(method, **params)
+ except Exception as exc: # noqa: BLE001
+ print(f" \033[31m! {method}: {exc}\033[0m", file=sys.stderr, flush=True)
+ return None
+
+ # -- scenes ------------------------------------------------------------ #
+ def scene_open(self):
+ """Land on a real function and show the listing."""
+ self.say("goto main")
+ self.do("goto", target="main", delay_ms=TYPE_MS)
+ self.beat(1.5)
+ self.say("scroll the listing")
+ self.do("move", dir="down", n=12)
+ self.beat(0.8)
+ self.do("move", dir="pagedown")
+ self.beat(1.0)
+ self.do("move", dir="top")
+ self.beat(0.8)
+
+ def scene_pseudocode(self):
+ """Hex-Rays, and back."""
+ self.say("F5 -> pseudocode")
+ self.do("toggle_view")
+ self.beat(2.0)
+ self.do("move", dir="down", n=8)
+ self.beat(1.2)
+ self.say("back to the listing")
+ self.do("toggle_view")
+ self.beat(1.0)
+
+ def scene_opfmt(self):
+ """The literal-format ring, IDA's `o`."""
+ self.say("cycle a literal's format (o)")
+ shown = self.do("opfmt", mode="show")
+ if shown is None:
+ return
+ for fmt in ("dec", "hex", "bin", "default"):
+ self.do("opfmt", mode=fmt)
+ self.beat(0.9)
+
+ def scene_follow(self):
+ """Follow a call and come back."""
+ self.say("follow a call, then Escape back")
+ self.do("follow")
+ self.beat(1.8)
+ self.do("back")
+ self.beat(1.0)
+
+ def scene_graph(self):
+ """The CFG: zoom, minimap, walking edges."""
+ self.say("space -> control-flow graph")
+ self.do("graph", action="open")
+ self.beat(2.2)
+ self.say("zoom levels")
+ for _ in range(2):
+ self.do("keys", keys=["z"])
+ self.beat(1.1)
+ self.say("minimap")
+ self.do("keys", keys=["m"])
+ self.beat(1.2)
+ self.say("walk the edges")
+ for _ in range(3):
+ self.do("keys", keys=["J"])
+ self.beat(0.7)
+ self.do("keys", keys=["m"])
+ self.do("graph", action="close")
+ self.beat(1.0)
+
+ def scene_split(self):
+ """Listing and pseudocode, cursor-synced."""
+ self.say("s -> split view, cursor-synced")
+ self.do("keys", keys=["s"])
+ self.beat(2.2)
+ for _ in range(6):
+ self.do("move", dir="down", n=2)
+ self.beat(0.5)
+ self.do("keys", keys=["s"])
+ self.beat(1.0)
+
+ def scene_xrefs(self):
+ """Who calls this."""
+ self.say("x -> xrefs")
+ self.do("xrefs")
+ self.beat(2.0)
+ self.do("close")
+ self.beat(0.8)
+
+ def scene_edit(self):
+ """Rename and comment -- typed into the real prompts, then reverted."""
+ fn = self.do("functions", filter="sub_", limit=1) or []
+ target = fn[0] if isinstance(fn, list) and fn else None
+ if not target:
+ self.say("(no sub_ function to rename; skipping)")
+ return
+ name, ea = target.get("name"), target.get("ea")
+ self.say(f"rename {name} -> demo_dispatch")
+ self.do("goto", target=name, delay_ms=TYPE_MS)
+ self.beat(0.8)
+ self.do("rename", name="demo_dispatch", delay_ms=TYPE_MS)
+ self.undo.append(("rename", {"addr": ea, "name": name}))
+ self.beat(1.8)
+ self.say("comment the line")
+ self.do("comment", text="reached from the command dispatcher")
+ self.undo.append(("comment", {}))
+ self.beat(2.0)
+
+ def scene_browsers(self):
+ """Strings, symbols, structs, hex."""
+ self.say('" -> strings')
+ self.do("keys", keys=["quotation_mark"])
+ self.beat(2.0)
+ self.do("close")
+ self.beat(0.6)
+
+ self.say("ctrl+n -> symbol palette")
+ self.do("symbols", query="exec")
+ self.beat(2.0)
+ self.do("close")
+ self.beat(0.6)
+
+ self.say("ctrl+t -> structs")
+ self.do("structs")
+ self.beat(2.0)
+ self.do("close")
+ self.beat(0.6)
+
+ self.say("\\ -> hex view")
+ self.do("hex")
+ self.beat(2.0)
+ self.do("hex")
+ self.beat(0.8)
+
+ def scene_search(self):
+ """Incremental search in the code view."""
+ self.say("/ -> search")
+ self.do("search", term="call")
+ self.beat(1.8)
+ self.do("close")
+ self.beat(0.8)
+
+ # -- cleanup ----------------------------------------------------------- #
+ def revert(self):
+ """Undo the demo's edits so the take is repeatable."""
+ for kind, args in reversed(self.undo):
+ if kind == "rename" and args.get("addr") is not None:
+ self.do("rename_many",
+ items=[{"addr": hex(args["addr"]), "name": args["name"]}])
+ elif kind == "comment":
+ self.do("comment", text="")
+ # Re-navigate so the view shows the reverted name: the nav entry caches
+ # the name it was opened with, so without this a recording ends on a
+ # screen still showing the demo's rename.
+ if self.undo:
+ self.do("goto", target="main")
+ self.undo.clear()
+
+
+SCENES = [
+ ("open", Demo.scene_open),
+ ("pseudocode", Demo.scene_pseudocode),
+ ("opfmt", Demo.scene_opfmt),
+ ("follow", Demo.scene_follow),
+ ("graph", Demo.scene_graph),
+ ("split", Demo.scene_split),
+ ("xrefs", Demo.scene_xrefs),
+ ("edit", Demo.scene_edit),
+ ("browsers", Demo.scene_browsers),
+ ("search", Demo.scene_search),
+]
+
+
+def spawn_pane(target: str) -> tuple[str, str, str]:
+ """Spawn a TUI pane on a COPY of ``target``. Returns (sock, pane, tmpdir)."""
+ import json
+ tmp = tempfile.mkdtemp(prefix="idatui-demo-")
+ copy = os.path.join(tmp, os.path.basename(target))
+ shutil.copy2(target, copy)
+ for suffix in (".i64",): # reuse the analysis if present
+ if os.path.exists(target + suffix):
+ shutil.copy2(target + suffix, copy + suffix)
+ out = subprocess.run(
+ [sys.executable, "-m", "idatui.pane", "spawn", "--open", copy],
+ cwd=REPO, capture_output=True, text=True, check=True).stdout
+ row = json.loads(out)
+ return row["sock"], row.get("pane", ""), tmp
+
+
+def main(argv=None) -> int:
+ ap = argparse.ArgumentParser(description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+ ap.add_argument("--sock", help="RPC socket of a running TUI (see --rpc)")
+ ap.add_argument("--spawn", action="store_true",
+ help="spawn a pane on a scratch copy, then tear it down")
+ ap.add_argument("--target", default=DEFAULT_TARGET, help="binary for --spawn")
+ ap.add_argument("--speed", type=float, default=1.0,
+ help="pause multiplier; 0.5 = twice as fast (default 1.0)")
+ ap.add_argument("--only", help="comma-separated scene names")
+ ap.add_argument("--list", action="store_true", help="list scenes and exit")
+ ap.add_argument("--no-revert", action="store_true",
+ help="keep the demo's rename/comment")
+ ap.add_argument("--quiet", action="store_true", help="no operator narration")
+ args = ap.parse_args(argv)
+
+ if args.list:
+ for name, fn in SCENES:
+ print(f" {name:12s} {(fn.__doc__ or '').splitlines()[0]}")
+ return 0
+
+ sock, pane, tmp = args.sock, "", ""
+ if args.spawn:
+ if not os.path.isfile(args.target):
+ print(f"no such binary: {args.target}", file=sys.stderr)
+ return 2
+ print(f"spawning a pane on a copy of {os.path.basename(args.target)}…")
+ sock, pane, tmp = spawn_pane(args.target)
+ print(f" sock={sock} pane={pane}")
+ if not sock:
+ ap.error("pass --sock <path> or --spawn")
+
+ wanted = set(args.only.split(",")) if args.only else None
+ scenes = [(n, f) for n, f in SCENES if wanted is None or n in wanted]
+
+ rc = 0
+ try:
+ with RpcClient(sock) as client:
+ ready = client.call("ping")
+ if not ready.get("complete"):
+ print(" waiting for the function index…", flush=True)
+ for _ in range(600):
+ if client.call("ping").get("complete"):
+ break
+ time.sleep(0.5)
+ demo = Demo(client, speed=args.speed, quiet=args.quiet)
+ print(f"\n\033[1m-- ida-tui demo, {len(scenes)} scenes --\033[0m\n")
+ for name, fn in scenes:
+ print(f"\033[1m[{name}]\033[0m", flush=True)
+ fn(demo)
+ if not args.no_revert:
+ print("\033[1m[revert]\033[0m", flush=True)
+ demo.revert()
+ print("\ndone.")
+ except KeyboardInterrupt:
+ print("\ninterrupted", file=sys.stderr)
+ rc = 130
+ except Exception as exc: # noqa: BLE001
+ print(f"demo failed: {type(exc).__name__}: {exc}", file=sys.stderr)
+ rc = 1
+ finally:
+ if args.spawn and sock:
+ subprocess.run([sys.executable, "-m", "idatui.pane", "stop",
+ "--sock", sock], cwd=REPO, capture_output=True)
+ if tmp:
+ shutil.rmtree(tmp, ignore_errors=True)
+ return rc
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())