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