Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/mobile-demo-record/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ xcrun simctl ui $U appearance light
xcrun simctl status_bar $U override --time "9:41" --batteryState discharging \
--batteryLevel 100 --cellularBars 4 --wifiBars 3 --dataNetwork wifi
xcrun simctl spawn $U defaults write sh.superset.mobile EXDevMenuIsOnboardingFinished -bool YES
xcrun simctl openurl $U "superset://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081"
xcrun simctl openurl $U "superset://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081&disableAutoLaunch=1&disableFab=1"
```

- `simctl boot` does not launch Simulator.app. Keep it that way.
Expand Down
31 changes: 29 additions & 2 deletions .agents/skills/mobile-eas-dev/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,31 @@ apps/mobile/scripts/eas-dev.sh
It reuses this checkout's running session when there is one. Otherwise it installs the finished
simulator build whose native fingerprint matches this checkout and starts a session, about two minutes.
It builds only when no such build exists (the first time, or after a native change), about fifteen
minutes. Pass `--new` to stop the running session and start a fresh one. `apps/mobile/AGENTS.md`'s
"Verifying in the real app" section records why the script does each step.
minutes. `apps/mobile/AGENTS.md`'s "Verifying in the real app" section records why the script does
each step.

```bash
apps/mobile/scripts/eas-dev.sh --reload # load the app from Metro again; starts nothing
apps/mobile/scripts/eas-dev.sh --new # stop the running session and start a fresh one
```

- **Reload with `--reload`.** It opens the dev-client URL and waits for a new `iOS Bundled` line in the
Metro log, three tries. Never `agent-device open Superset`: that relaunches the app without the
Metro URL and it fails with "Could not connect to the server".
- **Switch sessions with `--new`,** never `eas simulator` by hand. The egress client is bound to the
session it started with, so the script stops the old client and starts one for the new session.
With no flag it keeps a connected client and restarts one that died.
- When the script started Metro, each run that (re)starts the tunnel ends only when Metro has served
the app a bundle; otherwise it exits non-zero and names the Metro and egress logs. With a Metro
started some other way it opens the app but cannot confirm the load.
- The dev-client URL carries `disableAutoLaunch=1&disableFab=1`, so no dev menu opens at launch and
no gear button covers the toolbar. Each session is a fresh install, so every load passes them.
- Logs and PID files are in `.tmp/eas-dev/` of the checkout, so two checkouts do not stop each
other's tunnel.
- The fingerprint lookup refuses to run when `bun --version` differs from `.bun-version`. The
dependencies that bun installs give a fingerprint no build has, and EAS would start a 15-minute
build that also fails. A cloud workspace made before the pin changed has the old bun: make a new
one.

The session is tagged with the workspace id. The desktop Mobile pane lists the workspace's tagged
sessions, so the person can watch the simulator there without a link. The script also prints an
Expand All @@ -40,6 +63,10 @@ agent-device help # the rest
- Through `eas simulator:exec npx agent-device` each command takes about 25 s. Install it globally
(`npm install -g agent-device`) when it is missing.
- The dev-menu sheet covers the first screen on a fresh install: press `Continue`, then `Close`.
- iOS's "slide to type" keyboard intro is already dismissed on a session the script started.
- SwiftUI menu items (`@expo/ui` `Menu`) are not in `agent-device snapshot`. Open the menu, take a
screenshot, and tap the item by coordinate: `agent-device press <x> <y>`. This is a limit of the
tool, not of the app.
- Source edits reach the app through fast refresh in about two seconds.

## Limits
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/.maestro/flows/host-states/never-connected.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ env:
HOST_NAME: "E2E Mac mini"
---
- stopApp
- openLink: "superset://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081"
- openLink: "superset://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081&disableAutoLaunch=1&disableFab=1"
- extendedWaitUntil:
visible: "One step left"
timeout: 60000
Expand Down
14 changes: 13 additions & 1 deletion apps/mobile/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,20 @@ export default function AuthenticatedLayout() {
has the dev values in its shell.
- **The simulator reaches this machine only through local egress,** and the egress client can only
start once the session exists. The app's first load therefore always fails; the script loads it
again when the tunnel is up. A port the app calls must be passed as `--egress-allow`; the script
again through the dev-client URL when the tunnel is up, and retries until the Metro log shows a
new `iOS Bundled` line. A port the app calls must be passed as `--egress-allow`; the script
reads `$API_PORT` from `.env` rather than assuming a fixed port.
- **An egress client serves only the session it started with.** It reads `.env.eas-simulator` once,
so after a new session a running client still tunnels to the old one and the app shows "Could
not connect to the server". `--new` replaces the client; never start a session by hand.
- **Reload with `eas-dev.sh --reload`, not `agent-device open Superset`.** A plain launch has no
Metro URL and fails; only
`superset://expo-development-client/?url=...&disableAutoLaunch=1&disableFab=1` loads the app
without the dev menu and its gear button.
- **The "slide to type" keyboard intro** covers the screen the first time a keyboard opens.
`simctl` is out of reach, so the script opens the keyboard once in Settings and presses Continue.
- **SwiftUI menu items (`@expo/ui` `Menu`) are not in `agent-device snapshot`.** Tap them by
coordinate from a screenshot. This is a limit of the tool.
- **Native Sentry starts only when `EXPO_PUBLIC_SENTRY_DSN_MOBILE` is set.** Native init without a
DSN crashes the app at launch with a bare SIGSEGV before any JS runs, and the EAS `development`
environment has no DSN.
Expand Down
214 changes: 181 additions & 33 deletions apps/mobile/scripts/eas-dev.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,33 @@
# Usage:
# apps/mobile/scripts/eas-dev.sh # reuse this checkout's running session, else start one
# apps/mobile/scripts/eas-dev.sh --new # stop the running session and start a fresh one
# apps/mobile/scripts/eas-dev.sh --reload # load the app from Metro again; starts nothing
# MOBILE_EAS_BUILD_ID=<id> apps/mobile/scripts/eas-dev.sh # install this build, skip the lookup
#
# Requires: EXPO_TOKEN (or `eas login`), this repo's .env already written
# (.superset/setup.sh or setup.cloud.sh), and local egress enabled on the Expo
# account.
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd -P)"
cd "$ROOT_DIR"

NEW=0
RELOAD=0
for arg in "$@"; do
case "$arg" in
--new) NEW=1 ;;
--reload) RELOAD=1 ;;
*)
echo "Unknown argument: $arg" >&2
exit 1
;;
esac
done
if [ "$NEW" = "1" ] && [ "$RELOAD" = "1" ]; then
echo "--new and --reload cannot be used together." >&2
exit 1
fi

if [ ! -f .env ]; then
echo "No .env here — run .superset/setup.sh or .superset/setup.cloud.sh first." >&2
Expand All @@ -45,12 +52,17 @@ API_PORT="${API_PORT:?API_PORT is missing from .env}"
BUILD_PROFILE=development-simulator
IDLE_MINUTES="${MOBILE_EAS_IDLE_MINUTES:-15}"
SESSION_FILE=apps/mobile/.env.eas-simulator
EGRESS_LOG=/tmp/superset-eas-egress.log
METRO_LOG=/tmp/superset-metro.log
STATE_DIR="$ROOT_DIR/.tmp/eas-dev"
EGRESS_LOG="$STATE_DIR/egress.log"
EGRESS_PID_FILE="$STATE_DIR/egress.pid"
METRO_LOG="$STATE_DIR/metro.log"
METRO_PID_FILE="$STATE_DIR/metro.pid"
LOAD_ATTEMPTS=3
BUNDLE_WAIT_SECONDS=90

cd apps/mobile

DEV_CLIENT_URL="superset://expo-development-client/?url=$(node -e 'console.log(encodeURIComponent(process.argv[1]))' "http://localhost:$METRO_PORT")"
DEV_CLIENT_URL="superset://expo-development-client/?url=$(node -e 'console.log(encodeURIComponent(process.argv[1]))' "http://localhost:$METRO_PORT")&disableAutoLaunch=1&disableFab=1"

# The runtime version is a fingerprint of the app config. EAS resolves it
# without this machine's dev values, so the build and its lookup must too.
Expand All @@ -76,25 +88,183 @@ session_running() {
[ "$(eas simulator:get --id "$id" --json --non-interactive 2>/dev/null | json 'j.status')" = "IN_PROGRESS" ]
}

if ! curl -s "http://127.0.0.1:$METRO_PORT/status" 2>/dev/null | grep -q "packager-status:running"; then
metro_running() {
curl -s "http://127.0.0.1:$METRO_PORT/status" 2>/dev/null | grep -q "packager-status:running"
}

agent_device() {
if command -v agent-device >/dev/null 2>&1; then
(
set -a
# shellcheck disable=SC1090
source "$ROOT_DIR/$SESSION_FILE"
set +a
agent-device "$@"
)
else
eas simulator:exec npx -y agent-device "$@"
fi
}

process_cwd() {
if [ -d /proc ]; then
readlink "/proc/$1/cwd" 2>/dev/null || true
else
lsof -a -d cwd -p "$1" -Fn 2>/dev/null | sed -n 's/^n//p'
fi
}

is_own_egress_client() {
[ "$(process_cwd "$1")" = "$PWD" ] && ps -o command= -p "$1" 2>/dev/null | grep -q "simulator:egress"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

egress_pids() {
local pid
for pid in $(pgrep -f "eas simulator:egress" || true); do
if is_own_egress_client "$pid"; then echo "$pid"; fi
done
}

egress_connected() {
grep -E "Egress tunnel (connected|disconnected)" "$EGRESS_LOG" 2>/dev/null | tail -n 1 | grep -q "Egress tunnel connected"
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

egress_healthy() {
local pid
pid="$(cat "$EGRESS_PID_FILE" 2>/dev/null || true)"
[ -n "$pid" ] && is_own_egress_client "$pid" &&
grep -qF "simulator session $1." "$EGRESS_LOG" 2>/dev/null &&
egress_connected
}

stop_egress() {
local pid
for pid in $(egress_pids); do
pkill -P "$pid" 2>/dev/null || true
kill "$pid" 2>/dev/null || true
done
rm -f "$EGRESS_PID_FILE"
}

# The egress client reads .env.eas-simulator once, at start, so it stays bound
# to the session that was current then.
start_egress() {
stop_egress
echo "==> Egress for session $1"
nohup eas simulator:egress >"$EGRESS_LOG" 2>&1 </dev/null &
echo $! >"$EGRESS_PID_FILE"
for _ in $(seq 1 60); do
egress_connected && return 0
sleep 1
done
echo "The egress tunnel did not connect in 60 s. See $EGRESS_LOG." >&2
exit 1
}

bundle_count() {
local count
count="$(grep -c "iOS Bundled" "$METRO_LOG" 2>/dev/null || true)"
echo "${count:-0}"
}

metro_started_here() {
local pid
pid="$(cat "$METRO_PID_FILE" 2>/dev/null || true)"
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null && [ -f "$METRO_LOG" ]
}

load_app() {
local attempt before
if ! metro_started_here; then
echo "==> Loading the app from Metro (not confirmed: Metro was not started by this script)"
agent_device open "$DEV_CLIENT_URL" >/dev/null 2>&1 || true
agent_device alert accept >/dev/null 2>&1 || true
return 0
fi
for attempt in $(seq 1 "$LOAD_ATTEMPTS"); do
echo "==> Loading the app from Metro (attempt $attempt of $LOAD_ATTEMPTS)"
before="$(bundle_count)"
agent_device open "$DEV_CLIENT_URL" >/dev/null 2>&1 || true
agent_device alert accept >/dev/null 2>&1 || true
for _ in $(seq 1 "$BUNDLE_WAIT_SECONDS"); do
if [ "$(bundle_count)" -gt "$before" ]; then
echo "==> Metro served the bundle"
return 0
fi
sleep 1
done
done
echo "Metro served no bundle after $LOAD_ATTEMPTS tries. See $METRO_LOG and $EGRESS_LOG." >&2
exit 1
}

# simctl is not reachable from here, so the intro cannot be turned off with
# `defaults write`. Showing the keyboard once in Settings and pressing Continue
# marks it as shown for the whole simulator.
dismiss_keyboard_intro() {
local output
agent_device open com.apple.Preferences --relaunch >/dev/null 2>&1 || return 0
output="$(agent_device press 'role="search"' --settle 2>&1 || true)"
if [[ "$output" == *UIContinuousPathIntroductionView* ]]; then
echo "==> Dismissing the slide-to-type keyboard intro"
agent_device press 'label="Continue"' --settle >/dev/null 2>&1 || true
else
echo "==> No slide-to-type keyboard intro found"
fi
agent_device close com.apple.Preferences >/dev/null 2>&1 || true
}

mkdir -p "$STATE_DIR"

if [ "$RELOAD" = "1" ]; then
metro_running || {
echo "Metro is not running. Run apps/mobile/scripts/eas-dev.sh without --reload." >&2
exit 1
}
session_running || {
echo "No running simulator session. Run apps/mobile/scripts/eas-dev.sh without --reload." >&2
exit 1
}
egress_healthy "$(session_id)" || {
echo "The egress client is not connected to session $(session_id). Run apps/mobile/scripts/eas-dev.sh without --reload." >&2
exit 1
}
load_app
exit 0
fi

if ! metro_running; then
echo "==> Starting Metro"
nohup bunx expo start --dev-client --port "$METRO_PORT" >"$METRO_LOG" 2>&1 </dev/null &
echo $! >"$METRO_PID_FILE"
for _ in $(seq 1 30); do
curl -s "http://127.0.0.1:$METRO_PORT/status" 2>/dev/null | grep -q "packager-status:running" && break
metro_running && break
sleep 2
done
metro_running || {
echo "Metro did not start in 60 s. See $METRO_LOG." >&2
exit 1
}
fi

if [ "$NEW" = "1" ] && session_running; then
echo "==> Stopping session $(session_id)"
eas simulator:stop --id "$(session_id)" --non-interactive >/dev/null
fi

STARTED=0
if session_running; then
echo "==> Reusing session $(session_id)"
else
STARTED=1
BUILD_ID="${MOBILE_EAS_BUILD_ID:-}"
if [ -z "$BUILD_ID" ]; then
BUN_PINNED="$(cat "$ROOT_DIR/.bun-version")"
if [ "$(bun --version)" != "$BUN_PINNED" ]; then
echo "bun $(bun --version) is installed, but .bun-version pins $BUN_PINNED. No build matches the fingerprint of dependencies it installed." >&2
echo "In a cloud workspace, create a new one from the current image. Or set MOBILE_EAS_BUILD_ID to install a known build." >&2
exit 1
fi
HASH="$(without_dev_env eas fingerprint:generate -p ios --build-profile "$BUILD_PROFILE" --json --non-interactive | json 'j.hash')"
BUILD_ID="$(eas build:list -p ios --build-profile "$BUILD_PROFILE" --status finished --fingerprint-hash "$HASH" --limit 1 --json --non-interactive | json 'j[0]?.id')"
if [ -z "$BUILD_ID" ]; then
Expand All @@ -120,33 +290,10 @@ else
--out-config-type dotenv --non-interactive --force
fi

agent_device() {
if command -v agent-device >/dev/null 2>&1; then
(
set -a
# shellcheck disable=SC1090
source "$ROOT_DIR/$SESSION_FILE"
set +a
agent-device "$@"
)
else
eas simulator:exec npx -y agent-device "$@"
fi
}

# The simulator reaches this machine only while the egress client runs, and
# the client needs the session, so the app's first load at session start
# always fails. Load it again once the tunnel is up.
if ! pgrep -f "eas simulator:egress" >/dev/null 2>&1; then
echo "==> Egress"
nohup eas simulator:egress >"$EGRESS_LOG" 2>&1 </dev/null &
for _ in $(seq 1 30); do
grep -q "Egress tunnel connected" "$EGRESS_LOG" 2>/dev/null && break
sleep 1
done
echo "==> Loading the app from Metro"
agent_device open "$DEV_CLIENT_URL" >/dev/null 2>&1 || true
agent_device alert accept >/dev/null 2>&1 || true
if [ "$STARTED" = "1" ] || ! egress_healthy "$(session_id)"; then
start_egress "$(session_id)"
[ "$STARTED" = "1" ] && dismiss_keyboard_intro
load_app
fi

SESSION_ID="$(session_id)"
Expand All @@ -159,6 +306,7 @@ Ready.
Preview: $PREVIEW_URL
Drive it: (cd apps/mobile && set -a && . ./.env.eas-simulator && set +a && agent-device snapshot -i)
needs "npm install -g agent-device"; through npx each command takes about 25 s
Reload it: apps/mobile/scripts/eas-dev.sh --reload
Stop it: (cd apps/mobile && eas simulator:stop --id $SESSION_ID)
Metro log: $METRO_LOG
Egress log: $EGRESS_LOG
Expand Down
Loading