1import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
2import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as HostProcess from "@t3tools/shared/HostProcess";
5import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell";
6import * as Clock from "effect/Clock";
7import * as Console from "effect/Console";
8import * as Context from "effect/Context";
9import * as DateTime from "effect/DateTime";
10import * as Digest from "effect/crypto/Digest";
11import * as Effect from "effect/Effect";
12import * as FileSystem from "effect/FileSystem";
13import * as Option from "effect/Option";
14import * as Path from "effect/Path";
15import * as Random from "effect/Random";
16import * as Schema from "effect/Schema";
17import * as Stream from "effect/Stream";
18import { Argument, Command, Flag } from "effect/cli";
19import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
20import { ChildProcess, ChildProcessSpawner } from "effect/process";
21
22export type NativePlatform = "ios" | "android";
23const NativeClientRecord = Schema.Struct({ fingerprint: Schema.String, binary: Schema.String });
24const encodeRecord = Schema.encodeEffect(Schema.fromJsonString(NativeClientRecord));
25const decodeRecord = Schema.decodeUnknownEffect(Schema.fromJsonString(NativeClientRecord));
26const encodeOutput = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
27const isNotLink = Schema.is(Schema.Struct({ code: Schema.Literal("EINVAL") }));
28const decodeSimulators = Schema.decodeUnknownEffect(
29 Schema.fromJsonString(
30 Schema.Struct({
31 devices: Schema.Record(
32 Schema.String,
33 Schema.Array(
34 Schema.Struct({ udid: Schema.String, name: Schema.String, state: Schema.String }),
35 ),
36 ),
37 }),
38 ),
39);
40export type NativeClientRecord = typeof NativeClientRecord.Type;
41export type NativeClientStatus = "compatible" | "missing" | "unknown" | "stale";
42export class NativeClientError extends Schema.TaggedError<NativeClientError>()(
43 "NativeClientError",
44 { message: Schema.String },
45) {}
46
47export function clientStatus(
48 fingerprint: string,
49 binary: string | null,
50 record: NativeClientRecord | null,
51): NativeClientStatus {
52 if (binary === null) return "missing";
53 if (!record || record.binary !== binary) return "unknown";
54 return record.fingerprint === fingerprint ? "compatible" : "stale";
55}
56
57export type ArtifactSource = "local" | "remote" | "build";
58
59/** Keep native sources stable during ensure, as with a normal build; endpoint checks reject detected edits. */
60export const ensureClient = Effect.fn("ensureClient")(function* <E, R, E2, R2>(operations: {
61 fingerprint: Effect.Effect<string, E, R>;
62 installedBinary: Effect.Effect<string | null, E, R>;
63 readRecord: Effect.Effect<NativeClientRecord | null, E, R>;
64 install: (fingerprint: string) => Effect.Effect<ArtifactSource, E, R>;
65 saveRecord: (record: NativeClientRecord) => Effect.Effect<void, E2, R2>;
66}) {
67 const fingerprint = yield* operations.fingerprint;
68 const status = clientStatus(
69 fingerprint,
70 yield* operations.installedBinary,
71 yield* operations.readRecord,
72 );
73 if (status === "compatible") {
74 return { status, installed: false, fingerprint };
75 }
76 const source = yield* operations.install(fingerprint);
77 const binary = yield* operations.installedBinary;
78 if (binary === null)
79 return yield* new NativeClientError({
80 message: "Install finished but the development client is not on the device.",
81 });
82 yield* operations.saveRecord({ fingerprint, binary });
83 return { status: "compatible" as const, installed: true, source, fingerprint };
84});
85
86const digest = Effect.fn("nativeClient.digest")(function* (value: string | Uint8Array) {
87 const crypto = yield* Digest.Digest;
88 const bytes = yield* crypto.digest(
89 Digest.sha256,
90 typeof value === "string" ? new TextEncoder().encode(value) : value,
91 );
92 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
93});
94
95/** Hash fixed-size chunks to bound memory, including resources and symlink targets. */
96export const hashBundle = Effect.fn("hashBundle")(function* (root: string) {
97 const fs = yield* FileSystem.FileSystem;
98 const path = yield* Path.Path;
99 const entries: string[] = [];
100 const visit = Effect.fn("mobile-native-client.visit")(function* (
101 relative: string,
102 ): Effect.fn.Return<void, FileSystemError, FileSystem.FileSystem | Digest.Digest> {
103 const names = (yield* fs.readDirectory(path.join(root, relative))).sort((a, b) =>
104 a.localeCompare(b, "en"),
105 );
106 for (const name of names) {
107 const key = path.join(relative, name);
108 const absolute = path.join(root, key);
109 // FileSystem.stat follows links; readLink distinguishes them without following a cycle.
110 const link = yield* fs.readLink(absolute).pipe(
111 Effect.catchIf(
112 (error) => isNotLink(error.reason.cause),
113 () => Effect.succeed(null),
114 ),
115 );
116 if (link !== null) {
117 entries.push(`link:${key}:${link}`);
118 continue;
119 }
120 const info = yield* fs.stat(absolute);
121 if (info.type === "Directory") {
122 entries.push(`directory:${key}`);
123 yield* visit(key);
124 } else {
125 const chunks = yield* fs.stream(absolute, { chunkSize: 65536 }).pipe(
126 Stream.mapEffect((chunk) => digest(chunk)),
127 Stream.runCollect,
128 );
129 entries.push(`file:${key}:${yield* digest(chunks.join("\n"))}`);
130 }
131 }
132 });
133 yield* visit("");
134 return yield* digest(entries.map((entry) => `${entry.length}:${entry}`).join(""));
135});
136type FileSystemError = import("effect/PlatformError").PlatformError;
137
138const bundleId = "com.t3tools.t3code.dev";
139const roots = Effect.gen(function* () {
140 const path = yield* Path.Path;
141 const repo = yield* path.fromFileUrl(new URL("../", import.meta.url));
142 return { repo, mobile: path.join(repo, "apps/mobile") };
143});
144const collect = <E>(stream: Stream.Stream<Uint8Array, E>) =>
145 stream.pipe(
146 Stream.decodeText(),
147 Stream.runFold(
148 () => "",
149 (a, b) => a + b,
150 ),
151 );
152export const resolveAdb = Effect.gen(function* () {
153 if (yield* isCommandAvailable("adb")) return "adb";
154 const environment = yield* HostProcess.Environment;
155 const path = yield* Path.Path;
156 const executable = (yield* HostProcess.Platform) === "win32" ? "adb.exe" : "adb";
157 for (const sdk of [environment.ANDROID_SDK_ROOT, environment.ANDROID_HOME]) {
158 if (!sdk) continue;
159 const candidate = path.join(sdk, "platform-tools", executable);
160 if (yield* isCommandAvailable(candidate)) return candidate;
161 }
162 return yield* new NativeClientError({
163 message:
164 "adb was not found on PATH or in ANDROID_SDK_ROOT/ANDROID_HOME. Install Android SDK platform-tools.",
165 });
166});
167
168/** Build tool output goes to this file instead of the caller's terminal; null streams it. */
169const BuildLog = Context.Reference<string | null>("nativeClient/BuildLog", {
170 defaultValue: () => null,
171});
172
173const command = Effect.fn("nativeClient.command")(function* (
174 program: string,
175 args: string[],
176 inherit = false,
177 cwd?: string,
178) {
179 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
180 const environment = yield* HostProcess.Environment;
181 const log = inherit ? yield* BuildLog : null;
182 const spawn = yield* resolveSpawnCommand(program === "adb" ? yield* resolveAdb : program, args);
183 const child = yield* spawner.spawn(
184 ChildProcess.make(spawn.command, spawn.args, {
185 shell: spawn.shell,
186 cwd: cwd ?? (yield* roots).mobile,
187 env: {
188 ...environment,
189 APP_VARIANT: "development",
190 MOBILE_VERSION_POLICY: "appVersion",
191 T3CODE_IOS_PERSONAL_TEAM: "0",
192 CI: "1",
193 EXPO_NO_GIT_STATUS: "1",
194 // macOS tar otherwise adds AppleDouble entries that change the extracted bundle.
195 COPYFILE_DISABLE: "1",
196 },
197 stdin: "ignore",
198 stdout: inherit && !log ? "inherit" : "pipe",
199 stderr: inherit && !log ? "inherit" : "pipe",
200 }),
201 );
202 if (log) {
203 const fs = yield* FileSystem.FileSystem;
204 const append = <E>(stream: Stream.Stream<Uint8Array, E>) =>
205 stream.pipe(Stream.run(fs.sink(log, { flag: "a" })));
206 const [, , code] = yield* Effect.all(
207 [append(child.stdout), append(child.stderr), child.exitCode],
208 { concurrency: "unbounded" },
209 );
210 if (code === 0) return "";
211 const tail = (yield* fs.readFileString(log)).trimEnd().split("\n").slice(-40).join("\n");
212 return yield* new NativeClientError({
213 message: `${program} ${args[0]} failed (${code}). Last lines:\n${tail}\nFull log: ${log}`,
214 });
215 }
216 const [stdout, stderr, code] = yield* Effect.all(
217 [collect(child.stdout), collect(child.stderr), child.exitCode],
218 { concurrency: "unbounded" },
219 );
220 if (code !== 0)
221 return yield* new NativeClientError({
222 message: `${program} ${args[0]} failed (${code}): ${stderr || "see build output"}`,
223 });
224 return stdout.trim();
225}, Effect.scoped);
226
227const fingerprint = Effect.fn("nativeClient.fingerprint")(function* (platform: NativePlatform) {
228 const output = yield* command(yield* HostProcess.ExecutablePath, [
229 "--eval",
230 // Same hash on every machine: generated native projects and the config's `extra` (env-specific
231 // values a dev client reads from Metro) stay out, so a Mac and a Linux VM share builds.
232 `const f = require('expo/fingerprint'); f.createFingerprintAsync(process.cwd(), { platforms: [process.argv[1]], silent: true, ignorePaths: ['android/**', 'ios/**'], sourceSkips: f.DEFAULT_SOURCE_SKIPS | f.SourceSkips.ExpoConfigExtraSection }).then(fp => console.log('T3_NATIVE_FINGERPRINT=' + fp.hash)).catch(e => { console.error(e); process.exitCode = 1; });`,
233 platform,
234 ]);
235 const hash = output
236 .split("\n")
237 .find((line) => line.startsWith("T3_NATIVE_FINGERPRINT="))
238 ?.split("=")[1];
239 if (!hash || !/^[a-f0-9]{40,64}$/.test(hash))
240 return yield* new NativeClientError({ message: "Expo did not return a native fingerprint." });
241 return hash;
242});
243
244const runtimeName = (runtime: string) =>
245 runtime
246 .replace(/^com\.apple\.CoreSimulator\.SimRuntime\./, "")
247 .replace(/-(\d+)-(\d+)$/, " $1.$2");
248
249/** Validate the named device, or pick the only booted one; otherwise list exact commands to choose from. */
250const resolveDevice = Effect.fn("nativeClient.resolveDevice")(function* (
251 platform: NativePlatform,
252 requested: string | undefined,
253) {
254 const choose = (candidates: ReadonlyArray<{ id: string; label: string }>, problem: string) =>
255 new NativeClientError({
256 message: [
257 problem,
258 ...candidates.map(
259 ({ id, label }) => ` vp run mobile:install ${platform} ${id} # ${label}`,
260 ),
261 ].join("\n"),
262 });
263 if (platform === "ios") {
264 if ((yield* HostProcess.Platform) !== "darwin")
265 return yield* new NativeClientError({
266 message: "Run iOS installs on the Mac that hosts the simulator.",
267 });
268 // The shared iOS client is an arm64 simulator build; Intel simulators cannot run it.
269 if ((yield* HostProcess.Architecture) !== "arm64")
270 return yield* new NativeClientError({
271 message: "mobile:install needs an Apple silicon Mac. Use vp run ios:dev on Intel Macs.",
272 });
273 const listing = yield* decodeSimulators(
274 yield* command("xcrun", ["simctl", "list", "devices", "available", "--json"]),
275 );
276 const simulators = Object.entries(listing.devices).flatMap(([runtime, devices]) =>
277 devices.map((device) => ({ ...device, label: `${device.name} (${runtimeName(runtime)})` })),
278 );
279 const booted = simulators
280 .filter((simulator) => simulator.state === "Booted")
281 .map(({ udid, label }) => ({ id: udid, label }));
282 if (requested === undefined) {
283 if (booted.length === 1) return booted[0]!;
284 return yield* booted.length === 0
285 ? new NativeClientError({
286 message:
287 "No iOS simulator is booted. Open one with device_open, or boot one: xcrun simctl boot <udid>",
288 })
289 : choose(booted, "Several iOS simulators are booted. Pick one:");
290 }
291 const simulator = simulators.find((entry) => entry.udid === requested);
292 if (!simulator)
293 return yield* choose(booted, `No available iOS simulator with UDID ${requested}. Booted:`);
294 if (simulator.state !== "Booted")
295 return yield* new NativeClientError({
296 message: `Boot the selected simulator first: xcrun simctl boot ${requested}`,
297 });
298 return { id: simulator.udid, label: simulator.label };
299 }
300 const attached = (yield* command("adb", ["devices", "-l"]))
301 .split("\n")
302 .slice(1)
303 .map((line) => line.trim().split(/\s+/))
304 .filter((parts) => parts.length >= 2)
305 .map(([id, state, ...details]) => ({
306 id: id!,
307 state: state!,
308 label: details.find((detail) => detail.startsWith("model:"))?.slice(6) ?? id!,
309 }));
310 const ready = attached.filter((device) => device.state === "device");
311 const device =
312 requested === undefined ? ready : attached.filter((entry) => entry.id === requested);
313 if (requested === undefined && ready.length !== 1)
314 return yield* ready.length === 0
315 ? new NativeClientError({
316 message:
317 "No Android emulator or phone is connected. Open one with device_open, or start an emulator.",
318 })
319 : choose(ready, "Several Android devices are connected. Pick one:");
320 const selected = device[0];
321 if (!selected)
322 return yield* choose(ready, `Android device ${requested} is not connected. Ready:`);
323 if (selected.state === "unauthorized")
324 return yield* new NativeClientError({
325 message: `Unlock ${selected.label} and accept the USB debugging prompt, then run this again.`,
326 });
327 if (selected.state !== "device")
328 return yield* new NativeClientError({
329 message: `Android device ${selected.id} is ${selected.state}, not ready.`,
330 });
331 // Emulators report a generic model; their AVD name is what users recognize.
332 const avd = selected.id.startsWith("emulator-")
333 ? yield* command("adb", ["-s", selected.id, "emu", "avd", "name"]).pipe(
334 Effect.map((output) => output.split("\n")[0]?.trim()),
335 Effect.orElseSucceed(() => undefined),
336 )
337 : undefined;
338 return { id: selected.id, label: avd || selected.label };
339});
340
341export const installedBinary = Effect.fn("installedBinary")(function* (
342 platform: NativePlatform,
343 device: string,
344 run: typeof command = command,
345) {
346 if (platform === "ios") {
347 const apps = yield* run("xcrun", ["simctl", "listapps", device]);
348 if (!apps.includes(`"${bundleId}"`)) return null;
349 return yield* hashBundle(
350 yield* run("xcrun", ["simctl", "get_app_container", device, bundleId, "app"]),
351 );
352 }
353 const installed = yield* run("adb", ["-s", device, "shell", "pm", "list", "packages", bundleId]);
354 if (!installed.split("\n").some((line) => line.trim() === `package:${bundleId}`)) return null;
355 const packages = yield* run("adb", ["-s", device, "shell", "pm", "path", bundleId]);
356 const apks = packages
357 .split("\n")
358 .filter((line) => line.startsWith("package:"))
359 .map((line) => line.slice(8).trim())
360 .sort();
361 if (apks.length === 0) return null;
362 const hashes = yield* Effect.forEach(apks, (apk) =>
363 Effect.gen(function* () {
364 if (!/^\/[\w/+=.~-]+\.apk$/.test(apk))
365 return yield* new NativeClientError({ message: "Unexpected installed APK path." });
366 const hash = (yield* run("adb", ["-s", device, "shell", "sha256sum", apk])).split(/\s/)[0];
367 if (!hash || !/^[a-f0-9]{64}$/.test(hash))
368 return yield* new NativeClientError({ message: "Could not hash installed APK." });
369 return hash;
370 }),
371 );
372 return yield* digest(hashes.sort().join("\n"));
373});
374
375/** Preserve input-invalidated Gradle/.cxx caches; exclude app/build because its tasks omit inputs. Ignore cache failures. */
376export const prebuildAndroid = Effect.fn("nativeClient.prebuildAndroid")(function* <E, R>(
377 mobile: string,
378 prebuild: Effect.Effect<void, E, R>,
379) {
380 const fs = yield* FileSystem.FileSystem;
381 const path = yield* Path.Path;
382 const android = path.join(mobile, "android");
383 // Sibling renames stay on one filesystem; interrupted runs leave this gitignored cache for next-run cleanup.
384 const saved = path.join(mobile, ".android-cache");
385 const caches = [".gradle", "app/.cxx"];
386 const clear = fs.remove(saved, { recursive: true, force: true }).pipe(Effect.ignore);
387 const move = (from: string, to: string) =>
388 Effect.forEach(
389 caches,
390 (cache) =>
391 Effect.gen(function* () {
392 const source = path.join(from, cache);
393 const target = path.join(to, cache);
394 if (!(yield* fs.exists(source)) || (yield* fs.exists(target))) return;
395 yield* fs.makeDirectory(path.dirname(target), { recursive: true });
396 yield* fs.rename(source, target);
397 }).pipe(Effect.ignore),
398 { discard: true },
399 );
400 yield* Effect.acquireUseRelease(
401 clear.pipe(Effect.andThen(move(android, saved))),
402 () => prebuild,
403 () => move(saved, android).pipe(Effect.andThen(clear)),
404 );
405});
406
407const artifactNames = { ios: "T3CodeDev.app", android: "app-debug.apk" } as const;
408// R2 requires equal-sized parts and Workers cap request bodies at 100 MB.
409const partSize = 64 * 1024 * 1024;
410const RemoteCredentials = Schema.fromJsonString(
411 Schema.Record(Schema.String, Schema.Struct({ name: Schema.String, token: Schema.String })),
412);
413const decodeCredentials = Schema.decodeUnknownEffect(RemoteCredentials);
414const decodeEndpoint = Schema.decodeUnknownEffect(
415 Schema.fromJsonString(Schema.Struct({ endpoint: Schema.String })),
416);
417const decodeUpload = Schema.decodeUnknownEffect(
418 Schema.Union([
419 Schema.Struct({ exists: Schema.Literal(true) }),
420 Schema.Struct({ uploadId: Schema.String }),
421 ]),
422);
423const decodePart = Schema.decodeUnknownEffect(Schema.Struct({ etag: Schema.String }));
424const decodeEasBuilds = Schema.decodeUnknownEffect(
425 Schema.fromJsonString(
426 Schema.Array(Schema.Struct({ artifacts: Schema.Struct({ buildUrl: Schema.String }) })),
427 ),
428);
429
430interface RemoteCache {
431 readonly url: string;
432 readonly telemetry: string;
433 readonly name: string;
434 readonly token: string;
435}
436
437/** The shared task-cache login (infra/task-cache/install.sh) also authorizes native clients. */
438const remoteCache = Effect.fn("nativeClient.remoteCache")(
439 function* (home: string) {
440 const environment = yield* HostProcess.Environment;
441 if (environment.T3_NATIVE_CLIENT_CACHE === "off") return null;
442 const fs = yield* FileSystem.FileSystem;
443 const path = yield* Path.Path;
444 const { endpoint } = yield* decodeEndpoint(
445 yield* fs.readFileString(path.join((yield* roots).repo, ".task-cache.json")),
446 );
447 const credentials = yield* decodeCredentials(
448 yield* fs.readFileString(path.join(home, ".config/t3-task-cache/credentials.json")),
449 );
450 const login = credentials[endpoint];
451 if (!login) return null;
452 const base = endpoint.replace(/\/$/, "");
453 return {
454 url: `${base}/native-clients`,
455 telemetry: `${base}/telemetry`,
456 ...login,
457 } satisfies RemoteCache;
458 },
459 Effect.orElseSucceed(() => null),
460);
461
462const ReaderLogin = Schema.fromJsonString(
463 Schema.Struct({ name: Schema.String, token: Schema.String }),
464);
465
466/**
467 * A URL another machine can download `key` from. The header goes to that machine, so only a
468 * read-only login (`ci-reader`) is ever handed out, never a developer's writer token.
469 */
470const sharedSource = Effect.fn("nativeClient.sharedSource")(
471 function* (home: string, key: string) {
472 const remote = yield* remoteCache(home);
473 if (!remote) return null;
474 const fs = yield* FileSystem.FileSystem;
475 const path = yield* Path.Path;
476 const reader =
477 remote.name === "ci-reader"
478 ? remote
479 : yield* fs
480 .readFileString(path.join(home, ".config/t3-task-cache/ci-reader.json"))
481 .pipe(Effect.flatMap(Schema.decodeUnknownEffect(ReaderLogin)));
482 const basic = Buffer.from(`${reader.name}:${reader.token}`).toString("base64");
483 return { url: `${remote.url}/${key}`, header: `authorization:Basic ${basic}` };
484 },
485 Effect.orElseSucceed(() => null),
486);
487
488const authorized = (remote: RemoteCache, request: HttpClientRequest.HttpClientRequest) =>
489 request.pipe(HttpClientRequest.basicAuth(remote.name, remote.token));
490
491// Build duration lives beside the artifact so hits can report the time they saved.
492const sidecar = (directory: string) => `${directory}.json`;
493const decodeSidecar = Schema.decodeUnknownEffect(
494 Schema.fromJsonString(Schema.Struct({ buildMs: Schema.Number })),
495);
496const readBuildMs = (directory: string) =>
497 FileSystem.FileSystem.pipe(
498 Effect.flatMap((fs) => fs.readFileString(sidecar(directory))),
499 Effect.flatMap(decodeSidecar),
500 Effect.map(({ buildMs }) => buildMs),
501 Effect.orElseSucceed(() => 0),
502 );
503const writeBuildMs = (directory: string, buildMs: number) =>
504 FileSystem.FileSystem.pipe(
505 Effect.flatMap((fs) => fs.writeFileString(sidecar(directory), JSON.stringify({ buildMs }))),
506 Effect.ignore,
507 );
508
509/** Download into a sibling staging directory and publish only after the digest matches. */
510const download = Effect.fn("nativeClient.download")(function* (
511 remote: RemoteCache,
512 platform: NativePlatform,
513 key: string,
514 target: string,
515) {
516 const fs = yield* FileSystem.FileSystem;
517 const path = yield* Path.Path;
518 const client = yield* HttpClient.HttpClient;
519 const response = yield* client.execute(
520 authorized(remote, HttpClientRequest.get(`${remote.url}/${key}`)),
521 );
522 if (response.status === 404) return false;
523 yield* HttpClientResponse.filterStatusOk(response);
524 const expected = response.headers["x-t3-digest"] ?? "";
525 if (!/^[a-f0-9]{64}$/.test(expected))
526 return yield* new NativeClientError({ message: "Cached native client has no digest." });
527 yield* Console.error(`Downloading native client ${key} from the shared cache...`);
528 const staging = yield* fs.makeTempDirectory({
529 directory: path.dirname(target),
530 prefix: ".download-",
531 });
532 return yield* Effect.gen(function* () {
533 if (platform === "ios") {
534 const archive = `${staging}.tgz`;
535 yield* response.stream.pipe(
536 Stream.run(fs.sink(archive)),
537 Effect.andThen(command("tar", ["-xzf", archive, "-C", staging])),
538 Effect.ensuring(fs.remove(archive, { force: true }).pipe(Effect.ignore)),
539 );
540 } else {
541 yield* response.stream.pipe(Stream.run(fs.sink(path.join(staging, artifactNames.android))));
542 }
543 if ((yield* hashBundle(staging)) !== expected)
544 return yield* new NativeClientError({ message: "Cached native client failed verification." });
545 yield* publish(staging, target);
546 const buildMs = Number(response.headers["x-t3-build-ms"] ?? 0);
547 yield* writeBuildMs(target, Number.isSafeInteger(buildMs) ? buildMs : 0);
548 return true;
549 }).pipe(
550 Effect.ensuring(fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore)),
551 );
552});
553
554/** Upload in fixed-size parts; any failure aborts the upload and leaves the cache unchanged. */
555const upload = Effect.fn("nativeClient.upload")(function* (
556 remote: RemoteCache,
557 platform: NativePlatform,
558 key: string,
559 artifact: string,
560) {
561 const fs = yield* FileSystem.FileSystem;
562 const path = yield* Path.Path;
563 const client = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
564 const created = yield* client
565 .execute(
566 authorized(remote, HttpClientRequest.post(`${remote.url}/${key}/uploads`)).pipe(
567 HttpClientRequest.setHeader("x-t3-digest", yield* hashBundle(artifact)),
568 HttpClientRequest.setHeader("x-t3-build-ms", String(yield* readBuildMs(artifact))),
569 ),
570 )
571 .pipe(
572 Effect.flatMap((response) => response.json),
573 Effect.flatMap(decodeUpload),
574 );
575 if ("exists" in created) return;
576 const uploadUrl = `${remote.url}/${key}/uploads/${encodeURIComponent(created.uploadId)}`;
577 yield* Console.error(`Uploading native client ${key} to the shared cache...`);
578 yield* Effect.gen(function* () {
579 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-native-upload-" });
580 const file =
581 platform === "ios"
582 ? path.join(directory, "client.tgz")
583 : path.join(artifact, artifactNames.android);
584 if (platform === "ios")
585 yield* command("tar", ["-czf", file, "-C", artifact, artifactNames.ios]);
586 const handle = yield* fs.open(file);
587 const parts: Array<{ partNumber: number; etag: string }> = [];
588 for (let partNumber = 1; ; partNumber++) {
589 const buffer = new Uint8Array(partSize);
590 let filled = 0;
591 while (filled < partSize) {
592 const read = Number(yield* handle.read(buffer.subarray(filled)));
593 if (read === 0) break;
594 filled += read;
595 }
596 if (filled === 0) break;
597 const { etag } = yield* client
598 .execute(
599 authorized(remote, HttpClientRequest.put(`${uploadUrl}/${partNumber}`)).pipe(
600 HttpClientRequest.bodyUint8Array(buffer.subarray(0, filled)),
601 ),
602 )
603 .pipe(
604 Effect.flatMap((response) => response.json),
605 Effect.flatMap(decodePart),
606 );
607 parts.push({ partNumber, etag });
608 if (filled < partSize) break;
609 }
610 yield* client.execute(
611 authorized(remote, HttpClientRequest.post(`${uploadUrl}/complete`)).pipe(
612 HttpClientRequest.bodyJsonUnsafe({ parts }),
613 ),
614 );
615 }).pipe(
616 Effect.scoped,
617 Effect.onError(() =>
618 client.execute(authorized(remote, HttpClientRequest.delete(uploadUrl))).pipe(Effect.ignore),
619 ),
620 );
621});
622
623const publish = Effect.fn("nativeClient.publish")(function* (staging: string, target: string) {
624 const fs = yield* FileSystem.FileSystem;
625 // A concurrent ensure may have published the same fingerprint first; either copy is valid.
626 if (yield* fs.exists(target)) return;
627 yield* fs.rename(staging, target);
628});
629
630const easCli = "eas-cli@25.3.0";
631
632/** Xcode builds iOS locally; without it (Linux, or a Mac with no Xcode) EAS builds in the cloud. */
633const hasXcode = Effect.gen(function* () {
634 if ((yield* HostProcess.Platform) !== "darwin") return false;
635 return yield* command("xcrun", ["--find", "xcodebuild"]).pipe(
636 Effect.as(true),
637 Effect.orElseSucceed(() => false),
638 );
639});
640
641/** Without Xcode, EAS builds the same simulator client from this checkout. */
642const buildIosOnEas = Effect.fn("nativeClient.buildIosOnEas")(function* (staging: string) {
643 const fs = yield* FileSystem.FileSystem;
644 const path = yield* Path.Path;
645 // Fail before any work when neither builder is available.
646 yield* command("npx", ["-y", easCli, "whoami"]).pipe(
647 Effect.mapError(
648 () =>
649 new NativeClientError({
650 message:
651 "iOS builds need Xcode on macOS, or an EAS login (`npx eas-cli login`, or EXPO_TOKEN) to build in the cloud.",
652 }),
653 ),
654 );
655 yield* Console.error("No Xcode here; building the iOS simulator client on EAS (10-30 min).");
656 const [build] = yield* decodeEasBuilds(
657 yield* command("npx", [
658 "-y",
659 easCli,
660 "build",
661 "--platform",
662 "ios",
663 "--profile",
664 "development-simulator",
665 "--non-interactive",
666 "--wait",
667 "--json",
668 ]),
669 );
670 if (!build) return yield* new NativeClientError({ message: "EAS returned no build." });
671 const archive = path.join(staging, "eas-build.tar.gz");
672 yield* command("curl", ["-fsSL", "--retry", "3", "-o", archive, build.artifacts.buildUrl]);
673 yield* command("tar", ["-xzf", archive, "-C", staging]);
674 yield* fs.remove(archive);
675 const app = (yield* fs.readDirectory(staging)).find((name) => name.endsWith(".app"));
676 if (!app) return yield* new NativeClientError({ message: "The EAS build archive has no .app." });
677 if (app !== artifactNames.ios)
678 yield* fs.rename(path.join(staging, app), path.join(staging, artifactNames.ios));
679});
680
681const build = Effect.fn("nativeClient.build")(function* (
682 platform: NativePlatform,
683 abi: string | null,
684 target: string,
685 expected: string,
686) {
687 const fs = yield* FileSystem.FileSystem;
688 const path = yield* Path.Path;
689 const { repo, mobile } = yield* roots;
690 const tracked = yield* command("git", ["ls-files", `apps/mobile/${platform}`], false, repo);
691 if (tracked)
692 return yield* new NativeClientError({
693 message: "Native directory contains tracked files; clean prebuild would overwrite them.",
694 });
695 const prebuild = command(
696 "vp",
697 ["exec", "expo", "prebuild", "--clean", "--platform", platform, "--no-install"],
698 true,
699 ).pipe(Effect.asVoid);
700 const onEas = platform === "ios" && !(yield* hasXcode);
701 if (platform === "android") yield* prebuildAndroid(mobile, prebuild);
702 else if (!onEas) yield* prebuild;
703 const staging = yield* fs.makeTempDirectory({
704 directory: path.dirname(target),
705 prefix: ".build-",
706 });
707 yield* Effect.gen(function* () {
708 if (onEas) {
709 yield* buildIosOnEas(staging);
710 } else if (platform === "ios") {
711 const output = yield* fs.makeTempDirectoryScoped({ prefix: "t3-native-client-" });
712 yield* command("pod", ["install"], true, path.join(mobile, "ios"));
713 // One device-independent arm64 simulator build installs on every iPhone and iPad simulator.
714 yield* command(
715 "xcrun",
716 [
717 "xcodebuild",
718 "-workspace",
719 path.join(mobile, "ios/T3CodeDev.xcworkspace"),
720 "-scheme",
721 "T3CodeDev",
722 "-configuration",
723 "Debug",
724 "-destination",
725 "generic/platform=iOS Simulator",
726 "-derivedDataPath",
727 output,
728 "ARCHS=arm64",
729 "ONLY_ACTIVE_ARCH=NO",
730 "build",
731 ],
732 true,
733 );
734 yield* command("ditto", [
735 path.join(output, "Build/Products/Debug-iphonesimulator", artifactNames.ios),
736 path.join(staging, artifactNames.ios),
737 ]);
738 } else {
739 const android = path.join(mobile, "android");
740 yield* command(
741 path.join(android, (yield* HostProcess.Platform) === "win32" ? "gradlew.bat" : "gradlew"),
742 ["app:assembleDebug", `-PreactNativeArchitectures=${abi}`],
743 true,
744 android,
745 );
746 yield* fs.copyFile(
747 path.join(android, "app/build/outputs/apk/debug", artifactNames.android),
748 path.join(staging, artifactNames.android),
749 );
750 }
751 // Native edits during the build would publish this binary under a fingerprint it doesn't match.
752 if ((yield* fingerprint(platform)) !== expected)
753 return yield* new NativeClientError({
754 message: "Native inputs changed during the build, so it was discarded. Run this again.",
755 });
756 yield* publish(staging, target);
757 }).pipe(
758 Effect.scoped,
759 Effect.ensuring(fs.remove(staging, { recursive: true, force: true }).pipe(Effect.ignore)),
760 );
761});
762
763/** Keep the newest few fingerprints per platform; each artifact is a few hundred megabytes. */
764const prune = Effect.fn("nativeClient.prune")(
765 function* (parent: string, keep: string) {
766 const fs = yield* FileSystem.FileSystem;
767 const path = yield* Path.Path;
768 const entries = yield* Effect.forEach(
769 (yield* fs.readDirectory(parent)).filter(
770 (name) => !name.startsWith(".") && !name.endsWith(".json"),
771 ),
772 (name) =>
773 fs.stat(path.join(parent, name)).pipe(
774 Effect.map((info) => ({
775 name,
776 modified: Option.getOrUndefined(info.mtime)?.getTime() ?? 0,
777 })),
778 ),
779 );
780 const stale = entries
781 .filter(({ name }) => name !== keep)
782 .sort((a, b) => b.modified - a.modified)
783 .slice(2);
784 yield* Effect.forEach(
785 stale,
786 ({ name }) =>
787 fs
788 .remove(path.join(parent, name), { recursive: true, force: true })
789 .pipe(Effect.andThen(fs.remove(sidecar(path.join(parent, name)), { force: true }))),
790 { discard: true },
791 );
792 },
793 (effect) => Effect.ignore(effect),
794);
795
796const buildWorkflow = "mobile-dev-client.yml";
797const WorkflowRun = Schema.Struct({
798 displayTitle: Schema.String,
799 status: Schema.String,
800 conclusion: Schema.NullOr(Schema.String),
801 createdAt: Schema.String,
802});
803const decodeWorkflowRuns = Schema.decodeUnknownEffect(
804 Schema.fromJsonString(Schema.Array(WorkflowRun)),
805);
806
807/** The workflow names each run after the client it builds, which is how a waiting machine finds it. */
808export const buildRunTitle = (platform: NativePlatform, abi: string | null, fingerprint: string) =>
809 `Dev client ${platform}${abi ? ` ${abi}` : ""} ${fingerprint}`;
810
811export type RemoteBuildState = "none" | "running" | "failed" | "succeeded";
812
813/** The newest run with this title that was created at or after `sinceMs`. */
814export const remoteBuildState = (
815 runs: ReadonlyArray<typeof WorkflowRun.Type>,
816 title: string,
817 sinceMs: number,
818): RemoteBuildState => {
819 const run = runs
820 .filter((run) => run.displayTitle === title && Date.parse(run.createdAt) >= sinceMs)
821 .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))[0];
822 if (!run) return "none";
823 if (run.status !== "completed") return "running";
824 return run.conclusion === "success" ? "succeeded" : "failed";
825};
826
827const remoteBuildPollMs = 20_000;
828const remoteBuildTimeoutMs = 25 * 60_000;
829/** A run this recent for the same client is joined instead of dispatching a second one. */
830const remoteBuildJoinMs = 30 * 60_000;
831
832/**
833 * Build the client for HEAD in the `mobile-dev-client.yml` workflow (Blacksmith runners with warm
834 * Gradle and ccache state) and download it from the shared cache. False means "build here
835 * instead": no `gh`, no permission to dispatch, HEAD not on GitHub, native edits that HEAD does
836 * not have (the workflow rejects a fingerprint it does not compute), or a failed run.
837 */
838const buildOnBlacksmith = Effect.fn("nativeClient.buildOnBlacksmith")(
839 function* (
840 remote: RemoteCache,
841 platform: NativePlatform,
842 abi: string | null,
843 fingerprint: string,
844 key: string,
845 directory: string,
846 ) {
847 const environment = yield* HostProcess.Environment;
848 // The workflow itself runs this script: it must build, never dispatch.
849 if (environment.GITHUB_ACTIONS === "true" || environment.T3_NATIVE_CLIENT_BUILD === "local")
850 return false;
851 if (!(yield* isCommandAvailable("gh"))) return false;
852 const repository = environment.T3_NATIVE_CLIENT_REPOSITORY ?? "pingdotgg/t3code";
853 const { repo } = yield* roots;
854 const sha = yield* command("git", ["rev-parse", "HEAD"], false, repo);
855 const pushed = yield* command(
856 "gh",
857 ["api", `repos/${repository}/commits/${sha}`, "--jq", ".sha"],
858 false,
859 repo,
860 ).pipe(
861 Effect.map((found) => found === sha),
862 Effect.orElseSucceed(() => false),
863 );
864 if (!pushed) {
865 yield* Console.error(`HEAD ${sha.slice(0, 10)} is not on GitHub yet; building here.`);
866 return false;
867 }
868 const title = buildRunTitle(platform, abi, fingerprint);
869 const state = (sinceMs: number) =>
870 command(
871 "gh",
872 [
873 "run",
874 "list",
875 "--repo",
876 repository,
877 "--workflow",
878 buildWorkflow,
879 "--event",
880 "workflow_dispatch",
881 "--limit",
882 "30",
883 "--json",
884 "displayTitle,status,conclusion,createdAt",
885 ],
886 false,
887 repo,
888 ).pipe(
889 Effect.flatMap(decodeWorkflowRuns),
890 Effect.map((runs) => remoteBuildState(runs, title, sinceMs)),
891 // A failed listing says nothing about the build; keep waiting on the cache.
892 Effect.orElseSucceed((): RemoteBuildState => "none"),
893 );
894 const started = yield* Clock.currentTimeMillis;
895 const joined = (yield* state(started - remoteBuildJoinMs)) === "running";
896 if (!joined) {
897 const dispatched = yield* command(
898 "gh",
899 [
900 "workflow",
901 "run",
902 buildWorkflow,
903 "--repo",
904 repository,
905 "--ref",
906 "main",
907 "-f",
908 `ref=${sha}`,
909 "-f",
910 `platform=${platform}`,
911 "-f",
912 `abi=${abi ?? "x86_64"}`,
913 "-f",
914 `fingerprint=${fingerprint}`,
915 ],
916 false,
917 repo,
918 ).pipe(
919 Effect.as(true),
920 Effect.orElseSucceed(() => false),
921 );
922 if (!dispatched) return false;
923 }
924 yield* Console.error(
925 `${joined ? "A Blacksmith build of" : "Building"} ${key} ${joined ? "is already running" : "on Blacksmith"}; waiting for it in the shared cache...`,
926 );
927 const since = joined ? started - remoteBuildJoinMs : started - 60_000;
928 const fetched = download(remote, platform, key, directory).pipe(
929 Effect.orElseSucceed(() => false),
930 );
931 while ((yield* Clock.currentTimeMillis) - started < remoteBuildTimeoutMs) {
932 yield* Effect.sleep(remoteBuildPollMs);
933 if (yield* fetched) return true;
934 const now = yield* state(since);
935 if (now === "failed" || (now === "succeeded" && !(yield* fetched))) {
936 yield* Console.error(
937 now === "failed"
938 ? "The Blacksmith build failed (or HEAD lacks this checkout's native edits); building here."
939 : "The Blacksmith run finished without sharing this client; building here.",
940 );
941 return false;
942 }
943 }
944 yield* Console.error("The Blacksmith build did not finish in time; building here.");
945 return false;
946 },
947 Effect.orElseSucceed(() => false),
948);
949
950/** Local artifact, then the shared cache, then a Blacksmith build when this user can start one, then a build here that is shared back when credentials allow. */
951export const resolveArtifact = Effect.fn("nativeClient.resolveArtifact")(function* (
952 home: string,
953 platform: NativePlatform,
954 abi: string | null,
955 fingerprint: string,
956) {
957 const fs = yield* FileSystem.FileSystem;
958 const path = yield* Path.Path;
959 const key = `${platform}/${abi ? `development-${abi}` : "development"}/${fingerprint}`;
960 const directory = path.join(home, ".cache/t3code/native-clients/artifacts", key);
961 const artifact = { directory, file: path.join(directory, artifactNames[platform]) };
962 if (yield* fs.exists(artifact.file)) {
963 yield* Console.error(`Using cached client ${key}.`);
964 // Pruning keeps the most recently used, so a client another checkout is installing stays.
965 const now = yield* Clock.currentTimeMillis;
966 yield* fs.utimes(directory, now / 1000, now / 1000).pipe(Effect.ignore);
967 return { ...artifact, source: "local" as const, buildMs: yield* readBuildMs(directory) };
968 }
969 yield* fs.makeDirectory(path.dirname(directory), { recursive: true });
970 const remote = yield* remoteCache(home);
971 if (remote) {
972 const downloaded = yield* download(remote, platform, key, directory).pipe(
973 Effect.catch((error) =>
974 Console.error(`Shared native client cache unavailable (${describe(error)}).`).pipe(
975 Effect.as(false),
976 ),
977 ),
978 );
979 if (downloaded) {
980 yield* prune(path.dirname(directory), fingerprint);
981 return { ...artifact, source: "remote" as const, buildMs: yield* readBuildMs(directory) };
982 }
983 }
984 if (remote && (yield* buildOnBlacksmith(remote, platform, abi, fingerprint, key, directory))) {
985 yield* prune(path.dirname(directory), fingerprint);
986 return { ...artifact, source: "remote" as const, buildMs: yield* readBuildMs(directory) };
987 }
988 const log = yield* BuildLog;
989 yield* Console.error(
990 `No cached client for ${key}. Building it here once for every machine (5-15 min)${log ? `. Log: ${log}` : ""}`,
991 );
992 const started = yield* Clock.currentTimeMillis;
993 yield* build(platform, abi, directory, fingerprint);
994 yield* writeBuildMs(directory, (yield* Clock.currentTimeMillis) - started);
995 yield* prune(path.dirname(directory), fingerprint);
996 if (remote && (yield* HostProcess.Environment).T3_NATIVE_CLIENT_CACHE !== "read") {
997 // The Blacksmith workflow exists to share the client: there a failed upload fails the run.
998 const required = (yield* HostProcess.Environment).T3_NATIVE_CLIENT_SHARE === "required";
999 yield* upload(remote, platform, key, directory).pipe(
1000 Effect.catch((error) =>
1001 required
1002 ? Effect.fail(
1003 new NativeClientError({
1004 message: `Native client was not shared (${describe(error)}).`,
1005 }),
1006 )
1007 : Console.error(`Native client was not shared (${describe(error)}).`),
1008 ),
1009 );
1010 }
1011 return { ...artifact, source: "build" as const, buildMs: 0 };
1012});
1013
1014const describe = (error: unknown) =>
1015 typeof error === "object" &&
1016 error !== null &&
1017 "message" in error &&
1018 typeof error.message === "string"
1019 ? error.message
1020 : String(error);
1021
1022const install = Effect.fn("nativeClient.install")(function* (
1023 platform: NativePlatform,
1024 device: string,
1025 file: string,
1026) {
1027 if (platform === "ios") {
1028 yield* command("xcrun", ["simctl", "install", device, file], true);
1029 return;
1030 }
1031 yield* command("adb", ["-s", device, "install", "-r", "-d", file]).pipe(
1032 Effect.catchTags({
1033 NativeClientError: (error) =>
1034 Effect.fail(
1035 error.message.includes("INSTALL_FAILED_UPDATE_INCOMPATIBLE")
1036 ? new NativeClientError({
1037 message: `The installed client has a different signature. Remove it with adb -s ${device} uninstall ${bundleId} (this clears its pairings), then run ensure again.`,
1038 })
1039 : error,
1040 ),
1041 }),
1042 );
1043 // Emulators reach Metro through 10.0.2.2; a phone on USB needs the port forwarded.
1044 if ((yield* command("adb", ["-s", device, "shell", "getprop", "ro.kernel.qemu"])) !== "1")
1045 yield* command("adb", ["-s", device, "reverse", "tcp:8081", "tcp:8081"]).pipe(Effect.ignore);
1046});
1047
1048const reportTelemetry = Effect.fn("nativeClient.reportTelemetry")(
1049 function* (
1050 home: string,
1051 task: string,
1052 source: "local" | "remote" | "executed" | "failed",
1053 savedMs: number,
1054 ) {
1055 const remote = yield* remoteCache(home);
1056 if (!remote) return;
1057 const environment = yield* HostProcess.Environment;
1058 const recordedAt = yield* Clock.currentTimeMillis;
1059 // Same machine label as Vite task reports (infra/task-cache/src/telemetry.ts).
1060 const fs = yield* FileSystem.FileSystem;
1061 const machine = yield* fs.readFileString(`${home}/.agents/MACHINE`).pipe(
1062 Effect.map((value) => value.trim()),
1063 Effect.orElseSucceed(() => ""),
1064 );
1065 const host =
1066 environment.T3_TASK_CACHE_HOST ||
1067 machine ||
1068 (environment.GITHUB_ACTIONS === "true"
1069 ? `${environment.RUNNER_ENVIRONMENT || "github"}/${environment.RUNNER_OS}/${environment.RUNNER_ARCH}`
1070 : yield* HostProcess.Hostname);
1071 const client = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
1072 yield* client.execute(
1073 authorized(remote, HttpClientRequest.post(remote.telemetry)).pipe(
1074 HttpClientRequest.bodyJsonUnsafe({
1075 id: yield* digest(`${host}:${task}:${recordedAt}:${yield* Random.nextInt}`),
1076 recordedAt,
1077 host,
1078 platform: yield* HostProcess.Platform,
1079 arch: yield* HostProcess.Architecture,
1080 ci: environment.CI === "true" || environment.CI === "1",
1081 runId: environment.GITHUB_RUN_ID ?? "",
1082 tasks: [{ package: "@t3tools/mobile", task, source, savedMs: Math.round(savedMs) }],
1083 }),
1084 ),
1085 );
1086 },
1087 (effect) => effect.pipe(Effect.timeout("5 seconds"), Effect.ignore),
1088);
1089
1090/**
1091 * Install into an agent-device session the caller already opened (a device pool, a cloud
1092 * provider, or a local device). A cache miss builds here, as for a local device: the device
1093 * only runs the client.
1094 */
1095const installIntoSession = Effect.fn("nativeClient.installIntoSession")(function* (
1096 home: string,
1097 platform: NativePlatform,
1098 session: string,
1099 abi: string,
1100 metro: string | undefined,
1101) {
1102 const started = yield* Clock.currentTimeMillis;
1103 const path = yield* Path.Path;
1104 const node = yield* HostProcess.ExecutablePath;
1105 const agentDevice = path.join(
1106 (yield* roots).repo,
1107 "scripts/node_modules/agent-device/bin/agent-device.mjs",
1108 );
1109 // agent-device scopes session ownership to the workspace (working directory) it runs in.
1110 const workspace = yield* HostProcess.WorkingDirectory;
1111 const deviceAbi = platform === "android" ? abi : null;
1112 const task = deviceAbi ? `native-client:android-${deviceAbi}` : `native-client:${platform}`;
1113 const current = yield* fingerprint(platform);
1114 const key = `${platform}/${deviceAbi ? `development-${deviceAbi}` : "development"}/${current}`;
1115 const source = yield* sharedSource(home, key);
1116 // The session's host downloads the client itself; uploading a 166 MB APK through a
1117 // gateway from here takes minutes and can outlast the remote install timeout.
1118 const downloadOnDevice = source
1119 ? command(
1120 node,
1121 [
1122 agentDevice,
1123 "install-from-source",
1124 source.url,
1125 "--header",
1126 source.header,
1127 "--session",
1128 session,
1129 ],
1130 true,
1131 workspace,
1132 ).pipe(
1133 Effect.as(true),
1134 Effect.catch((error) =>
1135 Console.error(`The device could not download ${key} (${describe(error)}).`).pipe(
1136 Effect.as(false),
1137 ),
1138 ),
1139 )
1140 : Effect.succeed(false);
1141 const fetched = yield* downloadOnDevice;
1142 const artifact = fetched
1143 ? { source: "remote" as const, buildMs: 0 }
1144 : yield* Effect.gen(function* () {
1145 const local = yield* resolveArtifact(home, platform, deviceAbi, current).pipe(
1146 Effect.tapError(() => reportTelemetry(home, task, "failed", 0)),
1147 );
1148 // A build just shared the client, so the device can fetch it now too.
1149 if (local.source === "build" && (yield* downloadOnDevice)) return local;
1150 yield* Console.error(`Installing into agent-device session ${session}...`);
1151 yield* command(
1152 node,
1153 [agentDevice, "install", bundleId, local.file, "--session", session],
1154 true,
1155 workspace,
1156 );
1157 return local;
1158 });
1159 if (metro) {
1160 const url = encodeURIComponent(metro);
1161 yield* command(
1162 node,
1163 [
1164 agentDevice,
1165 "open",
1166 bundleId,
1167 `t3code-dev://expo-development-client/?url=${url}&disableOnboarding=1&disableFab=1&disableAutoLaunch=1`,
1168 "--session",
1169 session,
1170 ],
1171 true,
1172 workspace,
1173 );
1174 }
1175 yield* reportTelemetry(
1176 home,
1177 task,
1178 artifact.source === "build" ? "executed" : artifact.source,
1179 artifact.buildMs,
1180 );
1181 const seconds = Math.round(((yield* Clock.currentTimeMillis) - started) / 1000);
1182 yield* Console.error(
1183 `Installed into session ${session} in ${seconds}s (${artifact.source}).${metro ? "" : " Open it with Metro: --metro http://<this-machine>:8081."}`,
1184 );
1185 yield* Console.log(
1186 yield* encodeOutput({
1187 status: "installed",
1188 source: artifact.source,
1189 fingerprint: current,
1190 session,
1191 seconds,
1192 }),
1193 );
1194});
1195
1196const main = Command.make(
1197 "mobile-native-client",
1198 {
1199 mode: Argument.Literals("mode", ["check", "ensure"]),
1200 platform: Argument.Literals("platform", ["ios", "android"]),
1201 device: Argument.String("device").pipe(Argument.optional),
1202 session: Flag.String("session").pipe(
1203 Flag.withDescription(
1204 "Install into this already-open agent-device session (a device pool, cloud, or remote host) instead of a local device.",
1205 ),
1206 Flag.optional,
1207 ),
1208 abi: Flag.Literals("abi", ["x86_64", "arm64-v8a"]).pipe(
1209 Flag.withDescription("Android ABI of the session's device (default x86_64)."),
1210 Flag.withDefault("x86_64" as const),
1211 ),
1212 metro: Flag.String("metro").pipe(
1213 Flag.withDescription("With --session, open the dev client against this Metro URL."),
1214 Flag.optional,
1215 ),
1216 prepare: Flag.Boolean("prepare").pipe(
1217 Flag.withDescription(
1218 "Fetch or build the client for this checkout without a device (Android: for --abi). Run it before leasing a device: a build can outlast the lease.",
1219 ),
1220 Flag.withDefault(false),
1221 ),
1222 expectFingerprint: Flag.String("expect-fingerprint").pipe(
1223 Flag.withDescription(
1224 "With --prepare, fail before building unless the checkout's native fingerprint is this one.",
1225 ),
1226 Flag.optional,
1227 ),
1228 },
1229 Effect.fn("nativeClient.main")(function* ({
1230 mode,
1231 platform,
1232 device: requested,
1233 session,
1234 abi: sessionAbi,
1235 metro,
1236 prepare,
1237 expectFingerprint,
1238 }) {
1239 const started = yield* Clock.currentTimeMillis;
1240 if (prepare) {
1241 if (mode !== "ensure" || Option.isSome(requested) || Option.isSome(session))
1242 return yield* new NativeClientError({
1243 message: "--prepare needs no device; use it with ensure and no device id or --session.",
1244 });
1245 const environment = yield* HostProcess.Environment;
1246 const home = environment.HOME ?? environment.USERPROFILE;
1247 if (!home)
1248 return yield* new NativeClientError({ message: "HOME or USERPROFILE must be set." });
1249 const current = yield* fingerprint(platform);
1250 if (Option.isSome(expectFingerprint) && expectFingerprint.value !== current)
1251 return yield* new NativeClientError({
1252 message: `This checkout's ${platform} fingerprint is ${current}, not the expected ${expectFingerprint.value}: the caller has native changes this commit does not.`,
1253 });
1254 const artifact = yield* resolveArtifact(
1255 home,
1256 platform,
1257 platform === "android" ? sessionAbi : null,
1258 current,
1259 );
1260 yield* Console.error(
1261 `Client ready for ${platform} (${artifact.source}); lease a device and install.`,
1262 );
1263 return yield* Console.log(
1264 yield* encodeOutput({ status: "ready", source: artifact.source, fingerprint: current }),
1265 );
1266 }
1267 if (Option.isSome(session)) {
1268 if (mode !== "ensure" || Option.isSome(requested))
1269 return yield* new NativeClientError({
1270 message:
1271 "--session installs into an agent-device session; use it with ensure and no device id.",
1272 });
1273 const environment = yield* HostProcess.Environment;
1274 const home = environment.HOME ?? environment.USERPROFILE;
1275 if (!home)
1276 return yield* new NativeClientError({ message: "HOME or USERPROFILE must be set." });
1277 return yield* installIntoSession(
1278 home,
1279 platform,
1280 session.value,
1281 sessionAbi,
1282 Option.getOrUndefined(metro),
1283 );
1284 }
1285 const target = yield* resolveDevice(platform, Option.getOrUndefined(requested));
1286 const device = target.id;
1287 yield* Console.error(`Device: ${target.label} (${device})`);
1288 const fs = yield* FileSystem.FileSystem;
1289 const path = yield* Path.Path;
1290 const environment = yield* HostProcess.Environment;
1291 const home = environment.HOME ?? environment.USERPROFILE;
1292 if (!home)
1293 return yield* new NativeClientError({
1294 message: "HOME or USERPROFILE must be set to store native client records.",
1295 });
1296 const cache = path.join(home, ".cache/t3code/native-clients");
1297 const recordPath = path.join(cache, platform, `${yield* digest(device)}.json`);
1298 // An iOS simulator build is arm64 for every device; an APK carries only the device's own ABI.
1299 const abi = Effect.gen(function* () {
1300 if (platform === "ios") return null;
1301 const value = yield* command("adb", ["-s", device, "shell", "getprop", "ro.product.cpu.abi"]);
1302 if (value !== "x86_64" && value !== "arm64-v8a")
1303 return yield* new NativeClientError({ message: `Unsupported Android ABI ${value}.` });
1304 return value;
1305 });
1306 let task = `native-client:${platform}`;
1307 let savedMs = 0;
1308 const operations = {
1309 fingerprint: fingerprint(platform),
1310 installedBinary: installedBinary(platform, device),
1311 readRecord: fs.readFileString(recordPath).pipe(
1312 Effect.flatMap(decodeRecord),
1313 Effect.catchTags({ SchemaError: () => Effect.succeed(null) }),
1314 Effect.catchIf(
1315 (error) => error.reason._tag === "NotFound",
1316 () => Effect.succeed(null),
1317 ),
1318 ),
1319 install: Effect.fn(function* (fingerprint: string) {
1320 const deviceAbi = yield* abi;
1321 if (deviceAbi) task = `native-client:android-${deviceAbi}`;
1322 const artifact = yield* resolveArtifact(home, platform, deviceAbi, fingerprint);
1323 savedMs = artifact.buildMs;
1324 yield* Console.error(`Installing on ${target.label}...`);
1325 yield* install(platform, device, artifact.file);
1326 return artifact.source;
1327 }),
1328 saveRecord: Effect.fn(function* (record: NativeClientRecord) {
1329 yield* fs.makeDirectory(path.dirname(recordPath), { recursive: true });
1330 yield* fs.writeFileString(recordPath, yield* encodeRecord(record));
1331 }),
1332 };
1333 if (mode === "check") {
1334 const current = yield* operations.fingerprint;
1335 const status = clientStatus(
1336 current,
1337 yield* operations.installedBinary,
1338 yield* operations.readRecord,
1339 );
1340 yield* Console.log(
1341 yield* encodeOutput({
1342 status,
1343 fingerprint: current,
1344 device,
1345 next:
1346 status === "compatible"
1347 ? "Start Metro with vp run dev:client"
1348 : `vp run mobile:install ${platform} ${device}`,
1349 }),
1350 );
1351 process.exitCode = status === "compatible" ? 0 : 2;
1352 return;
1353 }
1354 const logs = path.join(cache, "logs");
1355 yield* fs.makeDirectory(logs, { recursive: true });
1356 yield* prune(logs, "");
1357 const stamp = DateTime.formatIso(DateTime.makeUnsafe(started)).replaceAll(":", "-");
1358 const log = path.join(logs, `${stamp}-${platform}.log`);
1359 const result = yield* ensureClient(operations).pipe(
1360 Effect.provideService(BuildLog, environment.T3_NATIVE_CLIENT_VERBOSE === "1" ? null : log),
1361 Effect.tapError(() => reportTelemetry(home, task, "failed", 0)),
1362 );
1363 if (result.installed && result.source)
1364 yield* reportTelemetry(
1365 home,
1366 task,
1367 result.source === "build" ? "executed" : result.source,
1368 result.source === "build" ? 0 : savedMs,
1369 );
1370 const seconds = Math.round(((yield* Clock.currentTimeMillis) - started) / 1000);
1371 yield* Console.error(
1372 result.installed
1373 ? `Installed on ${target.label} in ${seconds}s (${result.source}). Next: start Metro with vp run dev:client.`
1374 : `${target.label} already has the client for this checkout. Next: start Metro with vp run dev:client.`,
1375 );
1376 yield* Console.log(yield* encodeOutput({ ...result, device, seconds }));
1377 }),
1378);
1379
1380if (import.meta.main) {
1381 Command.run(main, { version: "0.0.0" }).pipe(
1382 // Expected failures carry their next step; a stack trace only buries it.
1383 Effect.catchTags({
1384 NativeClientError: (error) =>
1385 Console.error(error.message).pipe(
1386 Effect.andThen(Effect.sync(() => (process.exitCode = 1))),
1387 ),
1388 }),
1389 Effect.scoped,
1390 Effect.provide([NodeServices.layer, NodeCrypto.layer, FetchHttpClient.layer]),
1391 NodeRuntime.runMain,
1392 );
1393}