device-devtools-mcp 0.1.0

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.
Files changed (66) hide show
  1. package/AGENTS.md +327 -0
  2. package/LICENSE +21 -0
  3. package/README.md +491 -0
  4. package/VERSION +1 -0
  5. package/bin/device-devtools-mcp.js +106 -0
  6. package/bin/devicetools +315 -0
  7. package/config.example.json +63 -0
  8. package/integrations/agent-pointer.sh +48 -0
  9. package/integrations/claude/SKILL.md +8 -0
  10. package/integrations/cursor/devicetools.mdc +8 -0
  11. package/integrations/gemini/GEMINI.md +5 -0
  12. package/integrations/mcp/README.md +184 -0
  13. package/integrations/mcp/mcp.json +10 -0
  14. package/integrations/mcp/reference.sh +119 -0
  15. package/integrations/mcp/selftest.sh +158 -0
  16. package/integrations/mcp/server.sh +343 -0
  17. package/package.json +50 -0
  18. package/scripts/android/app.sh +241 -0
  19. package/scripts/android/back.sh +73 -0
  20. package/scripts/android/controls.sh +56 -0
  21. package/scripts/android/devices.sh +77 -0
  22. package/scripts/android/doctor.sh +253 -0
  23. package/scripts/android/lib.sh +699 -0
  24. package/scripts/android/logs.sh +289 -0
  25. package/scripts/android/permission.sh +119 -0
  26. package/scripts/android/settings.sh +63 -0
  27. package/scripts/android/setup.sh +97 -0
  28. package/scripts/android/tree.awk +166 -0
  29. package/scripts/android/tree.sh +127 -0
  30. package/scripts/android/type.sh +191 -0
  31. package/scripts/common/find.sh +95 -0
  32. package/scripts/common/key.sh +65 -0
  33. package/scripts/common/lib.sh +14 -0
  34. package/scripts/common/measure.sh +170 -0
  35. package/scripts/common/open.sh +131 -0
  36. package/scripts/common/screenshot.sh +102 -0
  37. package/scripts/common/scroll.sh +177 -0
  38. package/scripts/common/snapshot.sh +69 -0
  39. package/scripts/common/swipe.sh +171 -0
  40. package/scripts/common/tap.sh +226 -0
  41. package/scripts/common/wait.sh +212 -0
  42. package/scripts/common/waypoint.sh +141 -0
  43. package/scripts/dispatch.sh +21 -0
  44. package/scripts/flow.sh +266 -0
  45. package/scripts/init.sh +101 -0
  46. package/scripts/ios/app.sh +404 -0
  47. package/scripts/ios/back.sh +95 -0
  48. package/scripts/ios/controls.sh +68 -0
  49. package/scripts/ios/devices.sh +80 -0
  50. package/scripts/ios/doctor.sh +386 -0
  51. package/scripts/ios/lib.sh +864 -0
  52. package/scripts/ios/logs.sh +272 -0
  53. package/scripts/ios/permission.sh +108 -0
  54. package/scripts/ios/settings.sh +76 -0
  55. package/scripts/ios/setup.sh +175 -0
  56. package/scripts/ios/tree.sh +128 -0
  57. package/scripts/ios/type.sh +178 -0
  58. package/scripts/lib.sh +1032 -0
  59. package/scripts/links.tsv +44 -0
  60. package/scripts/relink.sh +121 -0
  61. package/scripts/run.sh +415 -0
  62. package/scripts/selftest.sh +1709 -0
  63. package/scripts/snapshot.awk +362 -0
  64. package/scripts/verify-npm-package.js +133 -0
  65. package/tests/fixtures/ios-contacts-list.expected +52 -0
  66. package/tests/fixtures/ios-contacts-list.rows +140 -0
@@ -0,0 +1,864 @@
1
+ #!/usr/bin/env bash
2
+ # DeviceTools — iOS adapter internals: everything that knows about WebDriverAgent.
3
+ #
4
+ # Sourced by the scripts in this directory, never by anything above it. The
5
+ # platform-neutral half lives in ../lib.sh and is sourced from here, so an
6
+ # adapter script sources exactly one file.
7
+
8
+ source "$(dirname "${BASH_SOURCE[0]}")/../lib.sh"
9
+
10
+ wda_base() {
11
+ printf 'http://%s:%s' "$(cfg '.wda.host' '127.0.0.1')" "$(cfg '.wda.local_port' '8100')"
12
+ }
13
+
14
+ # One timeout for every request is wrong, measured rather than assumed: reading
15
+ # the tree takes ~1.9s, while terminating the app under test was observed taking
16
+ # ~60s before XCUITest gave its own answer. A 10s ceiling on the second aborts
17
+ # the client while WebDriverAgent is still working, and reports a stuck driver
18
+ # that is not stuck.
19
+ #
20
+ # So callers that perform a slow operation raise the ceiling for themselves by
21
+ # exporting DT_HTTP_TIMEOUT. Reads keep the short one, where a hang really does
22
+ # mean something is wrong.
23
+ http_timeout() { printf '%s' "${DT_HTTP_TIMEOUT:-$(cfg '.timeouts.http_seconds' '10')}"; }
24
+
25
+ # wda_fail <curl-exit> <path> — one line saying what actually went wrong.
26
+ #
27
+ # "Unreachable" and "did not answer in time" call for different reactions, and
28
+ # collapsing them into one message sends the caller to restart a driver that is
29
+ # in fact running. curl 7 is a refused connection; 28 is a timeout, which for
30
+ # WebDriverAgent usually means a request it cannot satisfy — typing with nothing
31
+ # focused, for instance — rather than a dead process.
32
+ wda_fail() {
33
+ case "$1" in
34
+ 28) die "WebDriverAgent did not answer within $(http_timeout)s at $2 — it is running but stuck on this request" 3 ;;
35
+ 7) die "WebDriverAgent unreachable at $2 (connection refused) — run $(as_cmd doctor)" 3 ;;
36
+ *) die "WebDriverAgent request failed at $2 (curl $1) — run $(as_cmd doctor)" 3 ;;
37
+ esac
38
+ }
39
+
40
+ # wda_get <path> — prints the response body, exit 3 when the request fails.
41
+ wda_get() {
42
+ local body rc=0
43
+ body="$(curl -sS -m "$(http_timeout)" "$(wda_base)$1" 2>/dev/null)" || rc=$?
44
+ [ "$rc" -eq 0 ] || wda_fail "$rc" "$(wda_base)$1"
45
+ printf '%s' "$body"
46
+ }
47
+
48
+ # wda_post <path> <json-body>
49
+ wda_post() {
50
+ local body rc=0
51
+ body="$(curl -sS -m "$(http_timeout)" -X POST -H 'Content-Type: application/json' \
52
+ -d "$2" "$(wda_base)$1" 2>/dev/null)" || rc=$?
53
+ [ "$rc" -eq 0 ] || wda_fail "$rc" "$(wda_base)$1"
54
+ printf '%s' "$body"
55
+ }
56
+
57
+ device_udid() { cfg '.device.udid'; }
58
+
59
+ # ios_kind — "device" or "simulator", resolving "auto" by asking simctl.
60
+ #
61
+ # Resolution is cached in a variable rather than a file: it is one simctl call
62
+ # per script run, and a wrong answer cached on disk would outlive the reason it
63
+ # was wrong.
64
+ IOS_KIND=""
65
+ ios_kind() {
66
+ if [ -z "$IOS_KIND" ]; then
67
+ case "$(target_kind)" in
68
+ device) IOS_KIND=device ;;
69
+ simulator) IOS_KIND=simulator ;;
70
+ auto)
71
+ if xcrun simctl list devices -j 2>/dev/null | jq -e --arg u "$(device_udid)" \
72
+ '[.devices[][] | select(.udid == $u)] | length > 0' >/dev/null 2>&1; then
73
+ IOS_KIND=simulator
74
+ else
75
+ IOS_KIND=device
76
+ fi
77
+ ;;
78
+ *) die "device.kind must be auto, device or simulator, got: $(target_kind)" 2 ;;
79
+ esac
80
+ fi
81
+ printf '%s' "$IOS_KIND"
82
+ }
83
+
84
+ # The two builds cannot share a derived data directory: one targets
85
+ # iphoneos and the other iphonesimulator, and the second would overwrite the
86
+ # first's .xctestrun. Derived from one config key rather than needing two.
87
+ wda_derived() {
88
+ local base
89
+ base="$(expand_path "$(cfg '.wda.derived_data' '~/tools/wda-derived')")"
90
+ if [ "$(ios_kind)" = simulator ]; then
91
+ printf '%s-simulator' "$base"
92
+ else
93
+ printf '%s' "$base"
94
+ fi
95
+ }
96
+
97
+ # sim_booted — true when the configured simulator is booted.
98
+ # ASKED TWICE, BECAUSE SIMCTL SOMETIMES DOES NOT ANSWER. Under load — another
99
+ # simctl call in flight, a simulator booting — `simctl list -j` returns nothing
100
+ # at all, and reading that as "the device is not booted" fails a doctor
101
+ # against a simulator that is running fine. Seen once, during an exploration
102
+ # run. An empty answer is retried; a real answer is believed immediately.
103
+ sim_booted() {
104
+ local out attempt=0
105
+ while [ "$attempt" -lt 2 ]; do
106
+ out="$(xcrun simctl list devices -j 2>/dev/null || true)"
107
+ if [ -n "$out" ] && printf '%s' "$out" | jq -e . >/dev/null 2>&1; then
108
+ printf '%s' "$out" | jq -e --arg u "$(device_udid)" \
109
+ '[.devices[][] | select(.udid == $u and .state == "Booted")] | length > 0' >/dev/null 2>&1
110
+ return $?
111
+ fi
112
+ attempt=$(( attempt + 1 ))
113
+ sleep 0.5
114
+ done
115
+ return 1
116
+ }
117
+
118
+ # --- the tree ----------------------------------------------------------------
119
+ #
120
+ # Flattening and normalisation live here, in one jq program, so that tree.sh's
121
+ # output and every selector match are derived from exactly the same view of the
122
+ # tree. A label copied out of tree.sh output therefore always matches.
123
+ #
124
+ # Labels come from application copy and can contain newlines and pipes, both of
125
+ # which would break the line-oriented contract, so they are neutralised at the
126
+ # point the tree is read rather than at each point it is consumed.
127
+ JQ_CLEAN='
128
+ def clean:
129
+ if . == null then ""
130
+ elif type != "string" then tostring
131
+ else gsub("[\n\r\t]+"; " ") | gsub("\\|"; "/") | gsub(" +"; " ")
132
+ | ltrimstr(" ") | rtrimstr(" ")
133
+ end;
134
+ def walk($d):
135
+ [ { d: $d,
136
+ t: (.type | clean),
137
+ id: (.rawIdentifier | clean),
138
+ lb: (.label | clean),
139
+ v: (.value | clean),
140
+ x: (.rect.x // 0), y: (.rect.y // 0),
141
+ w: (.rect.width // 0), h: (.rect.height // 0),
142
+ vis: ((.isVisible // "") == "1"),
143
+ en: ((.isEnabled // "") == "1")
144
+ } ]
145
+ + ([ (.children // [])[] | walk($d + 1) ] | add // []);
146
+ '
147
+
148
+ # The XCUIElement types a user can act on. Kept as an explicit list rather than
149
+ # a pattern, so that adding one is a decision someone made rather than a regex
150
+ # quietly widening. Shared by controls.sh, which lists them, and tree.sh, which
151
+ # must not hide them: a production app's welcome screen was found with three
152
+ # unlabelled buttons and nothing else, and the tree showed none of them.
153
+ DT_TAPPABLE_TYPES='Button|TextField|SecureTextField|SearchField|Switch|Link|MenuItem|Slider|Stepper|SegmentedControl|PageIndicator|Picker|PickerWheel|CheckBox|RadioButton|Toggle|TextView'
154
+
155
+ # fetch_source — the raw tree. Internal only: nothing may print this. One screen
156
+ # measured 107 KB against the target app, which is why tree.sh exists at all.
157
+ # THE SESSION'S TREE, NOT THE SYSTEM'S.
158
+ #
159
+ # `GET /source` with no session in the path is WebDriverAgent's system-wide
160
+ # hierarchy: springboard, the app switcher, the home grabber, every window iOS
161
+ # has open. `GET /session/<id>/source` is the active application's.
162
+ #
163
+ # Measured, from an agent that got the app to the foreground and then read the
164
+ # screen: screenshot showed a full-screen login form, snapshot showed
165
+ # SBSwitcherWindow and AppSwitcherContentView, and `--all` returned ninety
166
+ # unlabelled Other nodes. Every tap, measure and type after that was reasoning
167
+ # about a tree belonging to a different process.
168
+ #
169
+ # The second symptom names the same cause: nodes measuring 1284x2778 sat beside
170
+ # nodes measuring 428x926 in one tree. Those are the same screen in pixels and
171
+ # in points — window_size already went through the session and answered in
172
+ # points, while this went around it and answered in whatever each window used.
173
+ fetch_source() {
174
+ local src
175
+ src="$(session_get '/source?format=json')"
176
+ printf '%s' "$src" | jq -e '.value' >/dev/null 2>&1 \
177
+ || die "WebDriverAgent returned no tree — run $(as_cmd doctor)" 3
178
+ printf '%s' "$src"
179
+ }
180
+
181
+ # try_fetch_source — like fetch_source, but returns 1 instead of dying.
182
+ #
183
+ # For advisory work that must not kill an operation which already succeeded: a
184
+ # screenshot that was written is still a screenshot, even if the check that
185
+ # annotates it could not run.
186
+ try_fetch_source() {
187
+ local sid body rc=0
188
+ # The cached session only. Creating one here would make an advisory check that
189
+ # is allowed to fail into something that can fail loudly, and reading the
190
+ # system tree instead would answer a different question than the caller asked.
191
+ sid="$(cat "$(session_file)" 2>/dev/null || true)"
192
+ [ -n "$sid" ] || return 1
193
+ body="$(curl -sS -m "$(http_timeout)" "$(wda_base)/session/$sid/source?format=json" 2>/dev/null)" || rc=$?
194
+ [ "$rc" -eq 0 ] || return 1
195
+ printf '%s' "$body" | jq -e '.value' >/dev/null 2>&1 || return 1
196
+ printf '%s' "$body"
197
+ }
198
+
199
+ # --- what is inside a bundle, and what will let it onto a phone ---------------
200
+ #
201
+ # DeviceTools does not build. It does have to say why a build will not install,
202
+ # because it is the only party that can see the bundle and the device at once,
203
+ # and the errors iOS gives for this are `ApplicationVerificationFailed` and
204
+ # `MismatchedApplicationIdentifierEntitlement`, which name neither.
205
+
206
+ # bundle_facts <path> — "id|version|build|team|signed", or non-zero.
207
+ bundle_facts() {
208
+ local p="$1" plist id ver bld team sig
209
+ plist="$p/Info.plist"
210
+ [ -f "$plist" ] || return 1
211
+ id="$(plutil -extract CFBundleIdentifier raw -o - "$plist" 2>/dev/null || true)"
212
+ ver="$(plutil -extract CFBundleShortVersionString raw -o - "$plist" 2>/dev/null || true)"
213
+ bld="$(plutil -extract CFBundleVersion raw -o - "$plist" 2>/dev/null || true)"
214
+ if codesign -dv "$p" >/dev/null 2>&1; then
215
+ sig=signed
216
+ team="$(codesign -dv --verbose=4 "$p" 2>&1 | sed -n 's/^TeamIdentifier=//p' | head -1)"
217
+ [ "$team" = "not set" ] && team=""
218
+ else
219
+ sig=unsigned
220
+ team=""
221
+ fi
222
+ printf '%s|%s|%s|%s|%s' "$id" "$ver" "$bld" "$team" "$sig"
223
+ }
224
+
225
+ # install_record / last_install — what DeviceTools put on this phone, and when.
226
+ #
227
+ # The device does not volunteer a version: reading one back needs
228
+ # ideviceinstaller, which is a separate install and is usually absent. So this
229
+ # records what it installed and says that is what it is — "installed by
230
+ # DeviceTools at T" is a smaller claim than "the phone is running T", and it is
231
+ # one that is true.
232
+ install_record() { printf '%s/installed.tsv' "$(state_dir)"; }
233
+ last_install() { cat "$(install_record)" 2>/dev/null || true; }
234
+
235
+ # content_digest <source> — one number over the type, label and value of every
236
+ # visible element. Changes when the screen's contents change, including in ways
237
+ # the screen hash deliberately ignores: the screen hash answers "am I still on
238
+ # the same screen", and a field filling up is the same screen.
239
+ content_digest() {
240
+ printf '%s' "$1" | jq -r "$JQ_CLEAN"'
241
+ (.value | walk(0)) | map(select(.vis))
242
+ | map("\(.t)\u001f\(.lb)\u001f\(.v)") | join("\u001e")
243
+ ' 2>/dev/null | cksum | awk '{ print $1 }'
244
+ }
245
+
246
+ # match_elements <source> <kind> <value>
247
+ # One line per exact, visible, non-degenerate match:
248
+ # x|y|w|h|type|id|label|text
249
+ #
250
+ # Matching is exact rather than substring on purpose. On the first screen this
251
+ # was tested against, "Đăng nhập" is both a Button and the StaticText nested
252
+ # inside it; a substring match that quietly takes the first candidate taps the
253
+ # label instead of the button, depending on tree order. That is the worst
254
+ # available failure mode, because it mostly works.
255
+ match_elements() {
256
+ if [ "$2" = holding ]; then holding_elements "$1" "$3"; return; fi
257
+ printf '%s' "$1" | jq -r --arg kind "$2" --arg val "$3" "$JQ_CLEAN"'
258
+ (.value | walk(0))
259
+ | map(select(.vis and .w > 0 and .h > 0))
260
+ | map(select(
261
+ if $kind == "id" then .id == $val
262
+ elif $kind == "kind" then .t == $val
263
+ elif $kind == "text" then .v == $val
264
+ else .lb == $val end))
265
+ | .[]
266
+ | "\(.x)|\(.y)|\(.w)|\(.h)|\(.t)|\(.id)|\(.lb)|\(.v)"
267
+ '
268
+ }
269
+
270
+ # holding_elements <source> <text> — the control that contains that text.
271
+ #
272
+ # The anchor is matched WITHOUT the visibility filter, because the whole reason
273
+ # this exists is that XCUITest calls a caption invisible when a sibling takes
274
+ # its hit test. The container must still be visible and of a type a user can
275
+ # act on; of those, the smallest containing one wins, so a caption inside a cell
276
+ # inside a table resolves to the cell rather than the table.
277
+ #
278
+ # One result per anchor, deduplicated. Two separate controls holding the same
279
+ # text stay two results, and the caller refuses to guess between them exactly as
280
+ # it would for any other ambiguous selector.
281
+ holding_elements() {
282
+ printf '%s' "$1" | jq -r --arg val "$2" --arg tappable "$DT_TAPPABLE_TYPES" "$JQ_CLEAN"'
283
+ (.value | walk(0)) as $all
284
+ | ($all | map(select(.w > 0 and .h > 0 and (.lb == $val or .v == $val)))) as $anchors
285
+ | ($all | map(select(.vis and .w > 0 and .h > 0
286
+ and (.t | test("^(" + $tappable + ")$"))))) as $controls
287
+ | $anchors
288
+ | map(. as $a
289
+ | $controls
290
+ | map(select(.x <= $a.x and .y <= $a.y
291
+ and (.x + .w) >= ($a.x + $a.w)
292
+ and (.y + .h) >= ($a.y + $a.h)))
293
+ | sort_by(.w * .h)
294
+ | first)
295
+ | map(select(. != null))
296
+ | unique_by([.x, .y, .w, .h, .t])
297
+ | .[]
298
+ | "\(.x)|\(.y)|\(.w)|\(.h)|\(.t)|\(.id)|\(.lb)|\(.v)"
299
+ '
300
+ }
301
+
302
+ # keyboard_blocks <source> <x> <y> <w> <h> — "x y w h" of the keyboard when it
303
+ # stands between the caller and that element, nothing when it does not.
304
+ #
305
+ # Free here: the keyboard is already in the tree that was fetched, so this costs
306
+ # no round trip.
307
+ #
308
+ # MEMBERSHIP IS ANSWERED BY THE HIERARCHY, NOT BY THE TYPE. The obvious version
309
+ # of this asked whether the element's type was Key — and on the first screen it
310
+ # was tried against, the keyboard's own shift, Go, Emoji and Dictate are all
311
+ # plain Buttons. Type would have refused four legitimate taps on the keyboard
312
+ # while claiming to protect them. So the walk carries a flag down: anything
313
+ # under the Keyboard element is the keyboard, whatever it calls itself.
314
+ # drv_keyboard_dismiss — ask the keyboard to go away, then insist.
315
+ #
316
+ # WebDriverAgent's own dismiss works by pressing a Done or Return key, and an
317
+ # app that offers neither simply keeps its keyboard: on the login screen
318
+ # this was built against, the endpoint returned success and the keyboard stayed
319
+ # up through a scroll as well.
320
+ #
321
+ # So the fallback is what a person does — tap the screen away from everything.
322
+ # The point is COMPUTED, NOT GUESSED. Tapping a coordinate that happens to look
323
+ # empty is exactly the wrong-tap-in-a-loop this project refuses; instead the
324
+ # tree is asked which parts of the screen are spoken for, and the first place
325
+ # nothing occupies is used. When every candidate is occupied, this gives up and
326
+ # the verb reports that the keyboard stayed.
327
+ drv_keyboard_dismiss() {
328
+ local src kb pt kbx kby kbw kbh
329
+ session_post '/wda/keyboard/dismiss' '{}' >/dev/null 2>&1
330
+ src="$(fetch_source)"
331
+ kb="$(keyboard_rect "$src")"
332
+ [ -n "$kb" ] || return 0
333
+ read -r kbx kby kbw kbh <<< "$kb"
334
+ pt="$(blank_point "$src" "$kby")"
335
+ [ -n "$pt" ] || return 0
336
+ # shellcheck disable=SC2086
337
+ drv_tap $pt >/dev/null 2>&1
338
+ }
339
+
340
+ # blank_point <source> <keyboard-top> — "x y" of a place on screen that no
341
+ # element claims, above the keyboard, or nothing when there is no such place.
342
+ #
343
+ # "Claimed" is deliberately generous: anything a user can act on, anything with
344
+ # an identifier, and anything carrying text. A custom control on iOS is often a
345
+ # plain container with a gesture recogniser and nothing to distinguish it, so
346
+ # the safe reading is that a labelled or identified region belongs to somebody.
347
+ blank_point() {
348
+ local win_w win_h
349
+ read -r win_w win_h <<< "$(window_size "$1")"
350
+ printf '%s' "$1" | jq -r \
351
+ --argjson kby "$2" --argjson w "$win_w" --argjson h "$win_h" \
352
+ --arg tappable "$DT_TAPPABLE_TYPES" "$JQ_CLEAN"'
353
+ (.value | walk(0))
354
+ | map(select(.vis and .w > 0 and .h > 0
355
+ and ((.t | test("^(" + $tappable + ")$"))
356
+ or .id != "" or .lb != "" or .v != "")
357
+ # A container the size of the window claims every point on the
358
+ # screen and so claims none of them usefully. This login screen
359
+ # carries a full-window Toolbar with both an identifier and a
360
+ # label, which alone made the whole search fail. Same rule the
361
+ # inspector uses to stop containers swallowing their contents.
362
+ and (.w < ($w * 95 / 100) or .h < ($h * 90 / 100))))
363
+ as $taken
364
+ | ($w / 2 | floor) as $x
365
+ # Upward from just above the keyboard: the lower half of a form is where the
366
+ # padding lives, and the top is where the navigation bar is.
367
+ | [ range(($kby - 16); 60; -16) ]
368
+ | map(select(. as $y
369
+ | ($taken | any(.x <= $x and .y <= $y
370
+ and (.x + .w) >= $x and (.y + .h) >= $y)) | not))
371
+ | first
372
+ | if . == null then empty else "\($x) \(.)" end
373
+ '
374
+ }
375
+
376
+ # keyboard_rect <source> — "x y w h" of the keyboard, or nothing when it is down.
377
+ keyboard_rect() {
378
+ printf '%s' "$1" | jq -r "$JQ_CLEAN"'
379
+ (.value | walk(0))
380
+ | map(select(.t == "Keyboard" and .vis and .w > 0 and .h > 0))
381
+ | first
382
+ | if . == null then empty else "\(.x) \(.y) \(.w) \(.h)" end
383
+ '
384
+ }
385
+
386
+ keyboard_blocks() {
387
+ printf '%s' "$1" | jq -r \
388
+ --argjson tx "$2" --argjson ty "$3" --argjson tw "$4" --argjson th "$5" '
389
+ def walk($k):
390
+ ( ((.type // "") == "Keyboard") ) as $isk
391
+ | ( $isk or $k ) as $ink
392
+ | [ { isk: $isk, ink: $ink,
393
+ x: (.rect.x // 0), y: (.rect.y // 0),
394
+ w: (.rect.width // 0), h: (.rect.height // 0),
395
+ vis: ((.isVisible // "") == "1") } ]
396
+ + ([ (.children // [])[] | walk($ink) ] | add // []);
397
+ (.value | walk(false)) as $all
398
+ | ($all | map(select(.isk and .vis and .w > 0 and .h > 0)) | first) as $kb
399
+ | if $kb == null then empty
400
+ elif ($all | any(.ink and .x == $tx and .y == $ty and .w == $tw and .h == $th))
401
+ then empty
402
+ else
403
+ (($tx + $tw / 2) as $cx | ($ty + $th / 2) as $cy
404
+ | if $cx >= $kb.x and $cx <= ($kb.x + $kb.w)
405
+ and $cy >= $kb.y and $cy <= ($kb.y + $kb.h)
406
+ then "\($kb.x) \($kb.y) \($kb.w) \($kb.h)"
407
+ else empty end)
408
+ end
409
+ '
410
+ }
411
+
412
+ # unhittable_twin <source> <kind> <value> <x> <y> <w> <h> — the control this
413
+ # selector really meant, when the caller is about to settle for a label instead.
414
+ #
415
+ # THE FAILURE THIS EXISTS FOR LOOKS EXACTLY LIKE SUCCESS.
416
+ #
417
+ # A login screen carries the words "Đăng nhập" twice: the title at the top and
418
+ # the submit button at the bottom. With the keyboard up the driver reports the
419
+ # button as not visible, so it is filtered out and the title is the only match
420
+ # left. One match is not ambiguous, so nothing was refused: the run tapped the
421
+ # title, the app did nothing, and the step printed OK. The baseline then
422
+ # recorded the title as the right answer, so even the fingerprint agreed with
423
+ # the mistake ever afterwards.
424
+ #
425
+ # The evidence was there the whole time — an actionable element with the same
426
+ # words, present in the tree and unhittable. Only reported when the element
427
+ # about to be tapped is not itself actionable: two buttons with one covered is a
428
+ # different situation, and tapping the visible one is right.
429
+ unhittable_twin() {
430
+ printf '%s' "$1" | jq -r --arg kind "$2" --arg val "$3" \
431
+ --argjson rx "$4" --argjson ry "$5" --argjson rw "$6" --argjson rh "$7" \
432
+ --arg tappable "$DT_TAPPABLE_TYPES" "$JQ_CLEAN"'
433
+ (.value | walk(0)) as $all
434
+ | ($all | map(select(.w > 0 and .h > 0 and (
435
+ if $kind == "id" then .id == $val
436
+ elif $kind == "kind" then .t == $val
437
+ elif $kind == "text" then .v == $val
438
+ else .lb == $val end)))) as $named
439
+ | ($named | map(select(.x == $rx and .y == $ry and .w == $rw and .h == $rh))
440
+ | first) as $self
441
+ | if ($self != null and ($self.t | test("^(" + $tappable + ")$"))) then empty
442
+ else
443
+ ( $named
444
+ | map(select((.vis | not) and (.t | test("^(" + $tappable + ")$"))))
445
+ | first
446
+ | if . == null then empty else "\(.t) at \(.x),\(.y),\(.w),\(.h)" end )
447
+ end
448
+ '
449
+ }
450
+
451
+ # named_anywhere <source> <kind> <value> — every element the selector names, with
452
+ # the visibility filter off:
453
+ #
454
+ # x|y|w|h|type|elements-sharing-that-exact-rect|visible
455
+ #
456
+ # Reached only after match_elements has already come back empty, so anything
457
+ # here is either invisible or degenerate — which is precisely the distinction
458
+ # `nothing on screen contains it` was failing to draw. See invisible_note.
459
+ named_anywhere() {
460
+ printf '%s' "$1" | jq -r --arg kind "$2" --arg val "$3" "$JQ_CLEAN"'
461
+ (.value | walk(0)) as $all
462
+ | $all
463
+ | map(select(
464
+ if $kind == "id" then .id == $val
465
+ elif $kind == "kind" then .t == $val
466
+ elif $kind == "text" then .v == $val
467
+ elif $kind == "any" then (.id == $val or .lb == $val or .v == $val)
468
+ else .lb == $val end))
469
+ | .[] as $e
470
+ | ($all | map(select(.x == $e.x and .y == $e.y and .w == $e.w and .h == $e.h)) | length) as $same
471
+ | "\($e.x)|\($e.y)|\($e.w)|\($e.h)|\($e.t)|\($same)|\(if $e.vis then 1 else 0 end)"
472
+ '
473
+ }
474
+
475
+ # text_anywhere <source> <text> — does that exact text exist at all, visible or
476
+ # not? Used only to tell a `holding:` failure apart from a missing screen.
477
+ text_anywhere() {
478
+ printf '%s' "$1" | jq -r --arg val "$2" "$JQ_CLEAN"'
479
+ (.value | walk(0))
480
+ | map(select(.lb == $val or .v == $val))
481
+ | .[] | "\(.x)|\(.y)|\(.w)|\(.h)|\(.t)"
482
+ '
483
+ }
484
+
485
+ # kinds_on_screen <source> — the element types present, commonest first, with
486
+ # counts. The answer to a `kind:` that matched nothing: candidate_elements
487
+ # searches identifiers, labels and values, so for a type it has nothing to say.
488
+ kinds_on_screen() {
489
+ printf '%s' "$1" | jq -r "$JQ_CLEAN"'
490
+ (.value | walk(0))
491
+ | map(select(.vis and .w > 0 and .h > 0))
492
+ | group_by(.t) | map({t: .[0].t, n: length})
493
+ | sort_by(-.n) | .[] | "\(.t) x\(.n)"
494
+ ' 2>/dev/null | take_lines 8 | join_lines ', '
495
+ }
496
+
497
+ # candidate_elements <source> <value> [limit]
498
+ # Near misses, for the message printed when an exact match fails. Being told
499
+ # what is actually on screen is what lets a caller recover without a screenshot.
500
+ # candidate_elements <source> <value> [n] — what is on screen that *contains*
501
+ # the value, for the message printed when nothing matched it exactly.
502
+ #
503
+ # A candidate that some field matches EXACTLY is named with the selector that
504
+ # would reach it. Reported from a real session: `tap text:8` was refused and the
505
+ # suggestion list offered `Key "8"` — which is exactly the thing being asked
506
+ # for, matching on its label rather than its value. Listing the answer without
507
+ # saying how to spell it is a riddle, not a hint.
508
+ candidate_elements() {
509
+ printf '%s' "$1" | jq -r --arg val "$2" "$JQ_CLEAN"'
510
+ (.value | walk(0))
511
+ | map(select(.vis and .w > 0 and .h > 0))
512
+ | map(select(.id != "" or .lb != "" or .v != ""))
513
+ | map(select(
514
+ ([.id, .lb, .v] | join(" ") | ascii_downcase) | contains($val | ascii_downcase)
515
+ ))
516
+ | .[]
517
+ | .t
518
+ + (if .id != "" then " #" + .id else "" end)
519
+ + (if .lb != "" then " \"" + .lb + "\"" else "" end)
520
+ + (if .v != "" and .v != .lb then " =" + .v else "" end)
521
+ + (if .id == $val then " → try id:" + $val
522
+ elif .lb == $val then " → try label:" + $val
523
+ elif .v == $val then " → try text:" + $val
524
+ else "" end)
525
+ ' | head -n "${3:-5}"
526
+ }
527
+
528
+ # --- sessions ----------------------------------------------------------------
529
+ #
530
+ # Every command that acts on the UI needs a session. Creating one per call costs
531
+ # a round trip on every action, so the id is cached on disk and reused. The
532
+ # cache is a hint and never truth: sessions die whenever the app or the runner
533
+ # restarts, so a stale id is detected from the response and replaced once.
534
+ session_file() { printf '%s/session.id' "$(state_dir)"; }
535
+
536
+ wda_new_session() {
537
+ local resp sid
538
+ resp="$(wda_post '/session' '{"capabilities":{"alwaysMatch":{}}}')"
539
+ sid="$(printf '%s' "$resp" | jq -r '.value.sessionId // .sessionId // empty' 2>/dev/null || true)"
540
+ [ -n "$sid" ] || die "could not create a WebDriverAgent session — run $(as_cmd doctor)" 3
541
+ printf '%s' "$sid" > "$(session_file)"
542
+ printf '%s' "$sid"
543
+ }
544
+
545
+ # wda_drop_session — forget the cached session so the next call makes a new one.
546
+ #
547
+ # A session holds a reference to the application it was created against. Killing
548
+ # that application leaves the reference dangling, and WebDriverAgent then
549
+ # rejects a subsequent launch with "invalid argument" — an error that says
550
+ # nothing about the real cause. Anything that ends an app's process must drop
551
+ # the session with it.
552
+ wda_drop_session() {
553
+ rm -f "$(session_file)" 2>/dev/null || true
554
+ }
555
+
556
+ wda_session() {
557
+ local f sid=""
558
+ f="$(session_file)"
559
+ [ -f "$f" ] && sid="$(cat "$f" 2>/dev/null || true)"
560
+ if [ -n "$sid" ]; then printf '%s' "$sid"; else wda_new_session; fi
561
+ }
562
+
563
+ # session_post <suffix> <json> — POST to /session/<id><suffix>, replacing the
564
+ # session once if the cached one has expired.
565
+ session_post() {
566
+ local sid resp err
567
+ sid="$(wda_session)"
568
+ resp="$(wda_post "/session/$sid$1" "$2")"
569
+ err="$(printf '%s' "$resp" | jq -r '.value.error // empty' 2>/dev/null || true)"
570
+ case "$err" in
571
+ "invalid session id"|"no such session"|"A session is either terminated or not started")
572
+ sid="$(wda_new_session)"
573
+ resp="$(wda_post "/session/$sid$1" "$2")"
574
+ ;;
575
+ esac
576
+ printf '%s' "$resp"
577
+ }
578
+
579
+ # session_get <suffix> — GET /session/<id><suffix>, replacing a stale session once.
580
+ session_get() {
581
+ local sid resp err
582
+ sid="$(wda_session)"
583
+ resp="$(wda_get "/session/$sid$1")"
584
+ err="$(printf '%s' "$resp" | jq -r '.value.error // empty' 2>/dev/null || true)"
585
+ case "$err" in
586
+ "invalid session id"|"no such session"|"A session is either terminated or not started")
587
+ sid="$(wda_new_session)"
588
+ resp="$(wda_get "/session/$sid$1")"
589
+ ;;
590
+ esac
591
+ printf '%s' "$resp"
592
+ }
593
+
594
+ # session_error <response> — the WebDriverAgent error message, or empty.
595
+ session_error() {
596
+ printf '%s' "$1" | jq -r '
597
+ if (.value | type) == "object" then (.value.error // .value.message // empty) else empty end
598
+ ' 2>/dev/null || true
599
+ }
600
+
601
+
602
+ # device_locale — the language and region the screen is currently rendered in,
603
+ # normalised to en-US form. WDA answers for a simulator and a physical device
604
+ # alike, which is why this asks the driver rather than ideviceinfo or simctl:
605
+ # one source, and it is one that is already required to be running.
606
+ #
607
+ # Best-effort by contract. Every caller treats "" as "unknown" and carries on,
608
+ # because provenance must never be the thing that fails a step.
609
+ device_locale() {
610
+ local resp l
611
+ resp="$(wda_get '/wda/device/info' 2>/dev/null || true)"
612
+ l="$(printf '%s' "$resp" | jq -r '.value.currentLocale // empty' 2>/dev/null || true)"
613
+ printf '%s' "${l//_/-}"
614
+ }
615
+
616
+ # window_size [source] — "WIDTH HEIGHT" in the same coordinate space tree.sh prints.
617
+ #
618
+ # The argument is accepted and ignored. WebDriverAgent answers this with a
619
+ # request, so a caller that already holds a tree cannot save anything by passing
620
+ # it — but the Android adapter can, and does. The shared verbs call it the same
621
+ # way on both platforms because that is the point of a shared verb.
622
+ window_size() {
623
+ local resp w h
624
+ resp="$(session_get '/window/size')"
625
+ w="$(printf '%s' "$resp" | jq -r '.value.width // empty' 2>/dev/null || true)"
626
+ h="$(printf '%s' "$resp" | jq -r '.value.height // empty' 2>/dev/null || true)"
627
+ [ -n "$w" ] && [ -n "$h" ] \
628
+ || die "could not read the window size — run $(as_cmd doctor)" 3
629
+ printf '%s %s' "$w" "$h"
630
+ }
631
+
632
+ # tree_rows <source> — the tree in the one shape both platforms agree on:
633
+ #
634
+ # depth|type|id|label|value|x|y|w|h
635
+ #
636
+ # Visible, non-degenerate elements only. This is what the resilient resolver in
637
+ # ../lib.sh works on, and it is the reason that resolver can be written once:
638
+ # the scoring never learns which platform produced the rows.
639
+ tree_rows() {
640
+ printf '%s' "$1" | jq -r "$JQ_CLEAN"'
641
+ (.value | walk(0))
642
+ | map(select(.vis and .w > 0 and .h > 0))
643
+ | .[]
644
+ | "\(.d)|\(.t)|\(.id)|\(.lb)|\(.v)|\(.x)|\(.y)|\(.w)|\(.h)"
645
+ '
646
+ }
647
+
648
+ # --- gestures ----------------------------------------------------------------
649
+ #
650
+ # Four primitives, matching scripts/android/lib.sh name for name. Everything the
651
+ # shared verbs in scripts/common/ do to the screen goes through one of them,
652
+ # which is what lets those verbs exist without knowing that WebDriverAgent does.
653
+ #
654
+ # Each returns 0 on success, or prints one line and returns 1. They do not call
655
+ # die: the caller decides what a failed gesture means and phrases it in terms of
656
+ # what the caller was doing.
657
+
658
+ # drv_tap <x> <y>
659
+ drv_tap() {
660
+ local resp err
661
+ resp="$(session_post '/wda/tap' "$(printf '{"x":%s,"y":%s}' "$1" "$2")")"
662
+ err="$(session_error "$resp")"
663
+ [ -z "$err" ] && return 0
664
+ printf '%s' "$err"
665
+ return 1
666
+ }
667
+
668
+ # drv_drag <from-x> <from-y> <to-x> <to-y> <hold-ms> [travel-ms]
669
+ #
670
+ # WebDriverAgent takes the hold as seconds in a double; the argument is in
671
+ # milliseconds because that is the unit gestures are actually described in.
672
+ # `travel-ms` is accepted and ignored — WebDriverAgent does not expose the
673
+ # travel time separately, while UiAutomator2 does.
674
+ drv_drag() {
675
+ local resp err secs
676
+ secs="$(awk -v ms="$5" 'BEGIN { printf "%.3f", ms / 1000 }')"
677
+ resp="$(session_post '/wda/dragfromtoforduration' \
678
+ "$(jq -nc --argjson fx "$1" --argjson fy "$2" --argjson tx "$3" --argjson ty "$4" \
679
+ --argjson d "$secs" '{fromX: $fx, fromY: $fy, toX: $tx, toY: $ty, duration: $d}')")"
680
+ err="$(session_error "$resp")"
681
+ [ -z "$err" ] && return 0
682
+ printf '%s' "$err"
683
+ return 1
684
+ }
685
+
686
+ # drv_open_url <url> — hand a URL to the system to route.
687
+ #
688
+ # Opening a link can start another application, which takes as long as any other
689
+ # launch, so this borrows the app timeout rather than the read timeout.
690
+ drv_open_url() {
691
+ local resp err
692
+ resp="$(DT_HTTP_TIMEOUT="$(cfg '.timeouts.app_seconds' '90')" \
693
+ session_post '/url' "$(jq -nc --arg u "$1" '{url: $u}')")"
694
+ err="$(session_error "$resp")"
695
+ [ -z "$err" ] && return 0
696
+ printf '%s' "$err"
697
+ return 1
698
+ }
699
+
700
+ # drv_foreground_app — the bundle id now in front, or empty.
701
+ drv_foreground_app() {
702
+ wda_get '/wda/activeAppInfo' 2>/dev/null | jq -r '.value.bundleId // empty' 2>/dev/null || true
703
+ }
704
+
705
+ # drv_fingerprint <source> — a number that changes when the screen changes.
706
+ #
707
+ # Deliberately excludes every element's value, and is sorted. Measured on an
708
+ # idle login screen, a fingerprint that included values changed on every single
709
+ # sample, because a carousel and a page indicator update themselves with nothing
710
+ # touching the device. Structure — types, identifiers and labels — was identical
711
+ # across the same samples. Including values would report every gesture as a
712
+ # success.
713
+ drv_fingerprint() {
714
+ printf '%s' "$1" | jq -r "$JQ_CLEAN"'
715
+ (.value | walk(0)) | map(select(.vis)) | map("\(.t)\(.id)\(.lb)") | sort | join("")' | cksum
716
+ }
717
+
718
+ # drv_fingerprint_pos <source> — as above, but including each element's y.
719
+ #
720
+ # For scrolling, where the structure is unchanged and only the position moves.
721
+ drv_fingerprint_pos() {
722
+ printf '%s' "$1" | jq -r "$JQ_CLEAN"'
723
+ (.value | walk(0)) | map(select(.vis)) | map("\(.t)\(.id)\(.lb)\(.y)") | sort | join("")' | cksum
724
+ }
725
+
726
+
727
+ # --- the normalised screen -----------------------------------------------------
728
+ #
729
+ # snapshot_rows — every element on screen, in document order, in the one shape
730
+ # both adapters agree on. This is the seam: scripts/snapshot.awk, measure.sh and
731
+ # uid_resolve all read this and none of them can tell which driver produced it.
732
+ #
733
+ # WINDOW|<w>|<h>|<orientation>
734
+ # <n>|<depth>|<parent-n>|<type>|<id>|<label>|<value>|<x>|<y>|<w>|<h>|<flags>
735
+ #
736
+ # <n> is a 1-based serial covering EVERY element, filtered or not. The display
737
+ # filter runs downstream, and a warning like "outside parent [12]" has to be
738
+ # able to name a container nobody asked to see.
739
+ #
740
+ # <flags> is a comma-separated subset of visible, enabled, actionable.
741
+ snapshot_rows() {
742
+ local src w h orient
743
+ src="$(fetch_source)"
744
+
745
+ # The root element's rect is the screen. Asking the session for /window/size
746
+ # would be a second request, and a request that fails when no session is open
747
+ # — this has to work for a verb that only reads.
748
+ w="$(printf '%s' "$src" | jq -r '.value.rect.width // 0 | floor')"
749
+ h="$(printf '%s' "$src" | jq -r '.value.rect.height // 0 | floor')"
750
+ if [ "$w" -gt "$h" ] 2>/dev/null; then orient=landscape; else orient=portrait; fi
751
+ printf 'WINDOW|%s|%s|%s\n' "$w" "$h" "$orient"
752
+
753
+ # The parent is filled in by the awk pass below rather than threaded through
754
+ # the walk. jq's recursive walk cannot carry a running serial and a parent at
755
+ # once without rebuilding it, and the depth stack does the same job in a line.
756
+ printf '%s' "$src" | jq -r --arg tappable "$DT_TAPPABLE_TYPES" "$JQ_CLEAN"'
757
+ (.value | walk(0))
758
+ | to_entries
759
+ | map(.value + { n: (.key + 1) })
760
+ | .[]
761
+ | . as $e
762
+ | ([ (if $e.vis then "visible" else empty end),
763
+ (if $e.en then "enabled" else empty end),
764
+ (if ($e.t | test("^(" + $tappable + ")$")) then "actionable" else empty end)
765
+ ] | join(",")) as $flags
766
+ | "\($e.n)|\($e.d)|0|\($e.t)|\($e.id)|\($e.lb)|\($e.v)|\($e.x|floor)|\($e.y|floor)|\($e.w|floor)|\($e.h|floor)|\($flags)"
767
+ ' | awk -F'|' -v OFS='|' '
768
+ # PARENT FROM DEPTH, DONE WITH A STACK. Keep the last serial seen at each
769
+ # depth: a row at depth d has whatever is at d-1 as its parent, and then
770
+ # becomes the entry for d itself. A subtree that ends needs no popping,
771
+ # because the next row at that depth overwrites the entry.
772
+ #
773
+ # The obvious alternative — "the previous row at depth-1" — picks the wrong
774
+ # parent the moment a subtree is empty.
775
+ { stack[$2] = $1
776
+ $3 = ($2 + 0 == 0) ? 0 : stack[$2 - 1] + 0
777
+ print }
778
+ '
779
+ }
780
+
781
+ # capture_png <path> — the screen, as a PNG, at the device's own resolution.
782
+ #
783
+ # Returns 2 when the file could not be written and 3 when the driver gave back
784
+ # something that is not an image, so the caller can phrase each one in its own
785
+ # terms. It does not call die: the caller decides what a failed capture means.
786
+ capture_png() {
787
+ local out="$1" b64
788
+ b64="$(wda_get '/screenshot' | jq -r '.value // empty')" || return 3
789
+ [ -n "$b64" ] || return 3
790
+ printf '%s' "$b64" | base64 --decode > "$out" 2>/dev/null || return 2
791
+ # A PNG always starts with the same eight bytes. Checking them turns a
792
+ # truncated response or an error page into a clear failure now, rather than a
793
+ # file that fails somewhere else later.
794
+ [ "$(head -c 8 "$out" | od -An -tx1 | tr -d ' \n')" = "89504e470d0a1a0a" ] \
795
+ || { rm -f "$out"; return 3; }
796
+ return 0
797
+ }
798
+
799
+ # logs_since <unix-seconds> — what was logged after that moment.
800
+ #
801
+ # BEST-EFFORT, AND SILENT WHEN THERE IS NOTHING. Two rules hold here and both
802
+ # matter:
803
+ #
804
+ # * it never starts a collector. logs.sh starts one when you ask it to read,
805
+ # which is right for a verb somebody typed; doing it inside every tap would
806
+ # spawn a background process as a side effect of touching a button, and this
807
+ # device produces twenty-one thousand lines in thirty seconds.
808
+ # * it never fails. A log stream that will not open must not fail a tap that
809
+ # already landed on the phone.
810
+ #
811
+ # So an agent that wants the LOG section runs `logs start` once, and until then
812
+ # actions simply do not carry one.
813
+ #
814
+ # The app filter is the last component of the bundle id, matched without regard
815
+ # to case — com.example.demoapp against a process called DemoApp. That is a
816
+ # heuristic and is named as one: iOS does not publish the process name for a
817
+ # bundle id anywhere this can reach.
818
+ # log_filter — from a device's whole syslog down to what the app said.
819
+ #
820
+ # A line looks like:
821
+ #
822
+ # Aug 27 12:18:58.913302 DemoApp(CoreMotion)[78395] <Info>: message
823
+ # \_ timestamp ________/ \_ process _/\_ library _/
824
+ #
825
+ # TWO FILTERS, BECAUSE THE PROCESS NAME ALONE IS NOT ENOUGH.
826
+ #
827
+ # The old rule was `grep -i <app>`, matching the name anywhere on the line. That
828
+ # is why an agent asking for the log after each action got eight lines of
829
+ # hit-testing and accelerometer traffic: those lines DO carry the app's process
830
+ # name, because the OS logged them on the app's behalf. Measured on a real
831
+ # 40,775-line capture: the old rule kept 354 lines, of which 162 were attributed
832
+ # to the app and every one of those came from a system framework —
833
+ # CoreMotion 82, UIKitCore 64, RunningBoardServices 16 — and the app's own
834
+ # logging, with no framework in parentheses, was zero lines.
835
+ #
836
+ # So the process has to be the app AND the framework has to not be one of the
837
+ # ones that log on everybody's behalf. When that leaves nothing, nothing is the
838
+ # answer: this app does not log, and eight lines of somebody else's telemetry is
839
+ # a worse answer than none.
840
+ log_filter() {
841
+ local app excl out
842
+ app="$(cfg '.app.bundle_id' '')"; app="${app##*.}"
843
+ excl="$(cfg '.logs.exclude_subsystems' \
844
+ 'AXRuntime|UIAccessibility|AccessibilityUtilities|CoreMotion|UIKitCore|RunningBoardServices|CoreFoundation|CoreBrightness|CoreBluetooth|CoreTelephony|WirelessProximity|BackBoardHIDEventProcessors|MediaSafetyNet|WPDaemon|libsystem_network')"
845
+ out="$(cat)"
846
+ if [ -n "$app" ]; then
847
+ out="$(printf '%s\n' "$out" | grep -iE "^[A-Za-z]{3} +[0-9]+ +[0-9:.]+ +${app}[([]" || true)"
848
+ fi
849
+ if [ -n "$excl" ]; then
850
+ out="$(printf '%s\n' "$out" | grep -vEi "\(($excl)\)\[[0-9]+\]" || true)"
851
+ fi
852
+ printf '%s' "$out"
853
+ }
854
+
855
+ logs_since() {
856
+ local since="$1" f out
857
+ f="$(state_dir)/syslog.log"
858
+ [ -s "$f" ] || return 0
859
+ out="$(awk -F'\t' -v c="$since" '$1 >= c { sub(/^[0-9]+\t/, ""); print }' "$f" 2>/dev/null || true)"
860
+ [ -n "$out" ] || return 0
861
+ out="$(printf '%s\n' "$out" | log_filter)"
862
+ [ -n "$out" ] || return 0
863
+ printf '%s\n' "$out" | sed '/^[[:space:]]*$/d' | tail -8
864
+ }