chore(watch): require explicit target in run.sh and prompt on ambiguity

Drop the legacy "(none)" target fallback that quietly picked the first
attached device. Every device-bound subcommand now requires a `watch` or
`emulator` prefix (or DOCTATE_TARGET env var).

The emulator path never auto-picks a running emulator: even with exactly
one running, the menu shows so the user can boot a different AVD
instead. ADB_SERIAL / WEAR_AVD still bypass the menu for scripted runs.

Menu results now travel through a global RESOLVED_SERIAL/PICKED instead
of stdout+$(), so `die` inside the resolvers aborts the main shell
directly rather than getting swallowed by a command-substitution subshell.
This commit is contained in:
2026-04-24 12:12:30 +02:00
parent b02cc0c870
commit 46880e1b06
+206 -59
View File
@@ -2,20 +2,25 @@
# Build / install / run helper for the Wear OS app. # Build / install / run helper for the Wear OS app.
# #
# Usage: # Usage:
# ./run.sh [target] <subcommand> [args] # ./run.sh <target> <subcommand> [args]
# #
# Optional target prefix (selects which device + which build config): # A target prefix is REQUIRED for every device-bound subcommand. The script
# never silently picks "the first attached device" — if more than one candidate
# exists it asks, and if ADB_SERIAL/WEAR_AVD pins a choice it uses that.
#
# Target prefix (or DOCTATE_TARGET env var):
# watch Real Pixel Watch over ADB-WiFi. Uses the cached serial # watch Real Pixel Watch over ADB-WiFi. Uses the cached serial
# from .watch_serial and points the build at the dev # from .watch_serial when it's attached; otherwise menu.
# laptop's LAN URL ($DOCTATE_DEV_SERVER). # Points the build at the dev laptop's LAN URL
# emulator Wear OS AVD on this host. Uses the first attached # ($DOCTATE_DEV_SERVER).
# emulator-* serial; build keeps its emulator default # emulator Wear OS AVD on this host.
# (10.0.2.2:3000). # - 0 emulators running → menu of AVDs from `emulator -list-avds`
# (none) Backwards-compatible: no serial pinning, no URL override. # - ≥1 running → menu of running serials + "Start a new AVD"
# ADB / Gradle pick the first attached device — fine when # The script never auto-picks a running emulator. To bypass the
# only one is connected. # menu, pin the choice via ADB_SERIAL=<serial> or WEAR_AVD=<name>.
# Build keeps its emulator default (10.0.2.2:3000).
# #
# Subcommands: # Subcommands that need a target:
# (none) install + start (default inner loop) # (none) install + start (default inner loop)
# build ./gradlew :app:assembleDebug # build ./gradlew :app:assembleDebug
# install ./gradlew :app:installDebug # install ./gradlew :app:installDebug
@@ -23,29 +28,31 @@
# stop adb shell am force-stop ... # stop adb shell am force-stop ...
# logcat adb logcat, filtered to the app's PID # logcat adb logcat, filtered to the app's PID
# shot [path] screencap to PNG (default /tmp/wear_screen.png) # shot [path] screencap to PNG (default /tmp/wear_screen.png)
# devices list attached devices and which one this script would use # test run JVM unit tests, plus instrumented tests on the target
# (set SKIP_INSTRUMENTED=1 to skip the on-device tier)
#
# Target-free subcommands (run without a prefix):
# devices list attached devices
# connect [host:port] # connect [host:port]
# adb connect to a Pixel Watch over WiFi and remember # adb connect to a Pixel Watch over WiFi and remember
# the serial in .watch_serial. Without an argument: # the serial in .watch_serial. Without an argument:
# reuse the cached serial. # reuse the cached serial.
# test run JVM unit tests, plus instrumented tests on emulator
# (set SKIP_INSTRUMENTED=1 to skip the on-device tier)
# clean ./gradlew clean # clean ./gradlew clean
# help show this help # help show this help
# #
# Env vars: # Env vars:
# DOCTATE_TARGET Same as the [target] prefix above (watch|emulator). # DOCTATE_TARGET Same as the <target> prefix (watch|emulator).
# The CLI prefix takes precedence when both are set. # The CLI prefix takes precedence when both are set.
# ADB_SERIAL Pin a specific device serial. Bypasses target logic. # ADB_SERIAL Pin a specific device serial. Skips the menu entirely.
# DOCTATE_DEV_SERVER # DOCTATE_DEV_SERVER
# LAN URL of the Axum dev server, used by 'watch' target. # LAN URL of the Axum dev server, used by 'watch' target.
# Default: http://192.168.178.27:3000 # Default: http://192.168.178.27:3000
# DOCTATE_API_KEY If set, passed as -Pdoctate.apiKey=... to Gradle and # DOCTATE_API_KEY If set, passed as -Pdoctate.apiKey=... to Gradle and
# baked into BuildConfig.API_KEY at compile time. # baked into BuildConfig.API_KEY at compile time.
# Otherwise the value from local.properties is used. # Otherwise the value from local.properties is used.
# WEAR_AVD AVD name to auto-boot when no device is attached # WEAR_AVD AVD name to auto-boot when no emulator is attached
# (skips the interactive menu; useful for CI / scripted runs) # (skips the AVD menu; useful for CI / scripted runs).
# SKIP_INSTRUMENTED set to 1 to limit `test` to JVM unit tests (no emulator needed) # SKIP_INSTRUMENTED set to 1 to limit `test` to JVM unit tests (no device needed).
set -euo pipefail set -euo pipefail
@@ -109,30 +116,173 @@ read_cached_watch_serial() {
fi fi
} }
first_emulator_serial() { # Interactive picker. Writes the chosen item to the global PICKED so the
detect_adb # caller doesn't need $(...) (which would swallow `die` inside a subshell).
"$ADB" devices | awk 'NR>1 && $2=="device" && $1 ~ /^emulator-/ {print $1; exit}' # Aborts (via die) on bad input or when stdin is not a TTY.
# Usage: pick_from_list "Prompt" "label1" "label2" ...; use "$PICKED"
PICKED=""
pick_from_list() {
local prompt="$1"; shift
local -a items=("$@")
if [[ ! -t 0 ]]; then
die "$prompt need a TTY to prompt; pin via ADB_SERIAL / WEAR_AVD instead. Items: ${items[*]}"
fi
info "$prompt"
local i
for i in "${!items[@]}"; do
printf " %d) %s\n" "$((i+1))" "${items[$i]}"
done
printf " 0) abort\n"
local choice
read -r -p "Choose: " choice
if [[ ! "$choice" =~ ^[0-9]+$ ]]; then
die "Invalid choice: '$choice' (expected a number)."
fi
if [[ "$choice" -eq 0 ]]; then
die "Aborted by user."
fi
if [[ "$choice" -lt 1 || "$choice" -gt "${#items[@]}" ]]; then
die "Out of range: $choice (have ${#items[@]} items)."
fi
PICKED="${items[$((choice-1))]}"
} }
# Picks an attached watch by Wear OS device code. The Pixel Watch 2's # Lists all running emulator serials (one per line).
# device code is "eos"; the Pixel Watch 1 is "rohan". This is a fallback list_emulator_serials() {
# for first-time users who haven't run ./run.sh connect yet.
auto_detect_watch_serial() {
detect_adb detect_adb
"$ADB" devices -l | awk '$2=="device" && /device:(eos|rohan)/ {print $1; exit}' "$ADB" devices | awk 'NR>1 && $2=="device" && $1 ~ /^emulator-/ {print $1}'
}
# Lists attached Wear OS watch serials (one per line). Pixel Watch 2
# reports device code "eos", Pixel Watch 1 is "rohan".
list_watch_serials() {
detect_adb
"$ADB" devices -l | awk '$2=="device" && /device:(eos|rohan)/ {print $1}'
}
# Human-readable label for a serial, built from `adb devices -l` metadata.
# Falls back to the bare serial if no extra info is available.
serial_label() {
local serial="$1"
local line
line=$("$ADB" devices -l | awk -v s="$serial" '$1==s {$1=""; sub(/^ +/,""); print}')
if [[ -n "$line" ]]; then
printf '%s — %s\n' "$serial" "$line"
else
printf '%s\n' "$serial"
fi
}
# Output variable for the resolve_*_serial functions. Using a global
# instead of stdout+$() lets `die` inside these functions abort the main
# shell directly — $() would wrap everything in a subshell and swallow
# the exit, which is how the earlier version silently leaked past errors.
RESOLVED_SERIAL=""
# Resolves the emulator serial the user wants to target. The script never
# auto-picks a running emulator — even when only one is up, the user may
# want to boot a different AVD instead. Bypass the menu by setting
# ADB_SERIAL=<serial> or WEAR_AVD=<name>.
# - 0 emulators running → boot one (WEAR_AVD env or AVD menu); take its serial.
# - ≥1 emulators running → always prompt; "Start a new AVD" is the last option.
resolve_emulator_serial() {
detect_adb
local -a running
mapfile -t running < <(list_emulator_serials)
if [[ "${#running[@]}" -eq 0 ]]; then
boot_emulator_or_die
mapfile -t running < <(list_emulator_serials)
if [[ "${#running[@]}" -eq 0 ]]; then
die "Boot reported success but no emulator serial visible."
fi
RESOLVED_SERIAL="${running[0]}"
return
fi
local -a labels=()
local s
for s in "${running[@]}"; do
labels+=("$(serial_label "$s")")
done
local start_new_label="Start a new AVD"
labels+=("$start_new_label")
pick_from_list "Pick emulator:" "${labels[@]}"
local choice="$PICKED"
if [[ "$choice" == "$start_new_label" ]]; then
local -a before=("${running[@]}")
boot_emulator_or_die
local -a after
mapfile -t after < <(list_emulator_serials)
local new=""
local a p found
for a in "${after[@]}"; do
found=0
for p in "${before[@]}"; do
[[ "$p" == "$a" ]] && { found=1; break; }
done
if [[ "$found" -eq 0 ]]; then
new="$a"
break
fi
done
[[ -z "$new" ]] && die "Could not identify newly booted emulator serial."
RESOLVED_SERIAL="$new"
return
fi
# Chosen label has the form "emulator-5554 — <metadata>"; strip to serial.
RESOLVED_SERIAL="${choice%% *}"
}
# Resolves the watch serial to target. Prefers the cached pairing when
# it's actually attached, falls back to a menu on ambiguity.
resolve_watch_serial() {
detect_adb
local -a attached
mapfile -t attached < <(list_watch_serials)
local cached
cached="$(read_cached_watch_serial)"
if [[ -n "$cached" ]]; then
local s
for s in "${attached[@]}"; do
if [[ "$s" == "$cached" ]]; then
RESOLVED_SERIAL="$cached"
return
fi
done
fi
if [[ "${#attached[@]}" -eq 0 ]]; then
if [[ -n "$cached" ]]; then
die "Cached watch serial '$cached' is not attached. Try './run.sh connect $cached' or './run.sh connect <ip:port>' for a fresh pairing."
fi
die "No watch attached. Run './run.sh connect <ip:port>' first."
fi
if [[ "${#attached[@]}" -eq 1 ]]; then
RESOLVED_SERIAL="${attached[0]}"
return
fi
local -a labels=()
local s
for s in "${attached[@]}"; do
labels+=("$(serial_label "$s")")
done
pick_from_list "Multiple watches attached — pick one:" "${labels[@]}"
RESOLVED_SERIAL="${PICKED%% *}"
} }
apply_target() { apply_target() {
case "$DOCTATE_TARGET" in case "$DOCTATE_TARGET" in
watch) watch)
if [[ -z "$ADB_SERIAL" ]]; then if [[ -z "$ADB_SERIAL" ]]; then
ADB_SERIAL="$(read_cached_watch_serial)" resolve_watch_serial
if [[ -z "$ADB_SERIAL" ]]; then ADB_SERIAL="$RESOLVED_SERIAL"
ADB_SERIAL="$(auto_detect_watch_serial || true)"
fi
if [[ -z "$ADB_SERIAL" ]]; then
die "No watch serial known. Run './run.sh connect <ip:port>' first."
fi
fi fi
local server="${DOCTATE_DEV_SERVER:-$DEFAULT_DEV_SERVER}" local server="${DOCTATE_DEV_SERVER:-$DEFAULT_DEV_SERVER}"
GRADLE_PROPS+=("-Pdoctate.serverUrl=$server") GRADLE_PROPS+=("-Pdoctate.serverUrl=$server")
@@ -140,15 +290,13 @@ apply_target() {
;; ;;
emulator) emulator)
if [[ -z "$ADB_SERIAL" ]]; then if [[ -z "$ADB_SERIAL" ]]; then
ADB_SERIAL="$(first_emulator_serial)" resolve_emulator_serial
if [[ -z "$ADB_SERIAL" ]]; then ADB_SERIAL="$RESOLVED_SERIAL"
die "No emulator attached. Start one first."
fi
fi fi
info "Target: emulator (serial=$ADB_SERIAL, server defaults to 10.0.2.2:3000)" info "Target: emulator (serial=$ADB_SERIAL, server defaults to 10.0.2.2:3000)"
;; ;;
"") "")
: # no override; ADB / Gradle pick the first device die "No target selected. Use './run.sh watch …' or './run.sh emulator …' (or set DOCTATE_TARGET)."
;; ;;
*) *)
die "Unknown target: '$DOCTATE_TARGET' (expected: watch | emulator)" die "Unknown target: '$DOCTATE_TARGET' (expected: watch | emulator)"
@@ -165,23 +313,13 @@ apply_target() {
ensure_device() { ensure_device() {
detect_adb detect_adb
if [[ -n "$ADB_SERIAL" ]]; then # apply_target must have resolved a serial before any device-bound command runs.
# Pinned device must actually be attached. if [[ -z "$ADB_SERIAL" ]]; then
if ! "$ADB" devices | awk 'NR>1 {print $1}' | grep -qx "$ADB_SERIAL"; then die "Internal error: ADB_SERIAL not set. This is a bug in run.sh dispatch."
die "Pinned device '$ADB_SERIAL' is not attached. Check './run.sh devices'."
fi
return
fi fi
local count if ! "$ADB" devices | awk 'NR>1 && $2=="device" {print $1}' | grep -qx "$ADB_SERIAL"; then
count=$("$ADB" devices | awk 'NR>1 && $2=="device"' | wc -l) die "Pinned device '$ADB_SERIAL' is no longer attached. Check './run.sh devices'."
if [[ "$count" -gt 0 ]]; then
if [[ "$count" -gt 1 ]]; then
info "Multiple devices attached; ADB will pick the first."
info "Tip: use './run.sh watch ...' or './run.sh emulator ...' to disambiguate."
fi
return
fi fi
boot_emulator_or_die
} }
boot_emulator_or_die() { boot_emulator_or_die() {
@@ -375,12 +513,25 @@ cmd_help() {
} }
# --- Dispatch -------------------------------------------------------------- # --- Dispatch --------------------------------------------------------------
# Optional target prefix (watch | emulator) before the subcommand. # Required target prefix (watch | emulator) before the subcommand. The legacy
# "(none)" fallback is gone: every device-bound subcommand must know which
# device it's talking to, otherwise we'd quietly pick "the first" — which is
# exactly what the user asked to stop doing.
if [[ "${1:-}" == "watch" || "${1:-}" == "emulator" ]]; then if [[ "${1:-}" == "watch" || "${1:-}" == "emulator" ]]; then
DOCTATE_TARGET="$1" DOCTATE_TARGET="$1"
shift shift
fi fi
# Subcommands that are device- and target-agnostic bypass apply_target so
# they don't trigger a resolver menu as a side-effect. Ordering matters:
# this short-circuit must run before apply_target.
case "${1:-}" in
help|-h|--help) cmd_help; exit 0 ;;
devices) cmd_devices; exit 0 ;;
connect) shift; cmd_connect "$@"; exit 0 ;;
clean) cmd_clean; exit 0 ;;
esac
apply_target apply_target
case "${1:-}" in case "${1:-}" in
@@ -391,10 +542,6 @@ case "${1:-}" in
stop) cmd_stop ;; stop) cmd_stop ;;
logcat) cmd_logcat ;; logcat) cmd_logcat ;;
shot) shift; cmd_shot "$@" ;; shot) shift; cmd_shot "$@" ;;
devices) cmd_devices ;;
connect) shift; cmd_connect "$@" ;;
test) cmd_test ;; test) cmd_test ;;
clean) cmd_clean ;;
-h|--help|help) cmd_help ;;
*) die "Unknown command: $1 (try './run.sh help')" ;; *) die "Unknown command: $1 (try './run.sh help')" ;;
esac esac