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.
- package/AGENTS.md +327 -0
- package/LICENSE +21 -0
- package/README.md +491 -0
- package/VERSION +1 -0
- package/bin/device-devtools-mcp.js +106 -0
- package/bin/devicetools +315 -0
- package/config.example.json +63 -0
- package/integrations/agent-pointer.sh +48 -0
- package/integrations/claude/SKILL.md +8 -0
- package/integrations/cursor/devicetools.mdc +8 -0
- package/integrations/gemini/GEMINI.md +5 -0
- package/integrations/mcp/README.md +184 -0
- package/integrations/mcp/mcp.json +10 -0
- package/integrations/mcp/reference.sh +119 -0
- package/integrations/mcp/selftest.sh +158 -0
- package/integrations/mcp/server.sh +343 -0
- package/package.json +50 -0
- package/scripts/android/app.sh +241 -0
- package/scripts/android/back.sh +73 -0
- package/scripts/android/controls.sh +56 -0
- package/scripts/android/devices.sh +77 -0
- package/scripts/android/doctor.sh +253 -0
- package/scripts/android/lib.sh +699 -0
- package/scripts/android/logs.sh +289 -0
- package/scripts/android/permission.sh +119 -0
- package/scripts/android/settings.sh +63 -0
- package/scripts/android/setup.sh +97 -0
- package/scripts/android/tree.awk +166 -0
- package/scripts/android/tree.sh +127 -0
- package/scripts/android/type.sh +191 -0
- package/scripts/common/find.sh +95 -0
- package/scripts/common/key.sh +65 -0
- package/scripts/common/lib.sh +14 -0
- package/scripts/common/measure.sh +170 -0
- package/scripts/common/open.sh +131 -0
- package/scripts/common/screenshot.sh +102 -0
- package/scripts/common/scroll.sh +177 -0
- package/scripts/common/snapshot.sh +69 -0
- package/scripts/common/swipe.sh +171 -0
- package/scripts/common/tap.sh +226 -0
- package/scripts/common/wait.sh +212 -0
- package/scripts/common/waypoint.sh +141 -0
- package/scripts/dispatch.sh +21 -0
- package/scripts/flow.sh +266 -0
- package/scripts/init.sh +101 -0
- package/scripts/ios/app.sh +404 -0
- package/scripts/ios/back.sh +95 -0
- package/scripts/ios/controls.sh +68 -0
- package/scripts/ios/devices.sh +80 -0
- package/scripts/ios/doctor.sh +386 -0
- package/scripts/ios/lib.sh +864 -0
- package/scripts/ios/logs.sh +272 -0
- package/scripts/ios/permission.sh +108 -0
- package/scripts/ios/settings.sh +76 -0
- package/scripts/ios/setup.sh +175 -0
- package/scripts/ios/tree.sh +128 -0
- package/scripts/ios/type.sh +178 -0
- package/scripts/lib.sh +1032 -0
- package/scripts/links.tsv +44 -0
- package/scripts/relink.sh +121 -0
- package/scripts/run.sh +415 -0
- package/scripts/selftest.sh +1709 -0
- package/scripts/snapshot.awk +362 -0
- package/scripts/verify-npm-package.js +133 -0
- package/tests/fixtures/ios-contacts-list.expected +52 -0
- 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
|
+
}
|