-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathsimulator
More file actions
executable file
·475 lines (435 loc) · 19.6 KB
/
Copy pathsimulator
File metadata and controls
executable file
·475 lines (435 loc) · 19.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
#!/bin/bash
set -euo pipefail
# simulator — resolve the iOS Simulator *this checkout* owns, creating it on
# first use, boot it, and print its UDID.
#
# Two problems, one script:
#
# 1. Ambiguity. A machine with several runtimes installed usually has an
# "iPhone 17" on each of them, and `xcrun simctl` matches by *name* only —
# so a name-based command can shut down, erase, or boot a different device
# than the one under test, which surfaces as `Application failed preflight
# checks (Busy)` or `Mach error -308` (launch failures that look like test
# failures). Booting is also the slow half: letting a run race a cold
# CoreSimulator has stretched a ~10 minute test job past 3 hours.
#
# 2. Contention. Several checkouts on one machine — clones, worktrees, an
# agent working in each — otherwise resolve to the *same* device and race
# one another's boots, installs, and erases of the same bundle ID, which
# fails in exactly the ways above. So a device is a per-checkout resource:
# the name is derived from the checkout's path
# (`Stuff-<folder>-<hash>-<device>-<os>`), it's created on demand, and
# nothing else on the machine touches it.
#
# See "Selecting a simulator" in AGENTS.md.
#
# Only the UDID goes to stdout, so this composes:
#
# -destination "platform=iOS Simulator,id=$(./simulator)"
#
# Progress and warnings go to stderr.
# Registry-backed deletion requires an exact checkout, UDID, name, and runtime
# match. A derived name without a registry claim is discoverable but unowned.
DEVICE="iPhone 17"
OS="27.0"
BOOT=true
SHARED=false
DRY_RUN=false
MODE=resolve
usage() {
cat <<'USAGE'
Usage: ./simulator [options]
Resolves this checkout's own iOS Simulator by UDID (never by name), creating it
if it doesn't exist yet, boots it, waits for the boot to finish, and prints the
UDID on stdout.
Every checkout — clone or worktree — owns a separate device, so parallel work
on one machine can't collide booting, installing, or erasing the same one.
Options:
--device NAME Simulator device name (default: "iPhone 17")
--os VERSION Simulator iOS version (default: "27.0")
--no-boot Only resolve (creating if needed) and print the UDID
--shared Target any existing device with that name instead of this
checkout's own — for throwaway machines (CI) that are already
isolated
--list List the per-checkout devices on this machine
--prune Delete devices whose checkout is gone, and forget devices
that no longer exist. Devices no checkout claims — a renamed
or moved checkout leaves one — are reported, never deleted
--dry-run With --prune: report what it would do and change nothing
--delete Delete this checkout's device
--recreate Delete this checkout's device, then create and boot a fresh one
-h, --help Show this help
Renaming or moving a checkout changes the name it resolves to, so it gets a
fresh device and the old one turns up as unowned in --list.
Examples:
./simulator
./simulator --device 'iPhone 17 Pro' --os 27.0
./simulator --list
./simulator --prune --dry-run
xcodebuild test -destination "platform=iOS Simulator,id=$(./simulator)" …
USAGE
}
while [ $# -gt 0 ]; do
case "$1" in
--device) shift; DEVICE="${1:?--device requires a value}" ;;
--os) shift; OS="${1:?--os requires a value}" ;;
--no-boot) BOOT=false ;;
--shared) SHARED=true ;;
--list) MODE=list ;;
--prune) MODE=prune ;;
--dry-run) DRY_RUN=true ;;
--delete) MODE=delete ;;
--recreate) MODE=recreate ;;
-h|--help) usage; exit 0 ;;
*) echo "error: unknown option '$1' (see ./simulator --help)" >&2; exit 1 ;;
esac
shift
done
if [ "$SHARED" = true ] && [ "$MODE" != resolve ]; then
echo "error: --shared has no checkout of its own to $MODE (see ./simulator --help)" >&2
exit 1
fi
if [ "$DRY_RUN" = true ] && [ "$MODE" != prune ]; then
echo "error: --dry-run only applies to --prune (see ./simulator --help)" >&2
exit 1
fi
cd "$(dirname "$0")"
# `jq` is deliberately unpinned: macOS ships it as an Apple-signed system binary
# (`/usr/bin/jq`, identifier com.apple.jq) on the macOS 26 baseline this repo
# requires, same as xcrun/simctl/plutil. The queries below are stable jq syntax
# and are exercised against both the OS build and the newer one on CI — so don't
# add it to .mise.toml or gate it behind an install check.
runtime_key="com.apple.CoreSimulator.SimRuntime.iOS-${OS//./-}"
# A worktree's toplevel is the worktree itself, so worktrees count as separate
# checkouts — which is the point. Outside a repo (a copied tree, an archive),
# the script's own directory is the checkout.
checkout="$(git rev-parse --show-toplevel 2>/dev/null || pwd -P)"
slug() { printf '%s' "$1" | tr -cs 'A-Za-z0-9.' '-' | sed 's/^-//; s/-$//'; }
# The prefix marks a device as one of ours, so --list and --prune can find the
# managed devices no index entry points at any more (a renamed or moved
# checkout hashes differently and leaves its old device behind).
name_prefix="Stuff-"
checkout_hash="$(printf '%s' "$checkout" | shasum -a 256 | cut -c1-8)"
owned_name="$name_prefix$(slug "$(basename "$checkout")")-$checkout_hash-$(slug "$DEVICE")-$(slug "$OS")"
# The registry is the ownership authority; `simctl` remains the authority on
# whether that exact UDID exists on the recorded runtime. The derived name lets
# an unambiguous unclaimed device be recovered, but a name alone never permits
# deletion.
registry_dir="$HOME/Library/Application Support/Stuff/simulators"
# `available` hides devices whose runtime has been uninstalled — right for
# resolving something to run on, wrong for --list/--prune, which must still see
# (and be able to delete) a device stranded on a removed runtime.
#
# Listing also nudges CoreSimulator into rebuilding its device cache, which a
# freshly provisioned machine (notably the xcode-27 CI image) needs before a
# destination will resolve at all.
available_devices_json() { xcrun simctl list devices available --json; }
all_devices_json() { xcrun simctl list devices --json; }
udids_named() { # <devices-json> <name>
printf '%s' "$1" \
| jq -r --arg key "$runtime_key" --arg name "$2" \
'.devices[$key][]? | select(.name == $name) | .udid'
}
managed_devices() { # <devices-json> -> "<udid> <name>" per line, every runtime
printf '%s' "$1" \
| jq -r --arg prefix "$name_prefix" \
'.devices | to_entries[] | .value[]
| select(.name | startswith($prefix)) | "\(.udid) \(.name)"'
}
indexed_udids() { # every UDID the index knows, whatever checkout it belongs to
[ -d "$registry_dir" ] || return 0
local entry
for entry in "$registry_dir"/*; do
[ -f "$entry" ] || continue
registry_field "$entry" udid
done
}
registry_write() { # <name> <udid>
mise exec -- ruby Tools/simulator_registry.rb record \
--registry-dir "$registry_dir" --name "$1" \
--checkout "$checkout" --udid "$2" --device "$DEVICE" --os "$OS"
}
registry_forget() { # <name>
mise exec -- ruby Tools/simulator_registry.rb forget \
--registry-dir "$registry_dir" --name "$1"
}
registry_field() { # <file> <key>
awk -v key="$2" 'index($0, key "=") == 1 { print substr($0, length(key) + 2); exit }' "$1"
}
create_device() { # <name> -> udid on stdout
local type_id runtime_available udid
type_id="$(
xcrun simctl list devicetypes --json \
| jq -r --arg name "$DEVICE" '.devicetypes[] | select(.name == $name) | .identifier' \
| head -1
)"
if [ -z "$type_id" ]; then
echo "error: no '$DEVICE' device type is installed." >&2
echo "Available device types:" >&2
xcrun simctl list devicetypes >&2
exit 1
fi
runtime_available="$(
xcrun simctl list runtimes --json \
| jq -r --arg key "$runtime_key" \
'.runtimes[] | select(.identifier == $key and .isAvailable == true) | .identifier'
)"
if [ -z "$runtime_available" ]; then
echo "error: the iOS $OS runtime ($runtime_key) isn't installed or isn't usable." >&2
echo "Available runtimes:" >&2
xcrun simctl list runtimes >&2
exit 1
fi
echo "==> Creating $1 ($DEVICE / iOS $OS) for $checkout" >&2
udid="$(xcrun simctl create "$1" "$type_id" "$runtime_key" | tr -d '[:space:]')"
if [ -z "$udid" ]; then
echo "error: simctl create returned no UDID for '$1'." >&2
exit 1
fi
printf '%s' "$udid"
}
registry_resolution() { # exact ownership decision from all + available inventories
local devices_json available_json
devices_json="$(all_devices_json)"
available_json="$(available_devices_json)"
printf '{"all":%s,"available":%s}\n' "$devices_json" "$available_json" \
| mise exec -- ruby Tools/simulator_registry.rb resolve \
--registry-dir "$registry_dir" --name "$owned_name" \
--checkout "$checkout" --device "$DEVICE" --os "$OS" \
--runtime-key "$runtime_key"
}
delete_owned_device() { # exact registry-owned target only
local devices_json decision action udid
devices_json="$(all_devices_json)"
decision="$(printf '%s' "$devices_json" \
| mise exec -- ruby Tools/simulator_registry.rb delete-target \
--registry-dir "$registry_dir" --name "$owned_name" \
--checkout "$checkout" --device "$DEVICE" --os "$OS" \
--runtime-key "$runtime_key")"
action="${decision%%$'\t'*}"
udid=""
[ "$decision" = "$action" ] || udid="${decision#*$'\t'}"
case "$action" in
delete)
echo "==> Deleting $owned_name ($udid)" >&2
xcrun simctl delete "$udid" >&2
registry_forget "$owned_name"
;;
stale)
echo "warning: forgetting $owned_name — its registered device no longer exists" >&2
registry_forget "$owned_name"
;;
none)
echo "==> No device for this checkout to delete" >&2
;;
*)
echo "error: unknown registry deletion action '$action'" >&2
exit 1
;;
esac
}
# Two first runs in one checkout would otherwise each miss the lookup and create
# a device, leaving twins that share a name — the ambiguity this script exists
# to prevent. Serialize *resolve-or-create* per checkout, and only that: booting
# takes minutes on a device's first launch, and two callers waiting on one boot
# is fine, so the lock is released before it. The kernel releases this advisory
# lock after any process death; the persistent file itself carries no state.
lock_file="${TMPDIR:-/tmp}/stuff-simulator-$checkout_hash.lock"
lock_wait_limit=120
lock_held=false
acquire_lock() {
if [ "$lock_held" = true ]; then
return 0
fi
exec 9>>"$lock_file"
if ! /usr/bin/lockf -t "$lock_wait_limit" 9; then
exec 9>&-
echo "error: timed out waiting for the ./simulator run holding $lock_file." >&2
exit 1
fi
lock_held=true
trap release_lock EXIT
}
release_lock() {
if [ "$lock_held" = true ]; then
exec 9>&-
lock_held=false
fi
}
case "$MODE" in
list)
devices_json="$(all_devices_json)"
states="$(printf '%s' "$devices_json" | jq -r '.devices | to_entries[] | .value[] | "\(.udid) \(.state)"')"
known_indexed="$(indexed_udids)"
rows=""
if [ -d "$registry_dir" ]; then
for entry in "$registry_dir"/*; do
[ -f "$entry" ] || continue
entry_udid="$(registry_field "$entry" udid)"
entry_checkout="$(registry_field "$entry" checkout)"
entry_device="$(registry_field "$entry" device) / iOS $(registry_field "$entry" os)"
entry_state="$(printf '%s\n' "$states" | awk -v u="$entry_udid" '$1 == u { print $2 }')"
[ -n "$entry_state" ] || entry_state="gone"
[ -d "$entry_checkout" ] || entry_checkout="$entry_checkout (missing)"
rows+="$(printf '%-10s %-36s %-22s %s' \
"$entry_state" "$entry_udid" "$entry_device" "$entry_checkout")"$'\n'
done
fi
# Devices carrying our prefix that no index entry claims: a checkout that
# was renamed or moved (it now hashes to a different name), or an index
# that was cleared. Showing them is the only way they're ever noticed.
while read -r managed_udid managed_name; do
[ -n "$managed_udid" ] || continue
if printf '%s\n' "$known_indexed" | grep -Fqx "$managed_udid"; then
continue
fi
managed_state="$(printf '%s\n' "$states" | awk -v u="$managed_udid" '$1 == u { print $2 }')"
rows+="$(printf '%-10s %-36s %-22s %s' \
"$managed_state" "$managed_udid" "$managed_name" "unowned — no index entry")"$'\n'
done <<<"$(managed_devices "$devices_json")"
if [ -z "$rows" ]; then
echo "No per-checkout simulators on this machine yet." >&2
exit 0
fi
printf '%-10s %-36s %-22s %s\n' STATE UDID DEVICE CHECKOUT
printf '%s' "$rows"
exit 0
;;
prune)
devices_json="$(all_devices_json)"
known_udids="$(printf '%s' "$devices_json" | jq -r '.devices | to_entries[] | .value[] | .udid')"
known_indexed="$(indexed_udids)"
pruned=0
if [ "$DRY_RUN" = true ]; then
delete_verb="Would delete"
forget_verb="Would forget"
else
delete_verb="Deleting"
forget_verb="Forgetting"
fi
if [ -d "$registry_dir" ]; then
for entry in "$registry_dir"/*; do
[ -f "$entry" ] || continue
entry_udid="$(registry_field "$entry" udid)"
entry_checkout="$(registry_field "$entry" checkout)"
if [ -z "$entry_udid" ]; then
echo "==> $forget_verb $(basename "$entry") — the entry records no UDID" >&2
[ "$DRY_RUN" = true ] || rm -f "$entry"
pruned=$((pruned + 1))
elif ! printf '%s\n' "$known_udids" | grep -Fqx "$entry_udid"; then
echo "==> $forget_verb $(basename "$entry") — device $entry_udid no longer exists" >&2
[ "$DRY_RUN" = true ] || rm -f "$entry"
pruned=$((pruned + 1))
elif [ ! -d "$entry_checkout" ]; then
# A checkout on an unmounted volume looks exactly like a
# deleted one, and deleting a device throws away whatever is
# installed on it. Its parent still being there is what tells
# the two apart, so anything else is left for a human.
if [ ! -d "$(dirname "$entry_checkout")" ]; then
echo "==> Skipping $(basename "$entry") — $entry_checkout is missing, but so is its parent (unmounted volume?)" >&2
else
entry_name="$(basename "$entry")"
entry_os="$(registry_field "$entry" os)"
entry_runtime_key="com.apple.CoreSimulator.SimRuntime.iOS-${entry_os//./-}"
if ! prune_decision="$(printf '%s' "$devices_json" \
| mise exec -- ruby Tools/simulator_registry.rb prune-target \
--registry-dir "$registry_dir" --name "$entry_name" \
--runtime-key "$entry_runtime_key")"
then
echo "==> Skipping $entry_name — its ownership record does not match simctl" >&2
continue
fi
prune_action="${prune_decision%%$'\t'*}"
prune_udid="${prune_decision#*$'\t'}"
if [ "$prune_action" != delete ] || [ "$prune_udid" != "$entry_udid" ]; then
echo "==> Skipping $entry_name — exact deletion target could not be proven" >&2
continue
fi
echo "==> $delete_verb $entry_name ($entry_udid) — $entry_checkout is gone" >&2
if [ "$DRY_RUN" != true ]; then
xcrun simctl delete "$entry_udid" >&2
registry_forget "$entry_name"
fi
pruned=$((pruned + 1))
fi
fi
done
fi
# Unowned devices are reported, never deleted: an index that was cleared
# leaves a live checkout's device looking exactly like an abandoned one,
# and that checkout reclaims it on its next run.
unowned=0
while read -r managed_udid managed_name; do
[ -n "$managed_udid" ] || continue
if printf '%s\n' "$known_indexed" | grep -Fqx "$managed_udid"; then
continue
fi
echo "==> Unowned: $managed_name ($managed_udid) — no checkout claims it." >&2
echo " A checkout that resolves to that name reclaims it on its next run;" >&2
echo " otherwise remove it with: xcrun simctl delete $managed_udid" >&2
unowned=$((unowned + 1))
done <<<"$(managed_devices "$devices_json")"
if [ "$DRY_RUN" = true ] && [ "$pruned" -gt 0 ]; then
echo "Dry run — nothing was deleted." >&2
elif [ "$pruned" -eq 0 ] && [ "$unowned" -eq 0 ]; then
echo "Nothing to prune." >&2
elif [ "$pruned" -eq 0 ]; then
echo "Nothing to prune — the unowned devices above are left alone." >&2
fi
exit 0
;;
delete)
acquire_lock
delete_owned_device
exit 0
;;
recreate)
acquire_lock
delete_owned_device
;;
resolve) ;;
esac
if [ "$SHARED" = true ]; then
# Every checkout that asks for it lands on the same device, so this is only
# safe where the machine itself is the isolation (a CI job's VM).
name="$DEVICE"
udids="$(udids_named "$(available_devices_json)" "$name")"
if [ -z "$udids" ]; then
echo "error: no available '$name' on the iOS $OS runtime." >&2
echo "Available devices:" >&2
xcrun simctl list devices available >&2
exit 1
fi
else
name="$owned_name"
acquire_lock
resolution="$(registry_resolution)"
resolution_action="${resolution%%$'\t'*}"
resolution_udid=""
[ "$resolution" = "$resolution_action" ] \
|| resolution_udid="${resolution#*$'\t'}"
case "$resolution_action" in
owned|claim) udids="$resolution_udid" ;;
create) udids="$(create_device "$name")" ;;
*) echo "error: unknown registry resolution action '$resolution_action'" >&2; exit 1 ;;
esac
fi
udid="$(printf '%s\n' "$udids" | head -1)"
# Same name *and* same runtime is a genuinely ambiguous setup — routine for
# `--shared` (a machine with two hand-made "iPhone 17"s), and a sign someone
# duplicated a managed device otherwise. Pick one deterministically, but say so:
# silently choosing is how you end up debugging the wrong device.
if [ "$(printf '%s\n' "$udids" | wc -l | tr -d ' ')" -gt 1 ]; then
echo "warning: several '$name' devices on iOS $OS; using $udid" >&2
fi
if [ "$SHARED" != true ]; then
registry_write "$name" "$udid"
release_lock
fi
if [ "$BOOT" = true ]; then
echo "==> Booting $name ($DEVICE / iOS $OS, $udid)" >&2
# `-b` boots the device if needed, then waits for boot to finish — a
# condition to wait on rather than a fixed sleep.
xcrun simctl bootstatus "$udid" -b >&2
fi
printf '%s\n' "$udid"