From 50ff4c371eab927a9650c114975241999f4cd7b1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 15 Sep 2026 00:47:06 -0700 Subject: [PATCH] fix(mobile): ensure a compatible native client before verification (#11862) --- .agents/skills/ios-debugger-agent/SKILL.md | 2 + .agents/skills/test-t3-mobile/SKILL.md | 32 +- AGENTS.md | 2 + apps/mobile/README.md | 17 +- knip.jsonc | 4 +- scripts/mobile-native-client.ts | 430 +++++++++++++++++++++ 6 files changed, 469 insertions(+), 18 deletions(-) create mode 100644 scripts/mobile-native-client.ts diff --git a/.agents/skills/ios-debugger-agent/SKILL.md b/.agents/skills/ios-debugger-agent/SKILL.md index 7afa579383d9c6a41de42bdf52b333fc0cbf02dd..8d204ff201bc57f637e639a90dc265916c078b34 100644 --- a/.agents/skills/ios-debugger-agent/SKILL.md +++ b/.agents/skills/ios-debugger-agent/SKILL.md @@ -31,6 +31,8 @@ Avoid generic Mac window automation for switching among Simulator windows. Expli ## Choose build or launch +For T3 Code Mobile, run `node scripts/mobile-native-client.ts ensure ios ` from the checkout on the simulator host first. It checks the local Expo native fingerprint against the installed client and builds/installs when stale, missing, or unknown. Then launch with the intended Metro bundle. Authorized verification includes native builds and installs; do not stop because the existing client is old. Use `check` instead of `ensure` only when the user explicitly prohibits rebuilding or requests a read-only check. + - Use `build_run_sim` when native source, native dependencies, entitlements, or project configuration changed. - Use `test_sim` for the smallest relevant native test target or test cases; do not run an entire workspace test matrix routinely. - Use `launch_app_sim` when a compatible app is already installed and no native rebuild is needed. diff --git a/.agents/skills/test-t3-mobile/SKILL.md b/.agents/skills/test-t3-mobile/SKILL.md index 3fcf94334fd670adbbd2baae5f623a81d2cf30b4..afebcb7aa7d53b121bf86a3e90b96f45b770000d 100644 --- a/.agents/skills/test-t3-mobile/SKILL.md +++ b/.agents/skills/test-t3-mobile/SKILL.md @@ -15,24 +15,28 @@ Inspect the host and the affected code before launching processes: - On macOS with Xcode, prefer one representative iOS Simulator when the change is cross-platform so the user can watch through serve-sim. Load and follow [`ios-debugger-agent`](../ios-debugger-agent/SKILL.md), and load [`ios-simulator-browser`](../ios-simulator-browser/SKILL.md) when live streaming is available. - On macOS, Linux, or Windows with the Android SDK, use one Android Emulator when Android is the affected surface or iOS tooling is unavailable. -- When the change is platform-specific, test that platform. When neither platform is viable, report the missing SDK, emulator, or dev-client prerequisite rather than claiming verification. +- When the change is platform-specific, test that platform. When neither platform is viable, report the missing SDK or emulator prerequisite rather than claiming verification. A missing development client is a build step, not a blocker. Do not treat unavailable iOS tooling as a blocker when Android is a valid representative target. -## Choose the lightest valid launch path +## Ensure a compatible native client -- For JavaScript, TypeScript, or asset-only changes, reuse a compatible installed development client and start Metro. Do not rebuild native code merely to load a new bundle. -- For native source, native dependencies, entitlements, config plugins, or generated project changes, rebuild the affected platform. -- Use `vp run ios:dev` or `vp run android:dev` only when an Expo clean prebuild is actually required; both commands regenerate the native project. -- If the user requested no native rebuild and no compatible app is installed, reuse an existing compatible `.app` or `.apk` artifact when available. Otherwise report the missing dev client instead of silently rebuilding. +Authorized mobile verification includes building and installing a development client. A missing, stale, or unknown native client is not a reason to skip verification or leave a PR in draft. Build and install it, then continue. Respect an explicit user instruction not to rebuild; otherwise do not ask for separate permission. -The development identity on both platforms is: +Run this from the checkout being tested, on the machine that hosts the selected simulator or emulator. Select and boot one explicit iOS UDID or Android emulator serial first: -- App: `T3 Code Dev` -- Bundle/package identifier: `com.t3tools.t3code.dev` -- URL scheme: `t3code-dev` +```bash +node scripts/mobile-native-client.ts ensure ios +node scripts/mobile-native-client.ts ensure android +``` -Bundle or package presence proves the correct variant, not native compatibility. Reuse it only when the current changes did not alter its Expo SDK, native dependencies, config plugins, entitlements, generated project, or native source. +`ensure` compares the checkout's local Expo development fingerprint and the installed app's binary contents against the last successful build record. It reuses a matching client; otherwise it runs a clean prebuild, builds and installs the development app, and records the successful result. It does not start Metro. Start Metro below after it succeeds. On hosts with an `agent-job` requirement, run the entire `ensure` command through that queue. + +For a read-only decision, use `check` in place of `ensure`. Exit 0 means compatible, 2 means build required, and 1 means an operational error. An app installed outside this helper is initially unknown and gets rebuilt once. Records are local to the simulator host under `~/.cache/t3code/native-clients` and work across checkouts. Do not copy records between machines or write them manually. + +A JavaScript-only diff, bundle identifier, app version, or recent install date does not prove native compatibility. Always check the whole checkout. Expo fingerprints are computed locally with `APP_VARIANT=development`; no EAS credentials or cloud build are required. Generated `ios/` and `android/` directories are excluded by `.fingerprintignore`, so edit native source modules or config plugins rather than generated output. + +The development identity is `T3 Code Dev`, bundle/package `com.t3tools.t3code.dev`, scheme `t3code-dev`. If a build fails, investigate the build error and fix the local prerequisites. Report the concrete failure if it cannot be resolved, not “no compatible client.” ## Start one disposable T3 environment @@ -98,10 +102,9 @@ Use `ios-debugger-agent` to select one UDID and set these XcodeBuildMCP session - Simulator ID: the selected UDID - Bundle ID: `com.t3tools.t3code.dev` -Check the installed client with: +After `ensure` succeeds, open the Metro URL: ```bash -xcrun simctl get_app_container com.t3tools.t3code.dev app xcrun simctl openurl ``` @@ -109,10 +112,9 @@ Accept the iOS confirmation prompt and dismiss the developer menu when it obscur ### Android launch -Select one running emulator serial from `adb devices` and check the installed client: +Use the emulator serial already checked by `ensure`: ```bash -adb -s shell pm path com.t3tools.t3code.dev adb -s reverse tcp: tcp: adb -s shell am start -W \ -a android.intent.action.VIEW \ diff --git a/AGENTS.md b/AGENTS.md index ccf1fdc1d85c4c94d7d16e0507d7563efe2480db..38df1e94fa8c1d9cc7bc262f028f9115efe70702 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,6 +110,8 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - The server is event-sourced and its async flows emit typed receipts. Wait on receipts and worker drains, never on sleeps or polling. A test that needs a timeout to pass is wrong. - Upon request, user-visible frontend changes should get one integrated pass in a real client: `test-t3-app` for web, `test-t3-mobile` for mobile. The primary agent does this once after integrating. Subagents do not launch their own dev servers. Ask permission before doing computer use or spinning up browsers. +For authorized mobile verification, a missing or outdated native client is a build step, not a blocker. Run `node scripts/mobile-native-client.ts ensure ` on the simulator host before starting Metro. It checks the local Expo fingerprint and builds/installs when needed. See `test-t3-mobile` for the full workflow. + ## Pull requests - Never make a PR unless the developer explicitly asks you to do so. diff --git a/apps/mobile/README.md b/apps/mobile/README.md index a9a8177c4ecefac78cea6a3e4e931b90861b716e..20f98c6c9e34e732aac15529902b920c98f07c82 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -22,7 +22,22 @@ repository-root `.env` or `.env.local`, not an `apps/mobile/.env` file. See ## Development -Start Metro for the dev client: +For simulator/emulator development, select and boot a device, then ensure its native client matches +this checkout before starting Metro: + +```bash +node ../../scripts/mobile-native-client.ts ensure ios +# Or: node ../../scripts/mobile-native-client.ts ensure android +vp run dev:client +``` + +The helper compares a local Expo fingerprint and the installed binary with its last successful +build record. It builds and installs missing, stale, or unverified clients and reuses matching ones. +Use `check` instead of `ensure` for a read-only decision: exit 0 means compatible, 2 means a build is +needed, and 1 means an operational error. Run it on the simulator host; no EAS login is required. +An externally installed client is unverified until the helper builds it once. + +Start Metro for an already verified dev client: ```bash vp run dev:client diff --git a/knip.jsonc b/knip.jsonc index e53792d8a299beaf47736304a7223c353b3d2f69..3365862842b8d403834d8ebe1ecc65c94aa53e6f 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -13,8 +13,8 @@ "vitest": { "entry": [".github/**/*.test.cjs"] }, }, "scripts": { - // Knip loads its preprocessor through a CLI option, not a source import. - "entry": ["knip-schemas.ts"], + // Knip loads its preprocessor through a CLI option; native verification runs directly. + "entry": ["knip-schemas.ts", "mobile-native-client.ts"], }, "apps/server": { // Vite+ pack entries and the launcher used by installed background services. diff --git a/scripts/mobile-native-client.ts b/scripts/mobile-native-client.ts new file mode 100644 index 0000000000000000000000000000000000000000..c93a644bc4939acd4654a3a10f0c64c8f996e5b0 --- /dev/null +++ b/scripts/mobile-native-client.ts @@ -0,0 +1,430 @@ +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + HostProcessEnvironment, + HostProcessExecutablePath, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Console from "effect/Console"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { Argument, Command } from "effect/unstable/cli"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +export type NativePlatform = "ios" | "android"; +const NativeClientRecord = Schema.Struct({ fingerprint: Schema.String, binary: Schema.String }); +const encodeRecord = Schema.encodeEffect(Schema.fromJsonString(NativeClientRecord)); +const decodeRecord = Schema.decodeUnknownEffect(Schema.fromJsonString(NativeClientRecord)); +const encodeOutput = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const isNotLink = Schema.is(Schema.Struct({ code: Schema.Literal("EINVAL") })); +const decodeSimulators = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Struct({ + devices: Schema.Record( + Schema.String, + Schema.Array(Schema.Struct({ udid: Schema.String, state: Schema.String })), + ), + }), + ), +); +export type NativeClientRecord = typeof NativeClientRecord.Type; +export type NativeClientStatus = "compatible" | "missing" | "unknown" | "stale"; +export class NativeClientError extends Schema.TaggedError()( + "NativeClientError", + { message: Schema.String }, +) {} + +export function clientStatus( + fingerprint: string, + binary: string | null, + record: NativeClientRecord | null, +): NativeClientStatus { + if (binary === null) return "missing"; + if (!record || record.binary !== binary) return "unknown"; + return record.fingerprint === fingerprint ? "compatible" : "stale"; +} + +/** Keep native sources stable during ensure, as with a normal build; endpoint checks reject detected edits. */ +export const ensureClient = Effect.fn("ensureClient")(function* (operations: { + fingerprint: Effect.Effect; + installedBinary: Effect.Effect; + readRecord: Effect.Effect; + build: Effect.Effect; + saveRecord: (record: NativeClientRecord) => Effect.Effect; +}) { + const fingerprint = yield* operations.fingerprint; + const status = clientStatus( + fingerprint, + yield* operations.installedBinary, + yield* operations.readRecord, + ); + const verifyInputs = Effect.gen(function* () { + if ((yield* operations.fingerprint) !== fingerprint) { + return yield* new NativeClientError({ + message: + "Native inputs changed during verification. Run ensure again; this build was not recorded.", + }); + } + }); + yield* verifyInputs; + if (status === "compatible") { + return { status, rebuilt: false, fingerprint }; + } + yield* operations.build; + const binary = yield* operations.installedBinary; + if (binary === null) + return yield* new NativeClientError({ + message: "Build finished but the development client is not installed.", + }); + yield* verifyInputs; + yield* operations.saveRecord({ fingerprint, binary }); + return { status: "compatible" as const, rebuilt: true, fingerprint }; +}); + +const digest = Effect.fn("nativeClient.digest")(function* (value: string | Uint8Array) { + const crypto = yield* Crypto.Crypto; + const bytes = yield* crypto.digest( + "SHA-256", + typeof value === "string" ? new TextEncoder().encode(value) : value, + ); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +}); + +/** Hash fixed-size chunks to bound memory, including resources and symlink targets. */ +export const hashBundle = Effect.fn("hashBundle")(function* (root: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const entries: string[] = []; + const visit = ( + relative: string, + ): Effect.Effect => + Effect.gen(function* () { + const names = (yield* fs.readDirectory(path.join(root, relative))).sort((a, b) => + a.localeCompare(b, "en"), + ); + for (const name of names) { + const key = path.join(relative, name); + const absolute = path.join(root, key); + // FileSystem.stat follows links; readLink distinguishes them without following a cycle. + const link = yield* fs.readLink(absolute).pipe( + Effect.catchIf( + (error) => isNotLink(error.reason.cause), + () => Effect.succeed(null), + ), + ); + if (link !== null) { + entries.push(`link:${key}:${link}`); + continue; + } + const info = yield* fs.stat(absolute); + if (info.type === "Directory") { + entries.push(`directory:${key}`); + yield* visit(key); + } else { + const chunks = yield* fs.stream(absolute, { chunkSize: FileSystem.Size(65536) }).pipe( + Stream.mapEffect((chunk) => digest(chunk)), + Stream.runCollect, + ); + entries.push(`file:${key}:${yield* digest(chunks.join("\n"))}`); + } + } + }); + yield* visit(""); + return yield* digest(entries.map((entry) => `${entry.length}:${entry}`).join("")); +}); +type FileSystemError = import("effect/PlatformError").PlatformError; + +const bundleId = "com.t3tools.t3code.dev"; +const roots = Effect.gen(function* () { + const path = yield* Path.Path; + const repo = yield* path.fromFileUrl(new URL("../", import.meta.url)); + return { repo, mobile: path.join(repo, "apps/mobile") }; +}); +const collect = (stream: Stream.Stream) => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (a, b) => a + b, + ), + ); +export const resolveAdb = Effect.gen(function* () { + if (yield* isCommandAvailable("adb")) return "adb"; + const environment = yield* HostProcessEnvironment; + const path = yield* Path.Path; + const executable = (yield* HostProcessPlatform) === "win32" ? "adb.exe" : "adb"; + for (const sdk of [environment.ANDROID_SDK_ROOT, environment.ANDROID_HOME]) { + if (!sdk) continue; + const candidate = path.join(sdk, "platform-tools", executable); + if (yield* isCommandAvailable(candidate)) return candidate; + } + return yield* new NativeClientError({ + message: + "adb was not found on PATH or in ANDROID_SDK_ROOT/ANDROID_HOME. Install Android SDK platform-tools.", + }); +}); + +const command = Effect.fn("nativeClient.command")(function* ( + program: string, + args: string[], + inherit = false, + cwd?: string, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const environment = yield* HostProcessEnvironment; + const spawn = yield* resolveSpawnCommand(program === "adb" ? yield* resolveAdb : program, args); + const child = yield* spawner.spawn( + ChildProcess.make(spawn.command, spawn.args, { + shell: spawn.shell, + cwd: cwd ?? (yield* roots).mobile, + env: { + ...environment, + APP_VARIANT: "development", + MOBILE_VERSION_POLICY: "appVersion", + T3CODE_IOS_PERSONAL_TEAM: "0", + CI: "1", + EXPO_NO_GIT_STATUS: "1", + }, + stdin: "ignore", + stdout: inherit ? "inherit" : "pipe", + stderr: inherit ? "inherit" : "pipe", + }), + ); + const [stdout, stderr, code] = yield* Effect.all( + [collect(child.stdout), collect(child.stderr), child.exitCode], + { concurrency: "unbounded" }, + ); + if (code !== 0) + return yield* new NativeClientError({ + message: `${program} ${args[0]} failed (${code}): ${stderr || "see build output"}`, + }); + return stdout.trim(); +}, Effect.scoped); + +const fingerprint = Effect.fn("nativeClient.fingerprint")(function* (platform: NativePlatform) { + const output = yield* command(yield* HostProcessExecutablePath, [ + "--eval", + `require('expo/fingerprint').createFingerprintAsync(process.cwd(), { platforms: [process.argv[1]], silent: true }).then(fp => console.log('T3_NATIVE_FINGERPRINT=' + fp.hash)).catch(e => { console.error(e); process.exitCode = 1; });`, + platform, + ]); + const hash = output + .split("\n") + .find((line) => line.startsWith("T3_NATIVE_FINGERPRINT=")) + ?.split("=")[1]; + if (!hash || !/^[a-f0-9]{40,64}$/.test(hash)) + return yield* new NativeClientError({ message: "Expo did not return a native fingerprint." }); + return hash; +}); + +const validateDevice = Effect.fn("nativeClient.validateDevice")(function* ( + platform: NativePlatform, + device: string, +) { + if (platform === "ios") { + if ((yield* HostProcessPlatform) !== "darwin") + return yield* new NativeClientError({ + message: "Run iOS check/ensure on the Mac that hosts the simulator.", + }); + const listing = yield* decodeSimulators( + yield* command("xcrun", ["simctl", "list", "devices", "available", "--json"]), + ); + const simulator = Object.values(listing.devices) + .flat() + .find((entry) => entry.udid === device); + if (!simulator) + return yield* new NativeClientError({ + message: `No available iOS simulator with UDID ${device}.`, + }); + if (simulator.state !== "Booted") + return yield* new NativeClientError({ + message: `Boot the selected simulator first: xcrun simctl boot ${device}`, + }); + } else { + if ((yield* command("adb", ["-s", device, "get-state"])) !== "device") + return yield* new NativeClientError({ message: "Android device is not connected." }); + if ((yield* command("adb", ["-s", device, "shell", "getprop", "ro.kernel.qemu"])) !== "1") + return yield* new NativeClientError({ + message: "Select an Android emulator, not a physical device.", + }); + } +}); + +export const installedBinary = Effect.fn("installedBinary")(function* ( + platform: NativePlatform, + device: string, + run: typeof command = command, +) { + if (platform === "ios") { + const apps = yield* run("xcrun", ["simctl", "listapps", device]); + if (!apps.includes(`"${bundleId}"`)) return null; + return yield* hashBundle( + yield* run("xcrun", ["simctl", "get_app_container", device, bundleId, "app"]), + ); + } + const installed = yield* run("adb", ["-s", device, "shell", "pm", "list", "packages", bundleId]); + if (!installed.split("\n").some((line) => line.trim() === `package:${bundleId}`)) return null; + const packages = yield* run("adb", ["-s", device, "shell", "pm", "path", bundleId]); + const apks = packages + .split("\n") + .filter((line) => line.startsWith("package:")) + .map((line) => line.slice(8).trim()) + .sort(); + if (apks.length === 0) return null; + const hashes = yield* Effect.forEach(apks, (apk) => + Effect.gen(function* () { + if (!/^\/[\w/+=.~-]+\.apk$/.test(apk)) + return yield* new NativeClientError({ message: "Unexpected installed APK path." }); + const hash = (yield* run("adb", ["-s", device, "shell", "sha256sum", apk])).split(/\s/)[0]; + if (!hash || !/^[a-f0-9]{64}$/.test(hash)) + return yield* new NativeClientError({ message: "Could not hash installed APK." }); + return hash; + }), + ); + return yield* digest(hashes.sort().join("\n")); +}); + +const main = Command.make( + "mobile-native-client", + { + mode: Argument.choice("mode", ["check", "ensure"]), + platform: Argument.choice("platform", ["ios", "android"]), + device: Argument.string("device"), + }, + Effect.fn("nativeClient.main")(function* ({ mode, platform, device }) { + yield* validateDevice(platform, device); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const environment = yield* HostProcessEnvironment; + const home = environment.HOME ?? environment.USERPROFILE; + if (!home) + return yield* new NativeClientError({ + message: "HOME or USERPROFILE must be set to store native client records.", + }); + const recordPath = path.join( + home, + ".cache/t3code/native-clients", + platform, + `${yield* digest(device)}.json`, + ); + const operations = { + fingerprint: fingerprint(platform), + installedBinary: installedBinary(platform, device), + readRecord: fs.readFileString(recordPath).pipe( + Effect.flatMap(decodeRecord), + Effect.catchTag("SchemaError", () => Effect.succeed(null)), + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.succeed(null), + ), + ), + build: Effect.gen(function* () { + yield* Console.error( + "Native client is missing, stale, or unverified. Building and installing a development client...", + ); + const tracked = yield* command( + "git", + ["ls-files", `apps/mobile/${platform}`], + false, + (yield* roots).repo, + ); + if (tracked) + return yield* new NativeClientError({ + message: + "Native directory contains tracked files; clean prebuild would overwrite them.", + }); + yield* command( + "vp", + ["exec", "expo", "prebuild", "--clean", "--platform", platform, "--no-install"], + true, + ); + if (platform === "ios") { + const output = yield* fs.makeTempDirectoryScoped({ prefix: "t3-native-client-" }); + const { mobile } = yield* roots; + yield* command("pod", ["install"], true, path.join(mobile, "ios")); + // Target this simulator only, without Expo's desktop activation or log streaming. + yield* command( + "xcrun", + [ + "xcodebuild", + "-workspace", + path.join(mobile, "ios/T3CodeDev.xcworkspace"), + "-scheme", + "T3CodeDev", + "-configuration", + "Debug", + "-destination", + `id=${device}`, + "-derivedDataPath", + output, + "build", + ], + true, + ); + yield* command( + "xcrun", + [ + "simctl", + "install", + device, + path.join(output, "Build/Products/Debug-iphonesimulator/T3CodeDev.app"), + ], + true, + ); + } else { + yield* command( + "vp", + [ + "exec", + "expo", + "run:android", + "--device", + device, + "--no-bundler", + "--variant", + "debug", + ], + true, + ); + } + }).pipe(Effect.scoped), + saveRecord: Effect.fn(function* (record: NativeClientRecord) { + yield* fs.makeDirectory(path.dirname(recordPath), { recursive: true }); + yield* fs.writeFileString(recordPath, yield* encodeRecord(record)); + }), + }; + if (mode === "ensure") { + yield* Console.log(yield* encodeOutput(yield* ensureClient(operations))); + } else { + const current = yield* operations.fingerprint; + const status = clientStatus( + current, + yield* operations.installedBinary, + yield* operations.readRecord, + ); + yield* Console.log( + yield* encodeOutput({ + status, + fingerprint: current, + next: + status === "compatible" + ? "Start Metro with vp run dev:client" + : `node scripts/mobile-native-client.ts ensure ${platform} ${device}`, + }), + ); + process.exitCode = status === "compatible" ? 0 : 2; + } + }), +); + +if (import.meta.main) { + Command.run(main, { version: "0.0.0" }).pipe( + Effect.scoped, + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, + ); +} -- 2.54.0