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,272 @@
1
+ #!/usr/bin/env bash
2
+ # logs.sh — device logs, collected in the background and read on demand.
3
+ #
4
+ # logs.sh start begin collecting
5
+ # logs.sh stop stop collecting
6
+ # logs.sh status is it collecting, and since when
7
+ # logs.sh [--since Ns] [--process P] [--grep S] [--lines N]
8
+ # read what has been collected
9
+ # --raw every line the device logged, not just
10
+ # the app's. The default keeps only the
11
+ # app's own output: the OS logs a great
12
+ # deal on an app's behalf under its
13
+ # process name, and none of that is the
14
+ # app talking. Use this when the default
15
+ # is empty and you need to know why.
16
+ # --network only lines that look like HTTP requests
17
+ # or responses, from the app under test.
18
+ # THIS IS NOT A PROXY: it filters what the
19
+ # app itself chose to print, so it shows no
20
+ # bodies, no headers the app did not log,
21
+ # and nothing at all from an app that logs
22
+ # none of it. In exchange it needs no CA
23
+ # certificate on the device and certificate
24
+ # pinning cannot block it.
25
+ # The regex is logs.network_pattern.
26
+ #
27
+ # WHY A COLLECTOR RATHER THAN A QUERY
28
+ #
29
+ # `idevicesyslog` is a live stream, not a searchable store: there is no way to
30
+ # ask it what happened thirty seconds ago. So collection has to be running
31
+ # before the thing you want to read about happens. Reading without a collector
32
+ # would quietly return nothing, which reads exactly like "there were no logs" —
33
+ # so instead it starts one and says the history begins here.
34
+ #
35
+ # NO --subsystem
36
+ #
37
+ # `--subsystem` is an os_log concept. On a pre-iOS-17 device reached through
38
+ # idevicesyslog the subsystem is not carried, so the flag would be a filter that
39
+ # silently matched nothing. --process and --grep are what actually exist.
40
+ #
41
+ # exit 0 lines printed, or a lifecycle verb succeeded
42
+ # exit 1 usage error
43
+ # exit 2 environment problem
44
+ # exit 4 nothing was being collected, or nothing matched
45
+
46
+ source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
47
+
48
+ SINCE=""
49
+ PROCESS=""
50
+ GREP=""
51
+ NETWORK=0
52
+ RAW=0
53
+ LINES=""
54
+ VERB="read"
55
+
56
+ while [ $# -gt 0 ]; do
57
+ case "$1" in
58
+ start|stop|status) VERB="$1"; shift ;;
59
+ --since)
60
+ [ $# -ge 2 ] || die "--since needs a number of seconds" 1
61
+ case "${2%s}" in (*[!0-9]*|"") die "--since needs seconds, e.g. --since 30 or --since 30s, got: $2" 1 ;; esac
62
+ SINCE="${2%s}"; shift 2 ;;
63
+ --process)
64
+ [ $# -ge 2 ] || die "--process needs a name" 1
65
+ PROCESS="$2"; shift 2 ;;
66
+ --network) NETWORK=1; shift ;;
67
+ --raw) RAW=1; shift ;;
68
+ --grep)
69
+ [ $# -ge 2 ] || die "--grep needs a string" 1
70
+ GREP="$2"; shift 2 ;;
71
+ --lines)
72
+ [ $# -ge 2 ] || die "--lines needs a number" 1
73
+ case "$2" in (*[!0-9]*|"") die "--lines needs a positive integer, got: $2" 1 ;; esac
74
+ LINES="$2"; shift 2 ;;
75
+ -h|--help) awk 'NR > 1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"; exit 0 ;;
76
+ *) die "unknown argument: $1 (usage: logs.sh [start|stop|status] [--since Ns] [--process P] [--grep S] [--lines N])" 1 ;;
77
+ esac
78
+ done
79
+
80
+ load_config
81
+ [ "$(ios_kind)" = simulator ] || need_cmd idevicesyslog
82
+
83
+ SD="$(state_dir)"
84
+ PIDFILE="$SD/syslog.pid"
85
+ LOGFILE="$SD/syslog.log"
86
+
87
+ collector_pid() {
88
+ [ -f "$PIDFILE" ] || return 1
89
+ local p
90
+ p="$(cat "$PIDFILE" 2>/dev/null || true)"
91
+ [ -n "$p" ] || return 1
92
+ kill -0 "$p" 2>/dev/null || return 1
93
+ printf '%s' "$p"
94
+ }
95
+
96
+ start_collector() {
97
+ local udid max source
98
+ udid="$(device_udid)"
99
+ max="$(cfg '.logs.max_lines_kept' '200000')"
100
+
101
+ # Different producer, same contract: one line per log record on stdout. The
102
+ # stamping and rotation below do not care which one is upstream.
103
+ if [ "$(ios_kind)" = simulator ]; then
104
+ source="xcrun simctl spawn '$udid' log stream --style syslog --level debug"
105
+ else
106
+ source="idevicesyslog -u '$udid'"
107
+ fi
108
+ # Two things happen in this writer, and both have to happen in the writer
109
+ # rather than in the reader.
110
+ #
111
+ # Each line is prefixed with the epoch second it arrived: idevicesyslog's own
112
+ # timestamps carry no year and no timezone, which makes "the last N seconds"
113
+ # unanswerable across midnight.
114
+ #
115
+ # And the file is rotated at max lines. The device produced 21,000 lines in
116
+ # thirty seconds during testing, so an uncapped collector left running fills
117
+ # the disk. Rotation belongs here because a reader that truncates a file an
118
+ # open writer is appending to leaves the writer's offset past the new end,
119
+ # and every later line lands after a gap of NULs.
120
+ nohup sh -c "$source 2>/dev/null | perl -e '
121
+ \$| = 1;
122
+ my (\$f, \$max) = @ARGV;
123
+ my \$n = 0;
124
+ open(my \$fh, q{>>}, \$f) or die;
125
+ \$fh->autoflush(1);
126
+ while (my \$l = <STDIN>) {
127
+ print \$fh time() . qq{\t} . \$l;
128
+ if (++\$n >= \$max) { close \$fh; rename(\$f, \$f . q{.1}); open(\$fh, q{>}, \$f) or die; \$fh->autoflush(1); \$n = 0; }
129
+ }' '$LOGFILE' '$max'" \
130
+ >/dev/null 2>&1 &
131
+ printf '%s' "$!" > "$PIDFILE"
132
+ for _ in $(seq 1 20); do
133
+ collector_pid >/dev/null 2>&1 && break
134
+ sleep 0.2
135
+ done
136
+ }
137
+
138
+ case "$VERB" in
139
+ start)
140
+ if pid="$(collector_pid)"; then
141
+ printf 'OK logs — already collecting (pid %s, %s lines)\n' "$pid" "$(wc -l < "$LOGFILE" 2>/dev/null | tr -d ' ' || echo 0)"
142
+ exit 0
143
+ fi
144
+ start_collector
145
+ pid="$(collector_pid)" || die "could not start idevicesyslog — see $LOGFILE" 2
146
+ printf 'OK logs — collecting (pid %s) into %s\n' "$pid" "$LOGFILE"
147
+ exit 0
148
+ ;;
149
+
150
+ stop)
151
+ if ! pid="$(collector_pid)"; then
152
+ printf 'OK logs — not collecting\n'
153
+ exit 0
154
+ fi
155
+ # The collector is a shell wrapping a pipeline, so the children have to go
156
+ # too or idevicesyslog keeps the USB channel open.
157
+ pkill -P "$pid" 2>/dev/null || true
158
+ kill "$pid" 2>/dev/null || true
159
+ rm -f "$PIDFILE"
160
+ printf 'OK logs — stopped (%s lines kept in %s)\n' "$(wc -l < "$LOGFILE" 2>/dev/null | tr -d ' ' || echo 0)" "$LOGFILE"
161
+ exit 0
162
+ ;;
163
+
164
+ status)
165
+ if pid="$(collector_pid)"; then
166
+ printf 'OK logs — collecting (pid %s, %s lines) since %s\n' \
167
+ "$pid" "$(wc -l < "$LOGFILE" 2>/dev/null | tr -d ' ' || echo 0)" \
168
+ "$(head -1 "$LOGFILE" 2>/dev/null | cut -f1 | xargs -I{} date -r {} '+%H:%M:%S' 2>/dev/null || echo 'unknown')"
169
+ else
170
+ printf 'OK logs — not collecting\n'
171
+ fi
172
+ exit 0
173
+ ;;
174
+ esac
175
+
176
+ # --- read ---------------------------------------------------------------------
177
+
178
+ if ! collector_pid >/dev/null 2>&1; then
179
+ start_collector
180
+ collector_pid >/dev/null 2>&1 \
181
+ || die "log collection was not running and could not be started — see $LOGFILE" 2
182
+ die "log collection was not running; it is now — history begins here, so repeat the action and read again" 4
183
+ fi
184
+
185
+ [ -s "$LOGFILE" ] || [ -s "$LOGFILE.1" ] \
186
+ || die "nothing collected yet — the device has not logged since collection started" 4
187
+
188
+ CUTOFF=0
189
+ [ -n "$SINCE" ] && CUTOFF="$(( $(date +%s) - SINCE ))"
190
+ [ -n "$LINES" ] || LINES="$(cfg '.logs.max_lines' '50')"
191
+
192
+ # The rotated half first, so the result stays in chronological order.
193
+ ROTATED=""
194
+ [ -s "$LOGFILE.1" ] && ROTATED="$LOGFILE.1"
195
+ out="$(awk -F'\t' -v cutoff="$CUTOFF" '$1 >= cutoff { sub(/^[0-9]+\t/, ""); print }' $ROTATED "$LOGFILE")"
196
+
197
+ # THE APP'S OWN LINES BY DEFAULT.
198
+ #
199
+ # The whole device syslog is thirty-five thousand lines of other people's
200
+ # processes, and even the lines carrying this app's process name are mostly the
201
+ # OS logging on its behalf. --raw is the way back to all of it, and --process
202
+ # says which other process to look at instead.
203
+ UNFILTERED="$out"
204
+ if [ -n "$PROCESS" ]; then
205
+ out="$(printf '%s\n' "$out" | grep -F -- "$PROCESS" || true)"
206
+ elif [ "$RAW" -eq 0 ]; then
207
+ out="$(printf '%s\n' "$out" | log_filter)"
208
+ fi
209
+ if [ -n "$GREP" ]; then
210
+ out="$(printf '%s\n' "$out" | grep -F -i -- "$GREP" || true)"
211
+ fi
212
+
213
+ if [ "$NETWORK" -eq 1 ]; then
214
+ # THIS IS NOT A PROXY, AND IT CANNOT BE ONE HERE. A proxy needs its CA
215
+ # certificate installed on the device, and the app under test pins its
216
+ # certificates — it would see a handshake failure and nothing else. So this
217
+ # filters the app's own log output: what the app chose to print, and nothing
218
+ # more. No bodies, no headers the app did not log, and nothing at all from an
219
+ # app that logs nothing.
220
+ #
221
+ # THE PATTERN IS STRICTER THAN IT LOOKS, AND THE DEVICE IS WHY. A plain
222
+ # /GET|POST|.../ matched bluetoothd on the phone this was built against,
223
+ # because BD_VSC_OLYMPIC_GET_EXT_ADV_DROPPED contains GET. So a verb has to
224
+ # sit on a word boundary AND be followed by something that looks like a URL.
225
+ # "forgetting the cache" was never a GET either.
226
+ # The Network panel means the app's traffic. Without this the filter searches
227
+ # the whole device syslog, which on this phone is thirty-five thousand lines
228
+ # of other people's processes.
229
+ # Scoping to the app already happened above, unless --raw asked for
230
+ # everything, in which case the caller said what they wanted.
231
+ PAT="$(cfg '.logs.network_pattern' '(^|[^A-Za-z0-9_])(GET|POST|PUT|PATCH|DELETE)[[:space:]]+(https?://|/)|HTTP/[0-9]')"
232
+ out="$(printf '%s\n' "$out" | awk -v pat="$PAT" '$0 ~ pat' || true)"
233
+ if [ -z "$out" ]; then
234
+ die "no log lines matched /$PAT/ — this reads the app's own logging rather than a proxy, so an app that does not print its requests shows nothing here. Set logs.network_pattern in the config to match how yours logs them" 4
235
+ fi
236
+ fi
237
+
238
+ # A blank string still prints one empty line, which would read as a result.
239
+ out="$(printf '%s\n' "$out" | sed '/^[[:space:]]*$/d')"
240
+
241
+ if [ -z "$out" ]; then
242
+ filters=""
243
+ [ -n "$SINCE" ] && filters="$filters --since ${SINCE}s"
244
+ [ -n "$PROCESS" ] && filters="$filters --process $PROCESS"
245
+ [ -n "$GREP" ] && filters="$filters --grep $GREP"
246
+
247
+ # "Nothing" and "nothing of yours" are different answers, and the second one
248
+ # is the common one: an app that never calls os_log still has the OS logging
249
+ # on its behalf, and saying how many of those there were is the difference
250
+ # between "the log is broken" and "this app does not log".
251
+ extra=""
252
+ if [ "$RAW" -eq 0 ] && [ -z "$PROCESS" ]; then
253
+ app="$(cfg '.app.bundle_id' '')"; app="${app##*.}"
254
+ if [ -n "$app" ]; then
255
+ mine="$(printf '%s\n' "$UNFILTERED" \
256
+ | grep -icE "^[A-Za-z]{3} +[0-9]+ +[0-9:.]+ +${app}[([]" || true)"
257
+ [ "${mine:-0}" -gt 0 ] \
258
+ && extra=" — $mine line(s) carry this app's process name, all of them from system frameworks; --raw shows them"
259
+ fi
260
+ fi
261
+ die "no log lines from the app under test${filters:- }${extra} — $(wc -l < "$LOGFILE" | tr -d ' ') lines collected in total" 4
262
+ fi
263
+
264
+ n="$(printf '%s\n' "$out" | wc -l | tr -d ' ')"
265
+ if [ "$n" -le "$LINES" ]; then
266
+ printf '%s\n' "$out"
267
+ else
268
+ # The tail, not the head: when something has just gone wrong, the most recent
269
+ # lines are the ones that say why.
270
+ printf '%s\n' "$out" | tail -n "$LINES"
271
+ printf '… %s earlier lines dropped (--lines N)\n' "$(( n - LINES ))"
272
+ fi
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env bash
2
+ # permission.sh — grant or deny a privacy permission before the app asks.
3
+ #
4
+ # permission.sh <name>=<allow|deny> [<name>=<allow|deny> ...]
5
+ #
6
+ # permission.sh location=allow contacts=deny
7
+ #
8
+ # WHY THIS IS A STEP AND NOT AUTOMATIC
9
+ #
10
+ # Other tools dismiss permission dialogs for you. That hides whether the app
11
+ # asked at all — and for a production app, whether it asks, and for what, is itself
12
+ # under test. A prompt that quietly stops appearing is a real regression, and a
13
+ # tool that taps it away can never report one.
14
+ #
15
+ # TWO LIMITS THIS PLATFORM HAS AND ANDROID DOES NOT
16
+ #
17
+ # A physical iPhone has no interface for setting privacy permissions from the
18
+ # host. None: not in WebDriverAgent, not in Apple's own device tooling. So on a
19
+ # device this fails and says so, rather than reporting a success it did not
20
+ # achieve. Drive the dialog instead:
21
+ #
22
+ # - tap: { label: Cho phép, optional: true }
23
+ #
24
+ # And even on a simulator, Apple's list has no camera service. `camera=allow` is
25
+ # therefore refused rather than quietly skipped.
26
+ #
27
+ # exit 0 every permission is now in the requested state
28
+ # exit 1 usage error, or a name this platform does not have
29
+ # exit 2 a physical device, where this cannot be done at all
30
+ # exit 3 driver unreachable — run doctor.sh
31
+ # exit 4 the change was applied but did not take
32
+
33
+ source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
34
+
35
+ [ $# -ge 1 ] || die "missing permission (usage: permission.sh <name>=<allow|deny> [...])" 1
36
+ case "$1" in
37
+ -h|--help) awk 'NR > 1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"; exit 0 ;;
38
+ esac
39
+
40
+ load_config
41
+ BUNDLE="$(cfg '.app.bundle_id' '')"
42
+ [ -n "$BUNDLE" ] || die "app.bundle_id is not set in $DT_CONFIG — permissions are granted to a bundle" 2
43
+
44
+ [ "$(ios_kind)" = simulator ] \
45
+ || die "a physical iPhone has no host-side permission API — drive the dialog instead: tap: { label: <allow button>, optional: true }" 2
46
+
47
+ UDID="$(device_udid)"
48
+
49
+ # The neutral names, mapped onto Apple's service list. Names Android has and
50
+ # this does not are absent on purpose: refusing is the point, since a permission
51
+ # step that silently did nothing is worse than one that failed.
52
+ ios_service() {
53
+ case "$1" in
54
+ microphone) printf 'microphone' ;;
55
+ location) printf 'location' ;;
56
+ location-always) printf 'location-always' ;;
57
+ contacts) printf 'contacts' ;;
58
+ photos) printf 'photos' ;;
59
+ photos-add) printf 'photos-add' ;;
60
+ calendar) printf 'calendar' ;;
61
+ reminders) printf 'reminders' ;;
62
+ motion) printf 'motion' ;;
63
+ siri) printf 'siri' ;;
64
+ media) printf 'media-library' ;;
65
+ *) return 1 ;;
66
+ esac
67
+ }
68
+
69
+ DONE=""
70
+ for pair in "$@"; do
71
+ case "$pair" in
72
+ *=*) ;;
73
+ *) die "expected <name>=<allow|deny>, got '$pair'" 1 ;;
74
+ esac
75
+ name="${pair%%=*}"
76
+ want="${pair#*=}"
77
+ case "$want" in
78
+ allow|deny) ;;
79
+ *) die "'$name' must be allow or deny, got '$want'" 1 ;;
80
+ esac
81
+
82
+ service="$(ios_service "$name" || true)"
83
+ if [ -z "$service" ]; then
84
+ case "$name" in
85
+ camera) die "iOS has no settable camera permission — Apple's privacy service list omits it. Drive the dialog: tap: { label: <allow button>, optional: true }" 1 ;;
86
+ notifications|storage|phone|sms) die "'$name' is an Android permission with no iOS equivalent — remove it from this test, or guard the step with platform:" 1 ;;
87
+ *) die "no such permission name '$name' — known here: microphone location location-always contacts photos photos-add calendar reminders motion siri media" 1 ;;
88
+ esac
89
+ fi
90
+
91
+ action=grant
92
+ [ "$want" = deny ] && action=revoke
93
+
94
+ # simctl says nothing useful on success and prints one line on failure, so its
95
+ # output is what gets reported rather than a generic message.
96
+ out="$(xcrun simctl privacy "$UDID" "$action" "$service" "$BUNDLE" 2>&1 || true)"
97
+ case "$out" in
98
+ "") ;;
99
+ *) die "could not $want '$name' for $BUNDLE — $(first_line "$out")" 4 ;;
100
+ esac
101
+
102
+ DONE="$DONE $name=$want"
103
+ done
104
+
105
+ # Apple exposes no way to read a permission back, so unlike the Android adapter
106
+ # this cannot verify the result — only that the command reported no error. Said
107
+ # here rather than left for someone to assume the two platforms check equally.
108
+ printf 'OK permissions for %s —%s (applied, not read back: iOS exposes no query)\n' "$BUNDLE" "$DONE"
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env bash
2
+ # settings.sh — the device conditions a person would change by hand.
3
+ #
4
+ # settings.sh appearance light|dark
5
+ # settings.sh textsize <named size>
6
+ # settings.sh orientation portrait|landscape
7
+ #
8
+ # ON A SIMULATOR these go through simctl and are immediate.
9
+ #
10
+ # ON A PHYSICAL iPHONE THERE IS NO API FOR ANY OF THIS, AND THIS VERB REFUSES.
11
+ #
12
+ # iOS exposes no way to set appearance, text size or rotation on a real device
13
+ # from outside it. The only implementation available is the one a person would
14
+ # use — open Settings, navigate, tap the switch — and that has never been run
15
+ # here. Shipping an untested walk through another app's interface would be a
16
+ # claim this repository does not make: docs/RELEASE.md lists what has evidence
17
+ # beside it, and this would have none.
18
+ #
19
+ # So on a device it exits 2 and says to change it by hand. That is a worse
20
+ # answer than working, and a much better one than appearing to work.
21
+ #
22
+ # exit 0 set
23
+ # exit 1 usage error
24
+ # exit 2 not supported on this target
25
+ # exit 3 the simulator refused it
26
+
27
+ source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
28
+
29
+ case "${1-}" in
30
+ -h|--help) awk 'NR > 1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"; exit 0 ;;
31
+ esac
32
+ KEY="${1-}"; VAL="${2-}"
33
+ [ -n "$KEY" ] && [ -n "$VAL" ] \
34
+ || die "usage: settings.sh <appearance|textsize|orientation> <value>" 1
35
+ [ $# -eq 2 ] || die "too many arguments (usage: settings.sh <key> <value>)" 1
36
+
37
+ case "$KEY" in
38
+ appearance|textsize|orientation) ;;
39
+ *) die "unknown setting '$KEY' — appearance, textsize or orientation" 1 ;;
40
+ esac
41
+
42
+ load_config
43
+ UDID="$(device_udid)"
44
+
45
+ if [ "$(ios_kind)" != simulator ]; then
46
+ die "settings on a physical iPhone is not implemented — iOS exposes no API for it, and driving the Settings app by hand has never been run here. Change '$KEY' on the device, or point the config at a simulator" 2
47
+ fi
48
+
49
+ need_cmd xcrun
50
+
51
+ case "$KEY" in
52
+ appearance)
53
+ case "$VAL" in light|dark) ;; *) die "appearance is light or dark, got '$VAL'" 1 ;; esac
54
+ xcrun simctl ui "$UDID" appearance "$VAL" >/dev/null 2>&1 \
55
+ || die "simctl refused it — is the simulator booted?" 3
56
+ ;;
57
+ textsize)
58
+ # simctl takes a named content size, not a scale. The names are the ones
59
+ # Settings shows, and passing a number gets a message from simctl that does
60
+ # not say so, which is why the list is here.
61
+ case "$VAL" in
62
+ extra-small|small|medium|large|extra-large|extra-extra-large|extra-extra-extra-large) ;;
63
+ accessibility-medium|accessibility-large|accessibility-extra-large) ;;
64
+ accessibility-extra-extra-large|accessibility-extra-extra-extra-large) ;;
65
+ *) die "textsize takes a named size, not a number: extra-small, small, medium, large, extra-large, extra-extra-large, extra-extra-extra-large, or accessibility-medium through accessibility-extra-extra-extra-large — got '$VAL'" 1 ;;
66
+ esac
67
+ xcrun simctl ui "$UDID" content_size "$VAL" >/dev/null 2>&1 \
68
+ || die "simctl refused it — is the simulator booted?" 3
69
+ ;;
70
+ orientation)
71
+ case "$VAL" in portrait|landscape) ;; *) die "orientation is portrait or landscape, got '$VAL'" 1 ;; esac
72
+ die "simctl cannot rotate a simulator — rotate it from the Simulator's Device menu, or use an Android target" 2
73
+ ;;
74
+ esac
75
+
76
+ printf 'OK settings — %s is now %s\n' "$KEY" "$VAL"
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env bash
2
+ # setup.sh — build and sign WebDriverAgent for the configured target.
3
+ #
4
+ # setup.sh [--force]
5
+ #
6
+ # WHY THIS EXISTS AS A COMMAND
7
+ #
8
+ # The README asked a new user to clone WebDriverAgent, rewrite its bundle
9
+ # identifiers with sed, and get an xcodebuild signing invocation right. Every
10
+ # step of that is knowledge this repository already has — it is what CI does on
11
+ # every macOS run, and what the person who set this machine up did once by hand.
12
+ # A hundred lines of prose is a worse copy of a script.
13
+ #
14
+ # WHAT IT WILL NOT DO
15
+ #
16
+ # Sign in to Xcode, and register a device with Apple. Both are interactive
17
+ # credential operations, and a tool that appeared to do them would be lying
18
+ # about where the failure came from. When either is missing the build fails with
19
+ # a message Apple wrote for a different audience, so the log is read here and
20
+ # the actual cause is named.
21
+ #
22
+ # exit 0 WebDriverAgent is built for this target
23
+ # exit 1 usage error
24
+ # exit 2 configuration, checkout, or build problem
25
+
26
+ source "$(dirname "$0")/lib.sh"
27
+ load_config
28
+
29
+ FORCE=0
30
+ while [ $# -gt 0 ]; do
31
+ case "$1" in
32
+ --force) FORCE=1; shift ;;
33
+ -h|--help) awk 'NR > 1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"; exit 0 ;;
34
+ *) die "unknown argument: $1" 1 ;;
35
+ esac
36
+ done
37
+
38
+ need_cmd git
39
+ need_cmd xcodebuild
40
+
41
+ say() { printf '%s %s — %s\n' "$1" "$2" "$3"; }
42
+
43
+ PROJECT="$(expand_path "$(cfg '.wda.project')")"
44
+ WDA_DIR="$(dirname "$PROJECT")"
45
+ PREFIX="$(cfg '.wda.bundle_id_prefix')"
46
+ TEAM="$(cfg '.wda.team_id' '')"
47
+ SCHEME="$(cfg '.wda.scheme' 'WebDriverAgentRunner')"
48
+ KIND="$(ios_kind)"
49
+ UDID="$(device_udid)"
50
+ DERIVED="$(wda_derived)"
51
+
52
+ # --- 1. the checkout ----------------------------------------------------------
53
+
54
+ if [ -d "$PROJECT" ]; then
55
+ say OK checkout "WebDriverAgent already at $WDA_DIR"
56
+ else
57
+ mkdir -p "$(dirname "$WDA_DIR")" || die "could not create $(dirname "$WDA_DIR")" 2
58
+ # --depth 1: nobody here needs its history, and a shallow clone is a tenth of
59
+ # the download on a connection that may be someone's phone.
60
+ git clone --depth 1 https://github.com/appium/WebDriverAgent.git "$WDA_DIR" >/dev/null 2>&1 \
61
+ || die "could not clone WebDriverAgent into $WDA_DIR — check the path in wda.project and that the directory is writable" 2
62
+ say OK checkout "cloned into $WDA_DIR"
63
+ [ -d "$PROJECT" ] \
64
+ || die "cloned, but $PROJECT does not exist — wda.project should point at <checkout>/WebDriverAgent.xcodeproj" 2
65
+ fi
66
+
67
+ # --- 2. the bundle identifiers ------------------------------------------------
68
+ #
69
+ # Upstream ships com.facebook.* identifiers, which nobody outside Meta can sign.
70
+ # The .orig copy is kept because this edits a file the user owns and will one
71
+ # day want to `git pull` over.
72
+
73
+ PBX="$PROJECT/project.pbxproj"
74
+ [ -f "$PBX" ] || die "no project file at $PBX" 2
75
+
76
+ if grep -q 'com\.facebook\.WebDriverAgentRunner' "$PBX" 2>/dev/null || [ "$FORCE" -eq 1 ]; then
77
+ [ -f "$PBX.orig" ] || cp "$PBX" "$PBX.orig"
78
+ sed -i '' \
79
+ -e "s/com\.facebook\.WebDriverAgentRunner/$PREFIX.WebDriverAgentRunner/g" \
80
+ -e "s/com\.facebook\.WebDriverAgentLib/$PREFIX.WebDriverAgentLib/g" \
81
+ "$PBX" \
82
+ || die "could not rewrite bundle identifiers in $PBX" 2
83
+ say OK identifiers "rewritten to $PREFIX.* (original kept as project.pbxproj.orig)"
84
+ else
85
+ say OK identifiers "already $PREFIX.*"
86
+ fi
87
+
88
+ # --- 3. the build -------------------------------------------------------------
89
+ #
90
+ # A SIMULATOR BUILD IS NOT SIGNED, AND THAT IS THE WHOLE REASON iOS RUNS IN CI.
91
+ #
92
+ # CODE_SIGNING_ALLOWED=NO makes the simulator build need no account, no team and
93
+ # no device registration — which is what lets a hosted macOS runner do this.
94
+ # A device build needs all three, and the command line is where the team goes:
95
+ # the only DEVELOPMENT_TEAM entries in the project belong to a tvOS target, so
96
+ # editing the project file looks like it worked and changes nothing.
97
+
98
+ LOG="$(state_dir)/wda-build.log"
99
+ if [ "$KIND" = simulator ]; then
100
+ DEST="id=$UDID"
101
+ set -- build-for-testing -project "$PROJECT" -scheme "$SCHEME" \
102
+ -destination "$DEST" -derivedDataPath "$DERIVED" CODE_SIGNING_ALLOWED=NO
103
+ say OK target "simulator $UDID — no signing required"
104
+ else
105
+ [ -n "$TEAM" ] \
106
+ || die "wda.team_id is empty, and a device build must be signed — README.md explains where to find the real Team ID, which is not the value in parentheses in security find-identity" 2
107
+ DEST="id=$UDID"
108
+ set -- build-for-testing -project "$PROJECT" -scheme "$SCHEME" \
109
+ -destination "$DEST" -derivedDataPath "$DERIVED" \
110
+ -allowProvisioningUpdates DEVELOPMENT_TEAM="$TEAM" CODE_SIGN_STYLE=Automatic
111
+ say OK target "device $UDID — signing with team $TEAM"
112
+ fi
113
+
114
+ printf 'RUN xcodebuild build-for-testing (this takes a few minutes the first time)\n'
115
+ if xcodebuild "$@" >"$LOG" 2>&1; then
116
+ say OK build "WebDriverAgent built into $DERIVED"
117
+ else
118
+ # THE LOG KNOWS WHY. SAY IT.
119
+ #
120
+ # Every string below is one xcodebuild prints, and each has a different
121
+ # remedy. "See the log" is the answer a tool gives when it has not looked, and
122
+ # the log is four hundred lines of which one matters.
123
+ why="the build failed"
124
+ if grep -q 'Unable to find a destination matching' "$LOG" 2>/dev/null; then
125
+ # Found the first time this was run against a real iPhone: the phone was
126
+ # paired and listed by idevice_id, and Xcode still could not see it. That is
127
+ # what a locked screen, a pulled cable or an untrusted computer looks like
128
+ # from here, and the message Apple prints names none of them.
129
+ if [ "$KIND" = simulator ]; then
130
+ why="Xcode has no simulator with UDID $UDID — check device.udid against xcrun simctl list devices"
131
+ # NOT `xctrace … | grep -q`, AND NOT `printf … | grep -q` EITHER.
132
+ #
133
+ # grep -q exits the moment it matches, so the write end takes SIGPIPE.
134
+ # Under `set -o pipefail` — which lib.sh sets — the first spelling reported
135
+ # 141 even though the string was found, and the branch never fired: the
136
+ # message said "never seen" about a device Xcode was listing. Feeding it
137
+ # from printf instead fixed the exit code and left the shell writing into a
138
+ # closed pipe, which prints `printf: write error: Broken pipe` on stderr for
139
+ # every device that IS found. A case pattern has neither problem.
140
+ elif case "$(xcrun xctrace list devices 2>/dev/null)" in *"$UDID"*) true ;; *) false ;; esac; then
141
+ # Xcode knows the device and still will not build for it. Measured: the
142
+ # phone had been unplugged, and it lingers in the device list because it
143
+ # is remembered, not because it is reachable. Saying "no such device"
144
+ # there sends someone to check the UDID, which is correct.
145
+ why="Xcode knows device $UDID but cannot reach it — it is remembered from a previous session, not connected now: plug it in over USB and unlock the screen"
146
+ else
147
+ why="Xcode has never seen device $UDID — attach it over USB, unlock the screen, and trust this computer when it asks; note that idevice_id -l can list a device Xcode does not have"
148
+ fi
149
+ elif grep -q 'No Account for Team' "$LOG" 2>/dev/null; then
150
+ why="no Apple ID in Xcode is a member of team $TEAM — Xcode → Settings → Accounts → +, then run this again. This is the one step that cannot be scripted"
151
+ elif grep -q "isn't registered in your developer account" "$LOG" 2>/dev/null; then
152
+ why="the device is not registered in team $TEAM — register $UDID at developer.apple.com under Devices, in that team, then run this again. Check the UDID character by character: two iPhones of the same model differ only in the middle"
153
+ elif grep -q 'Provisioning profile .* doesn' "$LOG" 2>/dev/null; then
154
+ why="the provisioning profile does not include $UDID — register the device in team $TEAM at developer.apple.com, then run this again"
155
+ elif grep -q 'requires a development team' "$LOG" 2>/dev/null; then
156
+ why="the build wants a development team and wda.team_id is '$TEAM'"
157
+ elif grep -q 'xcode-select' "$LOG" 2>/dev/null; then
158
+ why="the command line tools are selected instead of Xcode — sudo xcode-select --switch /Applications/Xcode.app"
159
+ fi
160
+ die "$why — full log at $LOG" 2
161
+ fi
162
+
163
+ # --- 4. what came out ---------------------------------------------------------
164
+ #
165
+ # An .xctestrun is what doctor actually runs, so its absence is the failure
166
+ # worth catching here rather than three commands later.
167
+ XCTESTRUN="$(ls "$DERIVED"/Build/Products/*.xctestrun 2>/dev/null | head -1 || true)"
168
+ [ -n "$XCTESTRUN" ] \
169
+ || die "the build reported success but no .xctestrun is in $DERIVED/Build/Products — full log at $LOG" 2
170
+ say OK product "$(basename "$XCTESTRUN")"
171
+
172
+ if [ "$KIND" = device ]; then
173
+ printf 'NOTE the first launch on a device needs the certificate trusted by hand: Settings → General → VPN & Device Management\n'
174
+ fi
175
+ printf 'NEXT %s\n' "$(as_cmd doctor --recover)"