nolook 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nolook/__init__.py +0 -0
- nolook/cli.py +369 -0
- nolook/config.py +89 -0
- nolook/device.py +360 -0
- nolook/doctor.py +94 -0
- nolook/flow.py +202 -0
- nolook/motion.py +449 -0
- nolook/perf.py +278 -0
- nolook/tracing.py +174 -0
- nolook/tree.py +341 -0
- nolook-0.3.0.dist-info/METADATA +251 -0
- nolook-0.3.0.dist-info/RECORD +15 -0
- nolook-0.3.0.dist-info/WHEEL +4 -0
- nolook-0.3.0.dist-info/entry_points.txt +2 -0
- nolook-0.3.0.dist-info/licenses/LICENSE +21 -0
nolook/__init__.py
ADDED
|
File without changes
|
nolook/cli.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""nolook: drive an Android device by what is on screen, not by pixels."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import tree
|
|
11
|
+
from .config import find_config
|
|
12
|
+
from .device import Device, NolookError, Outcome
|
|
13
|
+
from .tree import Node, QueryError
|
|
14
|
+
|
|
15
|
+
SNAP_CAP = 80
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def print_nodes(ns: list[Node], cap: int | None = SNAP_CAP) -> None:
|
|
19
|
+
shown = ns if cap is None else ns[:cap]
|
|
20
|
+
for n in shown:
|
|
21
|
+
print(tree.format_node(n))
|
|
22
|
+
if len(ns) > len(shown):
|
|
23
|
+
print(f"… {len(ns) - len(shown)} more (nolook snap --all)")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def report(verb: str, o: Outcome, quiet: bool = False) -> None:
|
|
27
|
+
head = verb
|
|
28
|
+
if o.target is not None:
|
|
29
|
+
head += " " + tree.format_node(o.target, compact=True)
|
|
30
|
+
head += f" · settled {o.settled}s" if o.settled is not None else " · still changing after timeout"
|
|
31
|
+
print(head)
|
|
32
|
+
for g in o.guard_hits:
|
|
33
|
+
print(f"⚠ guard: {g}")
|
|
34
|
+
if quiet:
|
|
35
|
+
return
|
|
36
|
+
lines, wholesale = tree.delta(o.before, o.after)
|
|
37
|
+
if wholesale:
|
|
38
|
+
print(f"screen changed ({len(o.after)} elements):")
|
|
39
|
+
print_nodes(o.after, cap=40)
|
|
40
|
+
elif lines:
|
|
41
|
+
print("\n".join(lines))
|
|
42
|
+
else:
|
|
43
|
+
print("no visible change")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_box(s: str) -> tuple[int, int, int, int]:
|
|
47
|
+
parts = [int(p) for p in s.split(",")]
|
|
48
|
+
if len(parts) != 4:
|
|
49
|
+
raise argparse.ArgumentTypeError("expected l,t,r,b")
|
|
50
|
+
return tuple(parts) # type: ignore[return-value]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build() -> argparse.ArgumentParser:
|
|
54
|
+
p = argparse.ArgumentParser(
|
|
55
|
+
prog="nolook",
|
|
56
|
+
description="Drive an Android device by what is on screen. Queries: @e12 (ref), #tag (id), "
|
|
57
|
+
"~part (contains), /regex/, plain text (exact); prefix a role to narrow: btn:回复.",
|
|
58
|
+
)
|
|
59
|
+
p.add_argument("--serial", help="device serial (default: .nolook.toml, then the only device)")
|
|
60
|
+
p.add_argument("-q", "--quiet", action="store_true", help="print the action line only, no changes")
|
|
61
|
+
p.add_argument("--session", help=argparse.SUPPRESS) # the device a Claude Code session last drove, for its pane
|
|
62
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
63
|
+
|
|
64
|
+
s = sub.add_parser("snap", help="list what is on screen, with refs")
|
|
65
|
+
s.add_argument("--all", action="store_true", help=f"no {SNAP_CAP}-line cap")
|
|
66
|
+
s.add_argument("--in", dest="within", metavar="Q", help="only this element and what is inside it")
|
|
67
|
+
s.add_argument("--json", action="store_true", help="one JSON object per element, for scripts")
|
|
68
|
+
|
|
69
|
+
s = sub.add_parser("tap", help="tap one element")
|
|
70
|
+
s.add_argument("query", nargs="?")
|
|
71
|
+
s.add_argument("--nth", type=int, help="pick the nth of several matches (1-based)")
|
|
72
|
+
s.add_argument("--long", action="store_true", help="long press")
|
|
73
|
+
s.add_argument("--force", action="store_true", help="tap even if something covers it")
|
|
74
|
+
s.add_argument("--confirm", action="store_true", help="allow a write action (like, follow, send…); only when the user asked for it")
|
|
75
|
+
s.add_argument("--xy", metavar="X,Y", help="tap raw coordinates (canvas, WebView)")
|
|
76
|
+
|
|
77
|
+
s = sub.add_parser("type", help="set the text of a field")
|
|
78
|
+
s.add_argument("text")
|
|
79
|
+
s.add_argument("--into", metavar="Q", help="field to focus first (default: the focused one)")
|
|
80
|
+
s.add_argument("--append", action="store_true", help="add to the current text instead of replacing it")
|
|
81
|
+
s.add_argument("--enter", action="store_true", help="press enter after")
|
|
82
|
+
|
|
83
|
+
s = sub.add_parser("key", help="press a key: back, home, enter, recent, delete, volume_up …")
|
|
84
|
+
s.add_argument("name")
|
|
85
|
+
sub.add_parser("back", help="press back")
|
|
86
|
+
|
|
87
|
+
s = sub.add_parser("scroll", help="scroll a list; down reveals what is below")
|
|
88
|
+
s.add_argument("direction", nargs="?", default="down", choices=["down", "up", "left", "right"])
|
|
89
|
+
s.add_argument("--in", dest="within", metavar="Q", help="scroll container (default: the largest)")
|
|
90
|
+
s.add_argument("--until", metavar="Q", help="keep scrolling until this is in view")
|
|
91
|
+
s.add_argument("--max", type=int, default=10, help="swipes before giving up (with --until)")
|
|
92
|
+
|
|
93
|
+
s = sub.add_parser("wait", help="wait for an element to appear (or --gone)")
|
|
94
|
+
s.add_argument("query")
|
|
95
|
+
s.add_argument("--gone", action="store_true")
|
|
96
|
+
s.add_argument("--timeout", type=float, default=10.0)
|
|
97
|
+
|
|
98
|
+
s = sub.add_parser("get", help="everything about one element, untruncated")
|
|
99
|
+
s.add_argument("query")
|
|
100
|
+
s.add_argument("--nth", type=int)
|
|
101
|
+
|
|
102
|
+
s = sub.add_parser("open", help="open a URI (deep link) in the app")
|
|
103
|
+
s.add_argument("uri")
|
|
104
|
+
|
|
105
|
+
s = sub.add_parser("launch", help="start the app")
|
|
106
|
+
s.add_argument("package", nargs="?")
|
|
107
|
+
s.add_argument("--fresh", action="store_true", help="force-stop first")
|
|
108
|
+
|
|
109
|
+
s = sub.add_parser("shot", help="save a small screenshot; prints its path, never the image")
|
|
110
|
+
s.add_argument("query", nargs="?", help="crop to this element")
|
|
111
|
+
s.add_argument("--region", type=parse_box, metavar="L,T,R,B")
|
|
112
|
+
s.add_argument("--width", type=int, help="longest edge in px (default 540 whole screen, 900 crops)")
|
|
113
|
+
s.add_argument("--full", action="store_true", help="native resolution PNG")
|
|
114
|
+
s.add_argument("--png", action="store_true", help="PNG at the small size instead of JPEG")
|
|
115
|
+
s.add_argument("-o", "--out", metavar="PATH")
|
|
116
|
+
|
|
117
|
+
s = sub.add_parser("log", help="recent logcat lines of the app")
|
|
118
|
+
s.add_argument("-n", type=int, default=40)
|
|
119
|
+
s.add_argument("--grep", metavar="REGEX")
|
|
120
|
+
s.add_argument("--level", choices=list("VDIWEF"), help="minimum level")
|
|
121
|
+
|
|
122
|
+
s = sub.add_parser("run", help="run a Python flow; see `nolook flows`")
|
|
123
|
+
s.add_argument("flow", help="a .py path, or a name in the project's flows dir")
|
|
124
|
+
s.add_argument("args", nargs=argparse.REMAINDER)
|
|
125
|
+
|
|
126
|
+
sub.add_parser("flows", help="list the project's flows")
|
|
127
|
+
sub.add_parser("doctor", help="check adb, the device, ffmpeg and Perfetto, and say what to fix")
|
|
128
|
+
|
|
129
|
+
s = sub.add_parser("trace", help="summarise a Perfetto trace (no device needed)")
|
|
130
|
+
ts = s.add_subparsers(dest="trace_cmd", required=True)
|
|
131
|
+
t = ts.add_parser("summary", help="frames, jank causes, where the main thread's time went, GC, binder")
|
|
132
|
+
t.add_argument("file")
|
|
133
|
+
t.add_argument("--package", help="app process (default: .nolook.toml's, else the one with most frames)")
|
|
134
|
+
t.add_argument("--top", type=int, default=8, help="slices per thread")
|
|
135
|
+
t = ts.add_parser("sql", help="run a trace processor SQL query")
|
|
136
|
+
t.add_argument("file")
|
|
137
|
+
t.add_argument("query")
|
|
138
|
+
t.add_argument("--limit", type=int, default=40)
|
|
139
|
+
|
|
140
|
+
s = sub.add_parser("topng", help=argparse.SUPPRESS) # for the Claude Code pane: it draws PNGs only
|
|
141
|
+
s.add_argument("src")
|
|
142
|
+
s.add_argument("dst", nargs="?", help="default: beside src, as .png")
|
|
143
|
+
s.add_argument("--width", type=int)
|
|
144
|
+
|
|
145
|
+
s = sub.add_parser("motion", help="record an animation and report its timing, curve and stalls")
|
|
146
|
+
s.add_argument("--tap", metavar="Q", help="start recording, then tap this")
|
|
147
|
+
s.add_argument("--after", metavar="Q", help="start recording, then wait for this to appear")
|
|
148
|
+
s.add_argument("--key", metavar="NAME", help="start recording, then press this key")
|
|
149
|
+
s.add_argument("--el", metavar="Q", help="measure inside this element (default: whole screen)")
|
|
150
|
+
s.add_argument("--region", type=parse_box, metavar="L,T,R,B")
|
|
151
|
+
s.add_argument("--for", dest="seconds", type=float, default=1.5, help="seconds to keep recording after the trigger")
|
|
152
|
+
s.add_argument("--slow", type=float, metavar="N", help="run animations N times slower while recording")
|
|
153
|
+
s.add_argument("--label", help="name this run for `motion compare`")
|
|
154
|
+
s.add_argument("--confirm", action="store_true", help="allow --tap on a write action")
|
|
155
|
+
s.add_argument("--json", action="store_true")
|
|
156
|
+
s = sub.add_parser("motion-compare", help="table of recorded motion runs")
|
|
157
|
+
s.add_argument("labels", nargs="*")
|
|
158
|
+
s.add_argument("--last", type=int, default=10)
|
|
159
|
+
|
|
160
|
+
s = sub.add_parser("perf", help="frame timing, compile modes, traces, ANRs")
|
|
161
|
+
ps = s.add_subparsers(dest="perf_cmd", required=True)
|
|
162
|
+
f = ps.add_parser("frames", help="reset gfxinfo, swipe the list back and forth, print the frame summary")
|
|
163
|
+
f.add_argument("--rounds", type=int, default=4, help="forward+back swipe pairs (default 4)")
|
|
164
|
+
f.add_argument("--in", dest="within", metavar="Q", help="list to swipe (default: the largest)")
|
|
165
|
+
f.add_argument("--speed", type=float, default=10000, help="swipe speed in px/s, which it flings at (default 10000, a quick flick)")
|
|
166
|
+
f.add_argument("--duration", type=float, help="seconds per swipe instead of --speed (0.25 matches `input swipe … 250`)")
|
|
167
|
+
f.add_argument("--gap", type=float, default=0.8, help="seconds between swipes")
|
|
168
|
+
f.add_argument("--axis", choices=["y", "x"], default="y")
|
|
169
|
+
f.add_argument("--label", help="name this run for `perf compare`")
|
|
170
|
+
f.add_argument("--trace", metavar="PATH", help="also record a Perfetto trace to this file or directory")
|
|
171
|
+
f.add_argument("--all", action="store_true", help="print every gfxinfo counter")
|
|
172
|
+
f.add_argument("--json", action="store_true")
|
|
173
|
+
ps.add_parser("reset", help="reset gfxinfo counters (then act, then `perf read`)")
|
|
174
|
+
r = ps.add_parser("read", help="frame summary since the last reset")
|
|
175
|
+
r.add_argument("--label")
|
|
176
|
+
r.add_argument("--all", action="store_true")
|
|
177
|
+
r.add_argument("--json", action="store_true")
|
|
178
|
+
c = ps.add_parser("compare", help="table of recorded runs")
|
|
179
|
+
c.add_argument("labels", nargs="*", help="only these labels")
|
|
180
|
+
c.add_argument("--last", type=int, default=10)
|
|
181
|
+
c.add_argument("--all", action="store_true", help="every counter as a column")
|
|
182
|
+
c = ps.add_parser("compile", help="set the ART compile mode and stop the app so it takes effect")
|
|
183
|
+
c.add_argument("mode", help="speed, speed-profile, verify, quicken, or reset")
|
|
184
|
+
c = ps.add_parser("anr", help="the last ANR's header and main thread stack")
|
|
185
|
+
c.add_argument("-n", type=int, default=40)
|
|
186
|
+
return p
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def main(argv: list[str] | None = None) -> int:
|
|
190
|
+
a = build().parse_args(argv)
|
|
191
|
+
cfg = find_config()
|
|
192
|
+
if a.session:
|
|
193
|
+
from .config import follow_session
|
|
194
|
+
|
|
195
|
+
follow_session(cfg, a.session)
|
|
196
|
+
if a.serial:
|
|
197
|
+
cfg.serial = a.serial
|
|
198
|
+
|
|
199
|
+
if a.cmd == "topng":
|
|
200
|
+
from PIL import Image
|
|
201
|
+
|
|
202
|
+
img = Image.open(a.src)
|
|
203
|
+
if a.width and img.width > a.width:
|
|
204
|
+
img = img.resize((a.width, round(img.height * a.width / img.width)))
|
|
205
|
+
dst = a.dst or str(Path(a.src).with_suffix(".pane.png"))
|
|
206
|
+
img.save(dst, "PNG")
|
|
207
|
+
print(dst)
|
|
208
|
+
return 0
|
|
209
|
+
|
|
210
|
+
if a.cmd == "doctor":
|
|
211
|
+
from . import doctor
|
|
212
|
+
|
|
213
|
+
return doctor.run(cfg)
|
|
214
|
+
|
|
215
|
+
if a.cmd == "trace":
|
|
216
|
+
from . import tracing
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
lines = (
|
|
220
|
+
tracing.summary(a.file, a.package or cfg.package, a.top)
|
|
221
|
+
if a.trace_cmd == "summary"
|
|
222
|
+
else tracing.sql(a.file, a.query, a.limit)
|
|
223
|
+
)
|
|
224
|
+
except NolookError as e:
|
|
225
|
+
print(f"error: {e}", file=sys.stderr)
|
|
226
|
+
return 1
|
|
227
|
+
print("\n".join(lines))
|
|
228
|
+
return 0
|
|
229
|
+
|
|
230
|
+
if a.cmd == "flows":
|
|
231
|
+
from .flow import list_flows
|
|
232
|
+
|
|
233
|
+
list_flows(cfg)
|
|
234
|
+
return 0
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
if a.cmd == "run":
|
|
238
|
+
from .flow import run_flow
|
|
239
|
+
|
|
240
|
+
return run_flow(cfg, a.flow, a.args)
|
|
241
|
+
d = Device(cfg)
|
|
242
|
+
return dispatch(d, a)
|
|
243
|
+
except (NolookError, QueryError) as e:
|
|
244
|
+
print(f"error: {e}", file=sys.stderr)
|
|
245
|
+
return 1
|
|
246
|
+
except KeyboardInterrupt:
|
|
247
|
+
return 130
|
|
248
|
+
except Exception as e: # noqa: BLE001 - one line beats a traceback in an agent's context
|
|
249
|
+
print(f"error: unexpected {type(e).__name__}: {e} (NOLOOK_DEBUG=1 for the traceback)", file=sys.stderr)
|
|
250
|
+
if os.environ.get("NOLOOK_DEBUG"):
|
|
251
|
+
raise
|
|
252
|
+
return 1
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def dispatch(d: Device, a: argparse.Namespace) -> int:
|
|
256
|
+
q = a.quiet
|
|
257
|
+
if a.cmd == "snap":
|
|
258
|
+
ns = d.nodes()
|
|
259
|
+
if a.within:
|
|
260
|
+
box = tree.resolve(ns, a.within)
|
|
261
|
+
i = ns.index(box)
|
|
262
|
+
ns = ns[i : box.end]
|
|
263
|
+
if a.json:
|
|
264
|
+
import json
|
|
265
|
+
|
|
266
|
+
for n in ns:
|
|
267
|
+
print(json.dumps({
|
|
268
|
+
"ref": n.ref, "role": n.role, "label": n.label, "text": n.text, "desc": n.desc, "id": n.rid,
|
|
269
|
+
"bounds": list(n.bounds), "center": list(n.center), "depth": n.depth, "covered": n.covered,
|
|
270
|
+
**{k: v for k, v in n.flags.items()},
|
|
271
|
+
}, ensure_ascii=False))
|
|
272
|
+
return 0
|
|
273
|
+
pkgs = sorted({n.pkg for n in ns if n.pkg})
|
|
274
|
+
print(f"{' + '.join(pkgs) or '(empty)'} · {d.size[0]}x{d.size[1]} · {len(ns)} elements")
|
|
275
|
+
print_nodes(ns, cap=None if a.all else SNAP_CAP)
|
|
276
|
+
elif a.cmd == "tap":
|
|
277
|
+
if a.xy:
|
|
278
|
+
x, y = (int(v) for v in a.xy.split(","))
|
|
279
|
+
report(f"tap ({x},{y})", d.tap_xy(x, y), q)
|
|
280
|
+
elif a.query:
|
|
281
|
+
report("long-press" if a.long else "tap", d.tap(a.query, a.nth, long=a.long, force=a.force, confirm=a.confirm), q)
|
|
282
|
+
else:
|
|
283
|
+
raise NolookError("tap what? give a query or --xy X,Y")
|
|
284
|
+
elif a.cmd == "type":
|
|
285
|
+
o = d.type(a.text, a.into, append=a.append)
|
|
286
|
+
if a.enter:
|
|
287
|
+
o2 = d.key("enter")
|
|
288
|
+
o = Outcome(o.target, o.before, o2.after, o2.settled, o.guard_hits + o2.guard_hits)
|
|
289
|
+
report("type into", o, q)
|
|
290
|
+
elif a.cmd in ("key", "back"):
|
|
291
|
+
name = "back" if a.cmd == "back" else a.name
|
|
292
|
+
report(f"key {name}", d.key(name), q)
|
|
293
|
+
elif a.cmd == "scroll":
|
|
294
|
+
o = d.scroll(a.direction, a.within, a.until, a.max)
|
|
295
|
+
report(f"scroll {a.direction}" + (" until" if a.until else ""), o, q)
|
|
296
|
+
elif a.cmd == "wait":
|
|
297
|
+
ns, took = d.wait(a.query, a.gone, a.timeout)
|
|
298
|
+
hit = tree.match(ns, a.query)
|
|
299
|
+
what = "gone" if a.gone else tree.format_node(hit[0], compact=True)
|
|
300
|
+
print(f"{what} · after {took}s")
|
|
301
|
+
elif a.cmd == "get":
|
|
302
|
+
n, _ = d.find(a.query, a.nth)
|
|
303
|
+
print(tree.format_node(n, compact=True))
|
|
304
|
+
for k, v in (("text", n.text), ("desc", n.desc), ("id", n.rid), ("class", n.cls), ("package", n.pkg)):
|
|
305
|
+
if v:
|
|
306
|
+
print(f" {k}: {v}")
|
|
307
|
+
print(f" bounds: {list(n.bounds)}")
|
|
308
|
+
on = [k for k, v in n.flags.items() if v and k != "enabled"]
|
|
309
|
+
if on:
|
|
310
|
+
print(f" flags: {', '.join(on)}")
|
|
311
|
+
elif a.cmd == "open":
|
|
312
|
+
report(f"open {a.uri}", d.open(a.uri), q)
|
|
313
|
+
elif a.cmd == "launch":
|
|
314
|
+
report("launch", d.launch(a.package, a.fresh), q)
|
|
315
|
+
elif a.cmd == "shot":
|
|
316
|
+
r = d.shot(a.query, a.region, a.width, a.full, a.out, a.png)
|
|
317
|
+
w, h = r["size"]
|
|
318
|
+
print(f"{r['path']} {w}x{h} {r['kb']} KB ≈{r['tokens']} tokens")
|
|
319
|
+
elif a.cmd == "perf":
|
|
320
|
+
perf_dispatch(d, a)
|
|
321
|
+
elif a.cmd == "motion":
|
|
322
|
+
import json
|
|
323
|
+
|
|
324
|
+
from . import motion
|
|
325
|
+
|
|
326
|
+
r = motion.record(d, a.tap, a.after, a.key, a.el, a.region, a.seconds, a.slow, a.label, a.confirm)
|
|
327
|
+
if a.json:
|
|
328
|
+
print(json.dumps({k: v for k, v in r.items() if not k.startswith("_")}, ensure_ascii=False))
|
|
329
|
+
else:
|
|
330
|
+
print("\n".join(motion.format_report(r)))
|
|
331
|
+
elif a.cmd == "motion-compare":
|
|
332
|
+
from . import motion
|
|
333
|
+
|
|
334
|
+
print("\n".join(motion.compare_lines(motion.history(d, a.labels or None, a.last))))
|
|
335
|
+
elif a.cmd == "log":
|
|
336
|
+
for ln in d.log(a.n, a.grep, a.level):
|
|
337
|
+
print(ln)
|
|
338
|
+
return 0
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def perf_dispatch(d: Device, a: argparse.Namespace) -> None:
|
|
342
|
+
import json
|
|
343
|
+
|
|
344
|
+
from . import perf
|
|
345
|
+
|
|
346
|
+
keys = [k for k, _ in perf._FIELDS] if getattr(a, "all", False) else perf.SHORT
|
|
347
|
+
if a.perf_cmd == "frames":
|
|
348
|
+
rec = perf.frames(d, a.rounds, a.within, a.duration, a.gap, a.axis, a.label, a.trace, speed=a.speed)
|
|
349
|
+
print(json.dumps(rec, ensure_ascii=False) if a.json else perf.format_record(rec, keys))
|
|
350
|
+
if rec.get("trace") and not a.json:
|
|
351
|
+
print(f"trace: {rec['trace']} (open in ui.perfetto.dev)")
|
|
352
|
+
elif a.perf_cmd == "reset":
|
|
353
|
+
perf.reset(d)
|
|
354
|
+
print("gfxinfo reset")
|
|
355
|
+
elif a.perf_cmd == "read":
|
|
356
|
+
rec = {"label": a.label or "", **perf.read(d)}
|
|
357
|
+
print(json.dumps(rec, ensure_ascii=False) if a.json else perf.format_record(rec, keys))
|
|
358
|
+
elif a.perf_cmd == "compare":
|
|
359
|
+
for ln in perf.compare_table(perf.history(d, a.labels or None, a.last), keys):
|
|
360
|
+
print(ln)
|
|
361
|
+
elif a.perf_cmd == "compile":
|
|
362
|
+
print(f"compile status: {perf.compile(d, a.mode) or 'unknown'} · app stopped; relaunch it before measuring")
|
|
363
|
+
elif a.perf_cmd == "anr":
|
|
364
|
+
for ln in perf.last_anr(d, lines=a.n):
|
|
365
|
+
print(ln)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
if __name__ == "__main__":
|
|
369
|
+
sys.exit(main())
|
nolook/config.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Project settings from the nearest .nolook.toml, with env overrides.
|
|
2
|
+
|
|
3
|
+
serial = "emulator-5554"
|
|
4
|
+
adb_port = 5037
|
|
5
|
+
package = "com.example.notes"
|
|
6
|
+
flows = "tools/flows"
|
|
7
|
+
guard = ["/api/like", "/api/post"] # logcat regexes flagged after each action
|
|
8
|
+
danger = ["Like", "Send", "Delete"] # labels a tap refuses without --confirm
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import tomllib
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Config:
|
|
21
|
+
root: Path
|
|
22
|
+
serial: str | None = None
|
|
23
|
+
adb_port: int | None = None
|
|
24
|
+
package: str | None = None
|
|
25
|
+
flows: str = "flows"
|
|
26
|
+
guard: list[str] = field(default_factory=list)
|
|
27
|
+
hide: list[str] = field(default_factory=lambda: ["com.android.systemui"])
|
|
28
|
+
# Taps on these refuse without confirm: the app may be logged in to a
|
|
29
|
+
# real account, and one wrong tap posts, likes or deletes.
|
|
30
|
+
danger: list[str] = field(
|
|
31
|
+
default_factory=lambda: [
|
|
32
|
+
"赞", "踩", "关注", "取消关注", "发送", "发布", "回复", "删除", "收藏", "一键签到", "签到", "举报", "拉黑", "屏蔽",
|
|
33
|
+
"like", "follow", "send", "post", "delete", "submit", "buy", "pay",
|
|
34
|
+
]
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def flows_dir(self) -> Path:
|
|
39
|
+
return self.root / self.flows
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def find_config(start: Path | None = None) -> Config:
|
|
43
|
+
here = (start or Path.cwd()).resolve()
|
|
44
|
+
data: dict = {}
|
|
45
|
+
root = here
|
|
46
|
+
for d in (here, *here.parents):
|
|
47
|
+
f = d / ".nolook.toml"
|
|
48
|
+
if f.is_file():
|
|
49
|
+
data = tomllib.loads(f.read_text())
|
|
50
|
+
root = d
|
|
51
|
+
break
|
|
52
|
+
known = {k: v for k, v in data.items() if k in Config.__dataclass_fields__ and k != "root"}
|
|
53
|
+
cfg = Config(root=root, **known)
|
|
54
|
+
cfg.serial = os.environ.get("NOLOOK_SERIAL", cfg.serial)
|
|
55
|
+
port = os.environ.get("NOLOOK_ADB_PORT")
|
|
56
|
+
if port:
|
|
57
|
+
cfg.adb_port = int(port)
|
|
58
|
+
if cfg.adb_port:
|
|
59
|
+
# adbutils reads this when it first connects.
|
|
60
|
+
os.environ["ANDROID_ADB_SERVER_PORT"] = str(cfg.adb_port)
|
|
61
|
+
return cfg
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def cache_dir(serial: str) -> Path:
|
|
65
|
+
base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "nolook"
|
|
66
|
+
d = base / serial.replace(":", "_")
|
|
67
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
return d
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def session_dir() -> Path:
|
|
72
|
+
base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "nolook" / "sessions"
|
|
73
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
return base
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def follow_session(cfg: Config, session: str) -> bool:
|
|
78
|
+
"""Point cfg at the device a Claude Code session last drove; False if none is recorded."""
|
|
79
|
+
import json
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
rec = json.loads((session_dir() / f"{session}.json").read_text())
|
|
83
|
+
except (OSError, ValueError):
|
|
84
|
+
return False
|
|
85
|
+
cfg.serial = rec.get("serial") or cfg.serial
|
|
86
|
+
if rec.get("adb_port"):
|
|
87
|
+
cfg.adb_port = int(rec["adb_port"])
|
|
88
|
+
os.environ["ANDROID_ADB_SERVER_PORT"] = str(cfg.adb_port)
|
|
89
|
+
return True
|