From 2ffca8501b946f4c3c2bdde6edc5071fa331f32f Mon Sep 17 00:00:00 2001 From: tianma-if Date: Fri, 28 Aug 2026 00:38:37 +0800 Subject: [PATCH] fix: keep Android local API forwarding active --- apps/mobile/package.json | 4 +- package.json | 3 +- scripts/local-dev.test.mjs | 23 +++++++ scripts/mobile-dev.mjs | 129 +++++++++++++++++++++++++++++++++++++ 4 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 scripts/mobile-dev.mjs diff --git a/apps/mobile/package.json b/apps/mobile/package.json index ef841917..ffdf76d4 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -5,8 +5,8 @@ "type": "module", "main": "expo-router/entry", "scripts": { - "dev": "expo start", - "android": "expo run:android", + "dev": "bun ../../scripts/mobile-dev.mjs", + "android": "bun ../../scripts/mobile-dev.mjs run:android", "ios": "expo run:ios", "web": "expo start --web", "typecheck": "tsc -p tsconfig.json --noEmit" diff --git a/package.json b/package.json index 10ce6229..7a36593f 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "dev:api:remote": "bun scripts/run-wrangler.mjs dev --remote --port 8787", "dev:web": "vite --config apps/web/vite.config.ts --host 127.0.0.1 --port 5173", "dev:desktop": "concurrently -k -n web,desktop -c magenta,cyan \"bun run dev:web\" \"bun run --cwd apps/desktop dev\"", - "dev:mobile": "bun --cwd apps/mobile expo start", + "dev:mobile": "bun scripts/mobile-dev.mjs", + "dev:mobile:android": "bun scripts/mobile-dev.mjs run:android", "prepare:brand:icons": "bun scripts/trace-brand-icon.mjs && bun scripts/prepare-brand-icons.mjs", "prepare:mobile:icons": "bun scripts/prepare-mobile-icons.mjs", "prepare:desktop:icons": "bun scripts/prepare-desktop-icons.mjs", diff --git a/scripts/local-dev.test.mjs b/scripts/local-dev.test.mjs index 28b0f590..0db1a238 100644 --- a/scripts/local-dev.test.mjs +++ b/scripts/local-dev.test.mjs @@ -10,8 +10,31 @@ import { inspectLocalD1, parseEnvironmentFile, } from "./local-dev.mjs"; +import { + buildAdbReverseArguments, + parseAdbDevices, +} from "./mobile-dev.mjs"; describe("local development environment", () => { + test("targets every online Android device for local API reverse forwarding", () => { + const devices = parseAdbDevices([ + "List of devices attached", + "emulator-5554\tdevice product:sdk_gphone64_arm64 model:sdk_gphone64_arm64", + "emulator-5556\toffline", + "R58M123456\tdevice usb:1-1", + "", + ].join("\n")); + + expect(devices).toEqual(["emulator-5554", "R58M123456"]); + expect(buildAdbReverseArguments(devices[0], 8787)).toEqual([ + "-s", + "emulator-5554", + "reverse", + "tcp:8787", + "tcp:8787", + ]); + }); + test("keeps persistent development and resettable demo profiles isolated", () => { expect(getLocalDevelopmentProfile("local")).toMatchObject({ statePath: ".wrangler/state", diff --git a/scripts/mobile-dev.mjs b/scripts/mobile-dev.mjs new file mode 100644 index 00000000..e04da082 --- /dev/null +++ b/scripts/mobile-dev.mjs @@ -0,0 +1,129 @@ +import { existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn, spawnSync } from "node:child_process"; + +export const LOCAL_ANDROID_REVERSE_PORTS = Object.freeze([8787]); +export const ANDROID_DEVICE_POLL_INTERVAL_MS = 1_000; + +const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const MOBILE_DIR = join(PROJECT_ROOT, "apps/mobile"); + +export const parseAdbDevices = (source) => source + .split(/\r?\n/) + .slice(1) + .map((line) => line.trim().split(/\s+/)) + .filter(([serial, state]) => serial && state === "device") + .map(([serial]) => serial); + +export const resolveAdbExecutable = (environment = process.env) => { + for (const sdkRoot of [environment.ANDROID_HOME, environment.ANDROID_SDK_ROOT]) { + if (!sdkRoot) continue; + const candidate = join(sdkRoot, "platform-tools", process.platform === "win32" ? "adb.exe" : "adb"); + if (existsSync(candidate)) return candidate; + } + return "adb"; +}; + +export const buildAdbReverseArguments = (serial, port) => [ + "-s", + serial, + "reverse", + `tcp:${port}`, + `tcp:${port}`, +]; + +const startAndroidReverseMonitor = ({ + adbExecutable = resolveAdbExecutable(), + ports = LOCAL_ANDROID_REVERSE_PORTS, + pollIntervalMs = ANDROID_DEVICE_POLL_INTERVAL_MS, +} = {}) => { + const configuredDevices = new Set(); + let unavailableWarningPrinted = false; + let timer = null; + let stopped = false; + + const configureConnectedDevices = () => { + if (stopped) return; + const devicesResult = spawnSync(adbExecutable, ["devices"], { + encoding: "utf8", + timeout: 3_000, + }); + if (devicesResult.error || devicesResult.status !== 0) { + if (!unavailableWarningPrinted) { + const detail = devicesResult.error?.code === "ENOENT" + ? `cannot find ${adbExecutable}` + : (devicesResult.stderr?.trim() || devicesResult.error?.message || `exit ${devicesResult.status}`); + console.warn(`[mobile-dev] Android API forwarding is waiting: ${detail}.`); + unavailableWarningPrinted = true; + } + return; + } + + unavailableWarningPrinted = false; + const connectedDevices = new Set(parseAdbDevices(devicesResult.stdout)); + for (const serial of configuredDevices) { + if (!connectedDevices.has(serial)) configuredDevices.delete(serial); + } + + for (const serial of connectedDevices) { + if (configuredDevices.has(serial)) continue; + let configured = true; + for (const port of ports) { + const reverseResult = spawnSync(adbExecutable, buildAdbReverseArguments(serial, port), { + encoding: "utf8", + timeout: 3_000, + }); + if (reverseResult.error || reverseResult.status !== 0) { + configured = false; + const detail = reverseResult.stderr?.trim() || reverseResult.error?.message || `exit ${reverseResult.status}`; + console.warn(`[mobile-dev] Failed to forward ${serial} tcp:${port}: ${detail}`); + } + } + if (configured) { + configuredDevices.add(serial); + console.log(`[mobile-dev] ${serial}: ${ports.map((port) => `tcp:${port} -> host tcp:${port}`).join(", ")}`); + } + } + }; + + configureConnectedDevices(); + timer = setInterval(configureConnectedDevices, pollIntervalMs); + timer.unref(); + return () => { + stopped = true; + if (timer) clearInterval(timer); + }; +}; + +const runMobileDevelopment = () => { + const command = process.argv[2] ?? "start"; + if (!new Set(["start", "run:android"]).has(command)) { + console.error("Usage: bun scripts/mobile-dev.mjs [start|run:android]"); + process.exit(1); + } + + const stopAndroidReverseMonitor = startAndroidReverseMonitor(); + const expo = spawn(process.execPath, ["--cwd", MOBILE_DIR, "expo", command], { + cwd: PROJECT_ROOT, + env: process.env, + stdio: "inherit", + }); + + const forwardSignal = (signal) => { + if (!expo.killed) expo.kill(signal); + }; + process.once("SIGINT", forwardSignal); + process.once("SIGTERM", forwardSignal); + expo.once("error", (error) => { + stopAndroidReverseMonitor(); + console.error(`[mobile-dev] Cannot start Expo: ${error.message}`); + process.exitCode = 1; + }); + expo.once("exit", (code, signal) => { + stopAndroidReverseMonitor(); + process.exitCode = code ?? (signal ? 1 : 0); + }); +}; + +if (import.meta.main) runMobileDevelopment();