1import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as HostProcess from "@t3tools/shared/HostProcess";
4import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell";
5import * as Console from "effect/Console";
6import * as Crypto from "effect/Crypto";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
11import * as Stream from "effect/Stream";
12import { Argument, Command } from "effect/cli";
13import { ChildProcess, ChildProcessSpawner } from "effect/process";
14
15export type NativePlatform = "ios" | "android";
16const NativeClientRecord = Schema.Struct({ fingerprint: Schema.String, binary: Schema.String });
17const encodeRecord = Schema.encodeEffect(Schema.fromJsonString(NativeClientRecord));
18const decodeRecord = Schema.decodeUnknownEffect(Schema.fromJsonString(NativeClientRecord));
19const encodeOutput = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
20const isNotLink = Schema.is(Schema.Struct({ code: Schema.Literal("EINVAL") }));
21const decodeSimulators = Schema.decodeUnknownEffect(
22 Schema.fromJsonString(
23 Schema.Struct({
24 devices: Schema.Record(
25 Schema.String,
26 Schema.Array(Schema.Struct({ udid: Schema.String, state: Schema.String })),
27 ),
28 }),
29 ),
30);
31export type NativeClientRecord = typeof NativeClientRecord.Type;
32export type NativeClientStatus = "compatible" | "missing" | "unknown" | "stale";
33export class NativeClientError extends Schema.TaggedError<NativeClientError>()(
34 "NativeClientError",
35 { message: Schema.String },
36) {}
37
38export function clientStatus(
39 fingerprint: string,
40 binary: string | null,
41 record: NativeClientRecord | null,
42): NativeClientStatus {
43 if (binary === null) return "missing";
44 if (!record || record.binary !== binary) return "unknown";
45 return record.fingerprint === fingerprint ? "compatible" : "stale";
46}
47
48/** Keep native sources stable during ensure, as with a normal build; endpoint checks reject detected edits. */
49export const ensureClient = Effect.fn("ensureClient")(function* <E, R, E2, R2>(operations: {
50 fingerprint: Effect.Effect<string, E, R>;
51 installedBinary: Effect.Effect<string | null, E, R>;
52 readRecord: Effect.Effect<NativeClientRecord | null, E, R>;
53 build: Effect.Effect<void, E, R>;
54 saveRecord: (record: NativeClientRecord) => Effect.Effect<void, E2, R2>;
55}) {
56 const fingerprint = yield* operations.fingerprint;
57 const status = clientStatus(
58 fingerprint,
59 yield* operations.installedBinary,
60 yield* operations.readRecord,
61 );
62 const verifyInputs = Effect.gen(function* () {
63 if ((yield* operations.fingerprint) !== fingerprint) {
64 return yield* new NativeClientError({
65 message:
66 "Native inputs changed during verification. Run ensure again; this build was not recorded.",
67 });
68 }
69 });
70 yield* verifyInputs;
71 if (status === "compatible") {
72 return { status, rebuilt: false, fingerprint };
73 }
74 yield* operations.build;
75 const binary = yield* operations.installedBinary;
76 if (binary === null)
77 return yield* new NativeClientError({
78 message: "Build finished but the development client is not installed.",
79 });
80 yield* verifyInputs;
81 yield* operations.saveRecord({ fingerprint, binary });
82 return { status: "compatible" as const, rebuilt: true, fingerprint };
83});
84
85const digest = Effect.fn("nativeClient.digest")(function* (value: string | Uint8Array) {
86 const crypto = yield* Crypto.Crypto;
87 const bytes = yield* crypto.digest(
88 "SHA-256",
89 typeof value === "string" ? new TextEncoder().encode(value) : value,
90 );
91 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
92});
93
94/** Hash fixed-size chunks to bound memory, including resources and symlink targets. */
95export const hashBundle = Effect.fn("hashBundle")(function* (root: string) {
96 const fs = yield* FileSystem.FileSystem;
97 const path = yield* Path.Path;
98 const entries: string[] = [];
99 const visit = (
100 relative: string,
101 ): Effect.Effect<void, FileSystemError, FileSystem.FileSystem | Crypto.Crypto> =>
102 Effect.gen(function* () {
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
168const command = Effect.fn("nativeClient.command")(function* (
169 program: string,
170 args: string[],
171 inherit = false,
172 cwd?: string,
173) {
174 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
175 const environment = yield* HostProcess.Environment;
176 const spawn = yield* resolveSpawnCommand(program === "adb" ? yield* resolveAdb : program, args);
177 const child = yield* spawner.spawn(
178 ChildProcess.make(spawn.command, spawn.args, {
179 shell: spawn.shell,
180 cwd: cwd ?? (yield* roots).mobile,
181 env: {
182 ...environment,
183 APP_VARIANT: "development",
184 MOBILE_VERSION_POLICY: "appVersion",
185 T3CODE_IOS_PERSONAL_TEAM: "0",
186 CI: "1",
187 EXPO_NO_GIT_STATUS: "1",
188 },
189 stdin: "ignore",
190 stdout: inherit ? "inherit" : "pipe",
191 stderr: inherit ? "inherit" : "pipe",
192 }),
193 );
194 const [stdout, stderr, code] = yield* Effect.all(
195 [collect(child.stdout), collect(child.stderr), child.exitCode],
196 { concurrency: "unbounded" },
197 );
198 if (code !== 0)
199 return yield* new NativeClientError({
200 message: `${program} ${args[0]} failed (${code}): ${stderr || "see build output"}`,
201 });
202 return stdout.trim();
203}, Effect.scoped);
204
205const fingerprint = Effect.fn("nativeClient.fingerprint")(function* (platform: NativePlatform) {
206 const output = yield* command(yield* HostProcess.ExecutablePath, [
207 "--eval",
208 `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; });`,
209 platform,
210 ]);
211 const hash = output
212 .split("\n")
213 .find((line) => line.startsWith("T3_NATIVE_FINGERPRINT="))
214 ?.split("=")[1];
215 if (!hash || !/^[a-f0-9]{40,64}$/.test(hash))
216 return yield* new NativeClientError({ message: "Expo did not return a native fingerprint." });
217 return hash;
218});
219
220const validateDevice = Effect.fn("nativeClient.validateDevice")(function* (
221 platform: NativePlatform,
222 device: string,
223) {
224 if (platform === "ios") {
225 if ((yield* HostProcess.Platform) !== "darwin")
226 return yield* new NativeClientError({
227 message: "Run iOS check/ensure on the Mac that hosts the simulator.",
228 });
229 const listing = yield* decodeSimulators(
230 yield* command("xcrun", ["simctl", "list", "devices", "available", "--json"]),
231 );
232 const simulator = Object.values(listing.devices)
233 .flat()
234 .find((entry) => entry.udid === device);
235 if (!simulator)
236 return yield* new NativeClientError({
237 message: `No available iOS simulator with UDID ${device}.`,
238 });
239 if (simulator.state !== "Booted")
240 return yield* new NativeClientError({
241 message: `Boot the selected simulator first: xcrun simctl boot ${device}`,
242 });
243 } else {
244 if ((yield* command("adb", ["-s", device, "get-state"])) !== "device")
245 return yield* new NativeClientError({ message: "Android device is not connected." });
246 if ((yield* command("adb", ["-s", device, "shell", "getprop", "ro.kernel.qemu"])) !== "1")
247 return yield* new NativeClientError({
248 message: "Select an Android emulator, not a physical device.",
249 });
250 }
251});
252
253export const installedBinary = Effect.fn("installedBinary")(function* (
254 platform: NativePlatform,
255 device: string,
256 run: typeof command = command,
257) {
258 if (platform === "ios") {
259 const apps = yield* run("xcrun", ["simctl", "listapps", device]);
260 if (!apps.includes(`"${bundleId}"`)) return null;
261 return yield* hashBundle(
262 yield* run("xcrun", ["simctl", "get_app_container", device, bundleId, "app"]),
263 );
264 }
265 const installed = yield* run("adb", ["-s", device, "shell", "pm", "list", "packages", bundleId]);
266 if (!installed.split("\n").some((line) => line.trim() === `package:${bundleId}`)) return null;
267 const packages = yield* run("adb", ["-s", device, "shell", "pm", "path", bundleId]);
268 const apks = packages
269 .split("\n")
270 .filter((line) => line.startsWith("package:"))
271 .map((line) => line.slice(8).trim())
272 .sort();
273 if (apks.length === 0) return null;
274 const hashes = yield* Effect.forEach(apks, (apk) =>
275 Effect.gen(function* () {
276 if (!/^\/[\w/+=.~-]+\.apk$/.test(apk))
277 return yield* new NativeClientError({ message: "Unexpected installed APK path." });
278 const hash = (yield* run("adb", ["-s", device, "shell", "sha256sum", apk])).split(/\s/)[0];
279 if (!hash || !/^[a-f0-9]{64}$/.test(hash))
280 return yield* new NativeClientError({ message: "Could not hash installed APK." });
281 return hash;
282 }),
283 );
284 return yield* digest(hashes.sort().join("\n"));
285});
286
287const main = Command.make(
288 "mobile-native-client",
289 {
290 mode: Argument.Literals("mode", ["check", "ensure"]),
291 platform: Argument.Literals("platform", ["ios", "android"]),
292 device: Argument.String("device"),
293 },
294 Effect.fn("nativeClient.main")(function* ({ mode, platform, device }) {
295 yield* validateDevice(platform, device);
296 const fs = yield* FileSystem.FileSystem;
297 const path = yield* Path.Path;
298 const environment = yield* HostProcess.Environment;
299 const home = environment.HOME ?? environment.USERPROFILE;
300 if (!home)
301 return yield* new NativeClientError({
302 message: "HOME or USERPROFILE must be set to store native client records.",
303 });
304 const recordPath = path.join(
305 home,
306 ".cache/t3code/native-clients",
307 platform,
308 `${yield* digest(device)}.json`,
309 );
310 const operations = {
311 fingerprint: fingerprint(platform),
312 installedBinary: installedBinary(platform, device),
313 readRecord: fs.readFileString(recordPath).pipe(
314 Effect.flatMap(decodeRecord),
315 Effect.catchTags({ SchemaError: () => Effect.succeed(null) }),
316 Effect.catchIf(
317 (error) => error.reason._tag === "NotFound",
318 () => Effect.succeed(null),
319 ),
320 ),
321 build: Effect.gen(function* () {
322 yield* Console.error(
323 "Native client is missing, stale, or unverified. Building and installing a development client...",
324 );
325 const tracked = yield* command(
326 "git",
327 ["ls-files", `apps/mobile/${platform}`],
328 false,
329 (yield* roots).repo,
330 );
331 if (tracked)
332 return yield* new NativeClientError({
333 message:
334 "Native directory contains tracked files; clean prebuild would overwrite them.",
335 });
336 yield* command(
337 "vp",
338 ["exec", "expo", "prebuild", "--clean", "--platform", platform, "--no-install"],
339 true,
340 );
341 if (platform === "ios") {
342 const output = yield* fs.makeTempDirectoryScoped({ prefix: "t3-native-client-" });
343 const { mobile } = yield* roots;
344 yield* command("pod", ["install"], true, path.join(mobile, "ios"));
345 // Target this simulator only, without Expo's desktop activation or log streaming.
346 yield* command(
347 "xcrun",
348 [
349 "xcodebuild",
350 "-workspace",
351 path.join(mobile, "ios/TheKnifeDev.xcworkspace"),
352 "-scheme",
353 "TheKnifeDev",
354 "-configuration",
355 "Debug",
356 "-destination",
357 `id=${device}`,
358 "-derivedDataPath",
359 output,
360 "build",
361 ],
362 true,
363 );
364 yield* command(
365 "xcrun",
366 [
367 "simctl",
368 "install",
369 device,
370 path.join(output, "Build/Products/Debug-iphonesimulator/TheKnifeDev.app"),
371 ],
372 true,
373 );
374 } else {
375 yield* command(
376 "vp",
377 [
378 "exec",
379 "expo",
380 "run:android",
381 "--device",
382 device,
383 "--no-bundler",
384 "--variant",
385 "debug",
386 ],
387 true,
388 );
389 }
390 }).pipe(Effect.scoped),
391 saveRecord: Effect.fn(function* (record: NativeClientRecord) {
392 yield* fs.makeDirectory(path.dirname(recordPath), { recursive: true });
393 yield* fs.writeFileString(recordPath, yield* encodeRecord(record));
394 }),
395 };
396 if (mode === "ensure") {
397 yield* Console.log(yield* encodeOutput(yield* ensureClient(operations)));
398 } else {
399 const current = yield* operations.fingerprint;
400 const status = clientStatus(
401 current,
402 yield* operations.installedBinary,
403 yield* operations.readRecord,
404 );
405 yield* Console.log(
406 yield* encodeOutput({
407 status,
408 fingerprint: current,
409 next:
410 status === "compatible"
411 ? "Start Metro with vp run dev:client"
412 : `node scripts/mobile-native-client.ts ensure ${platform} ${device}`,
413 }),
414 );
415 process.exitCode = status === "compatible" ? 0 : 2;
416 }
417 }),
418);
419
420if (import.meta.main) {
421 Command.run(main, { version: "0.0.0" }).pipe(
422 Effect.scoped,
423 Effect.provide(NodeServices.layer),
424 NodeRuntime.runMain,
425 );
426}