diff --git a/watch/wearos/run.sh b/watch/wearos/run.sh index b2c3a99..dc747d6 100755 --- a/watch/wearos/run.sh +++ b/watch/wearos/run.sh @@ -2,20 +2,25 @@ # Build / install / run helper for the Wear OS app. # # Usage: -# ./run.sh [target] [args] +# ./run.sh [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 -# from .watch_serial and points the build at the dev -# laptop's LAN URL ($DOCTATE_DEV_SERVER). -# emulator Wear OS AVD on this host. Uses the first attached -# emulator-* serial; build keeps its emulator default -# (10.0.2.2:3000). -# (none) Backwards-compatible: no serial pinning, no URL override. -# ADB / Gradle pick the first attached device — fine when -# only one is connected. +# from .watch_serial when it's attached; otherwise menu. +# Points the build at the dev laptop's LAN URL +# ($DOCTATE_DEV_SERVER). +# emulator Wear OS AVD on this host. +# - 0 emulators running → menu of AVDs from `emulator -list-avds` +# - ≥1 running → menu of running serials + "Start a new AVD" +# The script never auto-picks a running emulator. To bypass the +# menu, pin the choice via ADB_SERIAL= or WEAR_AVD=. +# Build keeps its emulator default (10.0.2.2:3000). # -# Subcommands: +# Subcommands that need a target: # (none) install + start (default inner loop) # build ./gradlew :app:assembleDebug # install ./gradlew :app:installDebug @@ -23,29 +28,31 @@ # stop adb shell am force-stop ... # logcat adb logcat, filtered to the app's PID # 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] # adb connect to a Pixel Watch over WiFi and remember # the serial in .watch_serial. Without an argument: # 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 # help show this help # # Env vars: -# DOCTATE_TARGET Same as the [target] prefix above (watch|emulator). +# DOCTATE_TARGET Same as the prefix (watch|emulator). # 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 # LAN URL of the Axum dev server, used by 'watch' target. # Default: http://192.168.178.27:3000 # DOCTATE_API_KEY If set, passed as -Pdoctate.apiKey=... to Gradle and # baked into BuildConfig.API_KEY at compile time. # Otherwise the value from local.properties is used. -# WEAR_AVD AVD name to auto-boot when no device is attached -# (skips the interactive menu; useful for CI / scripted runs) -# SKIP_INSTRUMENTED set to 1 to limit `test` to JVM unit tests (no emulator needed) +# WEAR_AVD AVD name to auto-boot when no emulator is attached +# (skips the AVD menu; useful for CI / scripted runs). +# SKIP_INSTRUMENTED set to 1 to limit `test` to JVM unit tests (no device needed). set -euo pipefail @@ -109,30 +116,173 @@ read_cached_watch_serial() { fi } -first_emulator_serial() { - detect_adb - "$ADB" devices | awk 'NR>1 && $2=="device" && $1 ~ /^emulator-/ {print $1; exit}' +# Interactive picker. Writes the chosen item to the global PICKED so the +# caller doesn't need $(...) (which would swallow `die` inside a subshell). +# 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 -# device code is "eos"; the Pixel Watch 1 is "rohan". This is a fallback -# for first-time users who haven't run ./run.sh connect yet. -auto_detect_watch_serial() { +# Lists all running emulator serials (one per line). +list_emulator_serials() { 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= or WEAR_AVD=. +# - 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 — "; 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 ' for a fresh pairing." + fi + die "No watch attached. Run './run.sh connect ' 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() { case "$DOCTATE_TARGET" in watch) if [[ -z "$ADB_SERIAL" ]]; then - ADB_SERIAL="$(read_cached_watch_serial)" - if [[ -z "$ADB_SERIAL" ]]; then - ADB_SERIAL="$(auto_detect_watch_serial || true)" - fi - if [[ -z "$ADB_SERIAL" ]]; then - die "No watch serial known. Run './run.sh connect ' first." - fi + resolve_watch_serial + ADB_SERIAL="$RESOLVED_SERIAL" fi local server="${DOCTATE_DEV_SERVER:-$DEFAULT_DEV_SERVER}" GRADLE_PROPS+=("-Pdoctate.serverUrl=$server") @@ -140,15 +290,13 @@ apply_target() { ;; emulator) if [[ -z "$ADB_SERIAL" ]]; then - ADB_SERIAL="$(first_emulator_serial)" - if [[ -z "$ADB_SERIAL" ]]; then - die "No emulator attached. Start one first." - fi + resolve_emulator_serial + ADB_SERIAL="$RESOLVED_SERIAL" fi 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)" @@ -165,23 +313,13 @@ apply_target() { ensure_device() { detect_adb - if [[ -n "$ADB_SERIAL" ]]; then - # Pinned device must actually be attached. - if ! "$ADB" devices | awk 'NR>1 {print $1}' | grep -qx "$ADB_SERIAL"; then - die "Pinned device '$ADB_SERIAL' is not attached. Check './run.sh devices'." - fi - return + # apply_target must have resolved a serial before any device-bound command runs. + if [[ -z "$ADB_SERIAL" ]]; then + die "Internal error: ADB_SERIAL not set. This is a bug in run.sh dispatch." fi - local count - count=$("$ADB" devices | awk 'NR>1 && $2=="device"' | wc -l) - 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 + if ! "$ADB" devices | awk 'NR>1 && $2=="device" {print $1}' | grep -qx "$ADB_SERIAL"; then + die "Pinned device '$ADB_SERIAL' is no longer attached. Check './run.sh devices'." fi - boot_emulator_or_die } boot_emulator_or_die() { @@ -375,12 +513,25 @@ cmd_help() { } # --- 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 DOCTATE_TARGET="$1" shift 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 case "${1:-}" in @@ -391,10 +542,6 @@ case "${1:-}" in stop) cmd_stop ;; logcat) cmd_logcat ;; shot) shift; cmd_shot "$@" ;; - devices) cmd_devices ;; - connect) shift; cmd_connect "$@" ;; test) cmd_test ;; - clean) cmd_clean ;; - -h|--help|help) cmd_help ;; *) die "Unknown command: $1 (try './run.sh help')" ;; esac