1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
|
#!/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.
# render the TUI in THIS terminal and drive it (best for a single-pane capture)
python tools/demo.py --here
# or give it its own pane in tmux/zellij
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. Fast enough to read as
#: typing, not so slow that a viewer is waiting on it.
TYPE_MS = 18
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.log: list[str] = []
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:
self.log.append(text)
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."""
# The app auto-lands on main once the index is in, so typing a goto to
# get there is a redundant round trip -- and every keystroke a driver
# injects costs real time. Only navigate if we are somewhere else.
st = self.do("state") or {}
if ((st.get("function") or {}).get("name")) != "main":
self.say("goto main")
self.do("goto", target="main", delay_ms=TYPE_MS)
self.beat(0.68)
else:
self.say("already landed on main")
self.beat(0.25)
self.say("scroll the listing")
self.do("move", dir="down", n=12)
self.beat(0.36)
self.do("move", dir="pagedown")
self.beat(0.45)
self.do("move", dir="top")
self.beat(0.36)
def scene_pseudocode(self):
"""Hex-Rays, and back."""
self.say("F5 -> pseudocode")
self.do("toggle_view")
self.beat(0.9)
self.do("move", dir="down", n=8)
self.beat(0.54)
self.say("back to the listing")
self.do("toggle_view")
self.beat(0.45)
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.41)
def scene_follow(self):
"""Follow a call and come back."""
self.say("follow a call, then Escape back")
self.do("follow")
self.beat(0.81)
self.do("back")
self.beat(0.45)
def scene_graph(self):
"""The CFG: zoom, minimap, walking edges."""
self.say("space -> control-flow graph")
self.do("graph", action="open")
self.beat(0.99)
self.say("zoom levels")
for _ in range(2):
self.do("keys", keys=["z"])
self.beat(0.5)
self.say("minimap")
self.do("keys", keys=["m"])
self.beat(0.54)
self.say("walk the edges")
for _ in range(3):
self.do("keys", keys=["J"])
self.beat(0.32)
self.do("keys", keys=["m"])
self.do("graph", action="close")
self.beat(0.45)
def scene_split(self):
"""Listing and pseudocode, cursor-synced."""
self.say("s -> split view, cursor-synced")
self.do("keys", keys=["s"])
self.beat(0.99)
for _ in range(6):
self.do("move", dir="down", n=2)
self.beat(0.25)
self.do("keys", keys=["s"])
self.beat(0.45)
def scene_xrefs(self):
"""Who calls this."""
self.say("x -> xrefs")
self.do("xrefs")
self.beat(0.9)
self.do("close")
self.beat(0.36)
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.36)
self.do("rename", name="demo_dispatch", delay_ms=TYPE_MS)
self.undo.append(("rename", {"addr": ea, "name": name}))
self.beat(0.81)
self.say("comment the line")
self.do("comment", text="reached from the command dispatcher")
self.undo.append(("comment", {}))
self.beat(0.9)
def scene_browsers(self):
"""Strings, symbols, structs, hex."""
self.say('" -> strings')
self.do("keys", keys=["quotation_mark"])
self.beat(0.9)
self.do("close")
self.beat(0.27)
self.say("ctrl+n -> symbol palette")
self.do("symbols", query="exec")
self.beat(0.9)
self.do("close")
self.beat(0.27)
self.say("ctrl+t -> structs")
self.do("structs")
self.beat(0.9)
self.do("close")
self.beat(0.27)
self.say("\\ -> hex view")
self.do("hex")
self.beat(0.9)
self.do("hex")
self.beat(0.36)
def scene_search(self):
"""Incremental search in the code view."""
self.say("/ -> search")
self.do("search", term="call")
self.beat(0.81)
self.do("close")
self.beat(0.36)
# -- 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 run_here(target: str) -> tuple[subprocess.Popen, str, str]:
"""Launch the TUI in THIS terminal and return (proc, sock, tmpdir).
stdio is inherited, so the app draws on the terminal that ran the demo --
which is what you want when the capture is one pane. It goes through the
./ida-tui launcher rather than re-deriving the interpreter, so the
$IDATUI_PYTHON rules stay in exactly one place.
"""
tmp = tempfile.mkdtemp(prefix="idatui-demo-")
copy = os.path.join(tmp, os.path.basename(target))
shutil.copy2(target, copy)
if os.path.exists(target + ".i64"):
shutil.copy2(target + ".i64", copy + ".i64")
sockdir = os.environ.get("XDG_RUNTIME_DIR") or "/tmp"
sock = os.path.join(sockdir, f"idatui-demo-{os.getpid()}.sock")
proc = subprocess.Popen([os.path.join(REPO, "ida-tui"), copy, "--rpc", sock],
cwd=REPO) # stdio inherited on purpose
return proc, sock, tmp
def wait_for_socket(proc: subprocess.Popen, sock: str, timeout: float = 600.0) -> bool:
"""Poll for the RPC socket, failing fast if the TUI exits first."""
deadline = time.time() + timeout
while time.time() < deadline:
if os.path.exists(sock):
return True
if proc.poll() is not None: # died before it ever listened
return False
time.sleep(0.1)
return False
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("--here", "--inline", dest="here", action="store_true",
help="run the TUI in THIS terminal (single-pane recording)")
ap.add_argument("--target", default=DEFAULT_TARGET,
help="binary for --here/--spawn")
ap.add_argument("--speed", type=float, default=1.0,
help="pause multiplier: <1 snappier, >1 slower (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, proc = args.sock, "", "", None
if args.here and args.spawn:
ap.error("--here and --spawn are mutually exclusive")
if (args.here or args.spawn) and not os.path.isfile(args.target):
print(f"no such binary: {args.target}", file=sys.stderr)
return 2
if args.spawn:
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}")
elif args.here:
proc, sock, tmp = run_here(args.target)
if not wait_for_socket(proc, sock):
print("the TUI exited before it was drivable", file=sys.stderr)
shutil.rmtree(tmp, ignore_errors=True)
return 1
if not sock:
ap.error("pass --sock <path>, --here, or --spawn")
# Inline mode shares the screen with the app, so narration is collected and
# replayed once the alternate screen is gone rather than drawn over it.
transcript: list[str] = []
if args.here:
args.quiet = True
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)
if not args.quiet:
print(f"\n\033[1m-- ida-tui demo, {len(scenes)} scenes --\033[0m\n")
for name, fn in scenes:
if not args.quiet:
print(f"\033[1m[{name}]\033[0m", flush=True)
demo.log.append(f"[{name}]")
fn(demo)
if not args.no_revert:
if not args.quiet:
print("\033[1m[revert]\033[0m", flush=True)
demo.revert()
transcript = demo.log
if args.here:
demo.do("quit")
if not args.quiet:
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 proc is not None:
try:
proc.wait(timeout=30)
except subprocess.TimeoutExpired:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
if tmp:
shutil.rmtree(tmp, ignore_errors=True)
if args.here and transcript: # the alt screen is gone: safe to print
print("\n\033[1m-- ida-tui demo --\033[0m")
for line in transcript:
print(f" {line}" if not line.startswith("[") else f"\033[1m{line}\033[0m")
print("done.")
return rc
if __name__ == "__main__":
raise SystemExit(main())
|