| 1 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 4 | import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 5 | import * as Console from "effect/Console"; |
| 6 | import * as Crypto from "effect/Crypto"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as FileSystem from "effect/FileSystem"; |
| 9 | import * as Path from "effect/Path"; |
| 10 | import * as Schema from "effect/Schema"; |
| 11 | import * as Stream from "effect/Stream"; |
| 12 | import { Argument, Command } from "effect/cli"; |
| 13 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 14 | |
| 15 | export type NativePlatform = "ios" | "android"; |
| 16 | const NativeClientRecord = Schema.Struct({ fingerprint: Schema.String, binary: Schema.String }); |
| 17 | const encodeRecord = Schema.encodeEffect(Schema.fromJsonString(NativeClientRecord)); |
| 18 | const decodeRecord = Schema.decodeUnknownEffect(Schema.fromJsonString(NativeClientRecord)); |
| 19 | const encodeOutput = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); |
| 20 | const isNotLink = Schema.is(Schema.Struct({ code: Schema.Literal("EINVAL") })); |
| 21 | const 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 | ); |
| 31 | export type NativeClientRecord = typeof NativeClientRecord.Type; |
| 32 | export type NativeClientStatus = "compatible" | "missing" | "unknown" | "stale"; |
| 33 | export class NativeClientError extends Schema.TaggedError<NativeClientError>()( |
| 34 | "NativeClientError", |
| 35 | { message: Schema.String }, |
| 36 | ) {} |
| 37 | |
| 38 | export 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. */ |
| 49 | export 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 | |
| 85 | const 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. */ |
| 95 | export 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 | }); |
| 136 | type FileSystemError = import("effect/PlatformError").PlatformError; |
| 137 | |
| 138 | const bundleId = "com.t3tools.t3code.dev"; |
| 139 | const 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 | }); |
| 144 | const collect = <E>(stream: Stream.Stream<Uint8Array, E>) => |
| 145 | stream.pipe( |
| 146 | Stream.decodeText(), |
| 147 | Stream.runFold( |
| 148 | () => "", |
| 149 | (a, b) => a + b, |
| 150 | ), |
| 151 | ); |
| 152 | export 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 | const 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 | |
| 205 | const 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 | |
| 220 | const 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 | |
| 253 | export 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 | |
| 287 | const 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 | |
| 420 | if (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 | } |