Files
dev-updater/app/enroll-emulator.sh
T
iris b0e83059a3 dev-updater: build an app on the machine, install it on the phone
A Rust backend that discovers Android projects under configured roots,
builds one on request, and serves the APK over pinned TLS on a WireGuard
interface; an Android client that lists what is buildable, watches a build,
and installs the result. Enrolment carries the token and the CA, so the
phone trusts exactly the machine that issued it and nothing else.

`AGENTS.md` is the working guide and `README.md` the configuration
reference. The shared tunnel-and-TLS code lives in `vendor/wg-app-link`,
which ai-app uses too.

History before this point was squashed away, and a stale `config.json` went
with it: nothing had read that file since the config moved to RON outside
the checkout, and what it still held was one machine's absolute paths and
the names of projects on it.
2026-08-31 20:31:08 -04:00

182 lines
7.6 KiB
Bash
Executable File

#!/bin/sh
# Enrols the Android emulator against this machine's dev-updater, without a
# camera and without a QR code.
#
# ./app/enroll-emulator.sh [--host 10.0.2.2] [--port 8090] [--avd dev-updater]
#
# Enrolling normally means scanning the QR the server prints. There is no
# camera on a headless emulator, so this uses the other path the app already
# supports: the `devupdater://enroll` intent that MainActivity handles for
# camera apps that redirect a scanned URI. Firing it with `am start` is the
# whole trick.
#
# It is idempotent. A token for the emulator is generated once and kept in
# $XDG_DATA_HOME/dev-updater/emulator-token; later runs reuse it, so
# re-enrolling after reinstalling the app costs nothing and never touches
# the token belonging to a real phone.
#
# Two things that cost an afternoon each, written down so they don't again:
#
# 1. **`adb shell am start -d "...?a=1&b=2"` silently loses everything
# after the first `&`.** The URI is handed to the *device's* shell,
# which treats `&` as "run in background" no matter how carefully it was
# quoted on this side. The symptom is an intent that starts the app and
# enrols nothing, with no error anywhere. Escape them: `\&`.
#
# 2. **More than one emulator can be attached, and then bare `adb` fails.**
# Every `adb` call here names a device, because `adb get-state` and
# `adb shell` both exit 1 with "more than one device/emulator" the
# moment a second AVD is running -- and the old version read that as
# "no emulator is running", which is the opposite of what happened and
# sends you off to start a third. The device is chosen by AVD *name*
# rather than by taking the only one attached, so it cannot enrol
# somebody else's emulator by accident.
#
# 3. **The server has to be listening somewhere the emulator can reach.**
# Inside the emulator, 10.0.2.2 is this machine. dev-updater binds wg0
# and nothing else by default, which the emulator has no route to, so
# the app reports the server as unreachable. Start it with
# `--bind 0.0.0.0` for the duration of the test. The CA already covers
# 10.0.2.2 -- `local_addresses` in main.rs puts it there deliberately --
# so TLS is not the problem, and a TLS error means something else.
set -eu
HOST=10.0.2.2
PORT=8090
# Defaulted from the environment the same way run-android.sh reads it, so
# the two agree about which emulator "the emulator" means; the flag is here
# because this script already takes its other settings that way.
AVD_NAME="${AVD_NAME:-dev-updater}"
while [ $# -gt 0 ]; do
case "$1" in
--host) HOST=$2; shift 2 ;;
--port) PORT=$2; shift 2 ;;
--avd) AVD_NAME=$2; shift 2 ;;
*) echo "usage: $0 [--host H] [--port P] [--avd NAME]" >&2; exit 2 ;;
esac
done
PACKAGE=com.example.devupdater
CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/dev-updater/config.ron"
STATE_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/dev-updater"
TOKEN_FILE="$STATE_DIR/emulator-token"
command -v adb >/dev/null 2>&1 || {
echo "adb is not on PATH -- add \$HOME/Android/Sdk/platform-tools." >&2
exit 1
}
# Prints the adb serial of a running instance of AVD "$1", or nothing.
# Lifted from run-android.sh, which has always had to do this: `adb -e`
# works only when exactly one emulator is attached and cannot tell ours
# apart from somebody else's.
avd_serial() {
for s in $(adb devices | awk '$2 == "device" {print $1}'); do
if [ "$(adb -s "$s" emu avd name 2>/dev/null | head -n1 | tr -d '\r')" = "$1" ]; then
echo "$s"
return 0
fi
done
}
SERIAL=$(avd_serial "$AVD_NAME")
if [ -z "$SERIAL" ]; then
# Three different situations, and saying the wrong one costs an
# afternoon: nothing running, something running that isn't this AVD,
# or adb itself not answering. The list is what tells them apart, so
# it is printed rather than described.
attached=$(adb devices | awk '$2 == "device" {print $1}')
if [ -z "$attached" ]; then
echo "No emulator is running. Start one with app/run-android.sh first." >&2
else
echo "No emulator named '$AVD_NAME' is running. These are attached:" >&2
for s in $attached; do
echo " $s ($(adb -s "$s" emu avd name 2>/dev/null | head -n1 | tr -d '\r'))" >&2
done
echo "Pass --avd NAME to pick one, or start '$AVD_NAME' with app/run-android.sh." >&2
fi
exit 1
fi
echo "==> Using $SERIAL (AVD '$AVD_NAME')"
# Every adb call from here names the device: with a second emulator
# attached, a bare one exits 1 rather than picking.
adb() { command adb -s "$SERIAL" "$@"; }
# Generated once and kept, so this script can be run again after a
# reinstall without adding a second entry to the server's config every time.
if [ ! -f "$TOKEN_FILE" ]; then
mkdir -p "$STATE_DIR"
# Alphanumeric only: the token goes in a URI, and anything needing
# percent-encoding would have to survive two shells to get there.
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 32 > "$TOKEN_FILE"
chmod 600 "$TOKEN_FILE"
echo "==> Generated an emulator token in $TOKEN_FILE"
fi
TOKEN=$(cat "$TOKEN_FILE")
HASH=$(printf %s "$TOKEN" | sha256sum | cut -d' ' -f1)
# Only the hash is stored server-side, so this is what the config needs.
RESTART_NEEDED=no
if [ ! -f "$CONFIG" ]; then
echo "No config at $CONFIG -- start dev-updater once to create it." >&2
exit 1
elif grep -q "$HASH" "$CONFIG"; then
echo "==> The server already knows this token"
else
# Inserted rather than replacing the token list: a real phone's
# enrolment lives in the same list and must survive this.
tmp=$(mktemp)
awk -v hash="$HASH" '
{ print }
/^tokens: \[/ && !done {
print " ("
print " name: \"emulator\","
print " sha256: \"" hash "\","
print " ),"
done = 1
}
' "$CONFIG" > "$tmp"
cp "$tmp" "$CONFIG"
rm -f "$tmp"
chmod 600 "$CONFIG"
echo "==> Added an 'emulator' token to $CONFIG"
RESTART_NEEDED=yes
fi
if [ "$RESTART_NEEDED" = yes ]; then
# The server reads its config at startup, so a token added underneath a
# running one is not yet a token it will accept.
echo "==> Restart dev-updater now so it reads the new token, then re-run this."
echo " (the running server, if any, still has the old list in memory)"
exit 1
fi
# force-stop first: an already-running activity receives this through
# onNewIntent, and whether that path enrols is not something to depend on.
adb shell am force-stop "$PACKAGE" >/dev/null 2>&1 || true
before=$(adb shell run-as "$PACKAGE" cat shared_prefs/server.xml 2>/dev/null || echo none)
# The backslashes are load-bearing -- see the note at the top.
adb shell am start -a android.intent.action.VIEW \
-d "devupdater://enroll?host=$HOST\&port=$PORT\&token=$TOKEN" >/dev/null 2>&1
# The app seals the token under a Keystore key before writing it, so the
# stored blob differs even for the same token. Changed is the signal; equal
# means the intent never landed.
attempt=0
while [ "$attempt" -lt 15 ]; do
sleep 1
after=$(adb shell run-as "$PACKAGE" cat shared_prefs/server.xml 2>/dev/null || echo none)
if [ "$after" != "$before" ]; then
echo "==> Enrolled against $HOST:$PORT"
echo " If the app still says the server is unreachable, it is listening"
echo " on the wrong interface: restart it with --bind 0.0.0.0."
exit 0
fi
attempt=$((attempt + 1))
done
echo "The app's stored settings did not change, so the intent did not land." >&2
echo "Check that $PACKAGE is installed (app/run-android.sh) and try again." >&2
exit 1