mirror of
https://github.com/VibedByKaKi/t3-code-android-nightly.git
synced 2026-10-09 20:01:15 +02:00
430 lines
16 KiB
TypeScript
430 lines
16 KiB
TypeScript
import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
|
|
import * as NodeServices from "@effect/platform-node/NodeServices";
|
|
import {
|
|
HostProcessEnvironment,
|
|
HostProcessExecutablePath,
|
|
HostProcessPlatform,
|
|
} from "@t3tools/shared/hostProcess";
|
|
import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell";
|
|
import * as Console from "effect/Console";
|
|
import * as Crypto from "effect/Crypto";
|
|
import * as Effect from "effect/Effect";
|
|
import * as FileSystem from "effect/FileSystem";
|
|
import * as Path from "effect/Path";
|
|
import * as Schema from "effect/Schema";
|
|
import * as Stream from "effect/Stream";
|
|
import { Argument, Command } from "effect/cli";
|
|
import { ChildProcess, ChildProcessSpawner } from "effect/process";
|
|
|
|
export type NativePlatform = "ios" | "android";
|
|
const NativeClientRecord = Schema.Struct({ fingerprint: Schema.String, binary: Schema.String });
|
|
const encodeRecord = Schema.encodeEffect(Schema.fromJsonString(NativeClientRecord));
|
|
const decodeRecord = Schema.decodeUnknownEffect(Schema.fromJsonString(NativeClientRecord));
|
|
const encodeOutput = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
|
|
const isNotLink = Schema.is(Schema.Struct({ code: Schema.Literal("EINVAL") }));
|
|
const decodeSimulators = Schema.decodeUnknownEffect(
|
|
Schema.fromJsonString(
|
|
Schema.Struct({
|
|
devices: Schema.Record(
|
|
Schema.String,
|
|
Schema.Array(Schema.Struct({ udid: Schema.String, state: Schema.String })),
|
|
),
|
|
}),
|
|
),
|
|
);
|
|
export type NativeClientRecord = typeof NativeClientRecord.Type;
|
|
export type NativeClientStatus = "compatible" | "missing" | "unknown" | "stale";
|
|
export class NativeClientError extends Schema.TaggedError<NativeClientError>()(
|
|
"NativeClientError",
|
|
{ message: Schema.String },
|
|
) {}
|
|
|
|
export function clientStatus(
|
|
fingerprint: string,
|
|
binary: string | null,
|
|
record: NativeClientRecord | null,
|
|
): NativeClientStatus {
|
|
if (binary === null) return "missing";
|
|
if (!record || record.binary !== binary) return "unknown";
|
|
return record.fingerprint === fingerprint ? "compatible" : "stale";
|
|
}
|
|
|
|
/** Keep native sources stable during ensure, as with a normal build; endpoint checks reject detected edits. */
|
|
export const ensureClient = Effect.fn("ensureClient")(function* <E, R, E2, R2>(operations: {
|
|
fingerprint: Effect.Effect<string, E, R>;
|
|
installedBinary: Effect.Effect<string | null, E, R>;
|
|
readRecord: Effect.Effect<NativeClientRecord | null, E, R>;
|
|
build: Effect.Effect<void, E, R>;
|
|
saveRecord: (record: NativeClientRecord) => Effect.Effect<void, E2, R2>;
|
|
}) {
|
|
const fingerprint = yield* operations.fingerprint;
|
|
const status = clientStatus(
|
|
fingerprint,
|
|
yield* operations.installedBinary,
|
|
yield* operations.readRecord,
|
|
);
|
|
const verifyInputs = Effect.gen(function* () {
|
|
if ((yield* operations.fingerprint) !== fingerprint) {
|
|
return yield* new NativeClientError({
|
|
message:
|
|
"Native inputs changed during verification. Run ensure again; this build was not recorded.",
|
|
});
|
|
}
|
|
});
|
|
yield* verifyInputs;
|
|
if (status === "compatible") {
|
|
return { status, rebuilt: false, fingerprint };
|
|
}
|
|
yield* operations.build;
|
|
const binary = yield* operations.installedBinary;
|
|
if (binary === null)
|
|
return yield* new NativeClientError({
|
|
message: "Build finished but the development client is not installed.",
|
|
});
|
|
yield* verifyInputs;
|
|
yield* operations.saveRecord({ fingerprint, binary });
|
|
return { status: "compatible" as const, rebuilt: true, fingerprint };
|
|
});
|
|
|
|
const digest = Effect.fn("nativeClient.digest")(function* (value: string | Uint8Array) {
|
|
const crypto = yield* Crypto.Crypto;
|
|
const bytes = yield* crypto.digest(
|
|
"SHA-256",
|
|
typeof value === "string" ? new TextEncoder().encode(value) : value,
|
|
);
|
|
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
});
|
|
|
|
/** Hash fixed-size chunks to bound memory, including resources and symlink targets. */
|
|
export const hashBundle = Effect.fn("hashBundle")(function* (root: string) {
|
|
const fs = yield* FileSystem.FileSystem;
|
|
const path = yield* Path.Path;
|
|
const entries: string[] = [];
|
|
const visit = (
|
|
relative: string,
|
|
): Effect.Effect<void, FileSystemError, FileSystem.FileSystem | Crypto.Crypto> =>
|
|
Effect.gen(function* () {
|
|
const names = (yield* fs.readDirectory(path.join(root, relative))).sort((a, b) =>
|
|
a.localeCompare(b, "en"),
|
|
);
|
|
for (const name of names) {
|
|
const key = path.join(relative, name);
|
|
const absolute = path.join(root, key);
|
|
// FileSystem.stat follows links; readLink distinguishes them without following a cycle.
|
|
const link = yield* fs.readLink(absolute).pipe(
|
|
Effect.catchIf(
|
|
(error) => isNotLink(error.reason.cause),
|
|
() => Effect.succeed(null),
|
|
),
|
|
);
|
|
if (link !== null) {
|
|
entries.push(`link:${key}:${link}`);
|
|
continue;
|
|
}
|
|
const info = yield* fs.stat(absolute);
|
|
if (info.type === "Directory") {
|
|
entries.push(`directory:${key}`);
|
|
yield* visit(key);
|
|
} else {
|
|
const chunks = yield* fs.stream(absolute, { chunkSize: 65536 }).pipe(
|
|
Stream.mapEffect((chunk) => digest(chunk)),
|
|
Stream.runCollect,
|
|
);
|
|
entries.push(`file:${key}:${yield* digest(chunks.join("\n"))}`);
|
|
}
|
|
}
|
|
});
|
|
yield* visit("");
|
|
return yield* digest(entries.map((entry) => `${entry.length}:${entry}`).join(""));
|
|
});
|
|
type FileSystemError = import("effect/PlatformError").PlatformError;
|
|
|
|
const bundleId = "com.t3tools.t3code.dev";
|
|
const roots = Effect.gen(function* () {
|
|
const path = yield* Path.Path;
|
|
const repo = yield* path.fromFileUrl(new URL("../", import.meta.url));
|
|
return { repo, mobile: path.join(repo, "apps/mobile") };
|
|
});
|
|
const collect = <E>(stream: Stream.Stream<Uint8Array, E>) =>
|
|
stream.pipe(
|
|
Stream.decodeText(),
|
|
Stream.runFold(
|
|
() => "",
|
|
(a, b) => a + b,
|
|
),
|
|
);
|
|
export const resolveAdb = Effect.gen(function* () {
|
|
if (yield* isCommandAvailable("adb")) return "adb";
|
|
const environment = yield* HostProcessEnvironment;
|
|
const path = yield* Path.Path;
|
|
const executable = (yield* HostProcessPlatform) === "win32" ? "adb.exe" : "adb";
|
|
for (const sdk of [environment.ANDROID_SDK_ROOT, environment.ANDROID_HOME]) {
|
|
if (!sdk) continue;
|
|
const candidate = path.join(sdk, "platform-tools", executable);
|
|
if (yield* isCommandAvailable(candidate)) return candidate;
|
|
}
|
|
return yield* new NativeClientError({
|
|
message:
|
|
"adb was not found on PATH or in ANDROID_SDK_ROOT/ANDROID_HOME. Install Android SDK platform-tools.",
|
|
});
|
|
});
|
|
|
|
const command = Effect.fn("nativeClient.command")(function* (
|
|
program: string,
|
|
args: string[],
|
|
inherit = false,
|
|
cwd?: string,
|
|
) {
|
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
const environment = yield* HostProcessEnvironment;
|
|
const spawn = yield* resolveSpawnCommand(program === "adb" ? yield* resolveAdb : program, args);
|
|
const child = yield* spawner.spawn(
|
|
ChildProcess.make(spawn.command, spawn.args, {
|
|
shell: spawn.shell,
|
|
cwd: cwd ?? (yield* roots).mobile,
|
|
env: {
|
|
...environment,
|
|
APP_VARIANT: "development",
|
|
MOBILE_VERSION_POLICY: "appVersion",
|
|
T3CODE_IOS_PERSONAL_TEAM: "0",
|
|
CI: "1",
|
|
EXPO_NO_GIT_STATUS: "1",
|
|
},
|
|
stdin: "ignore",
|
|
stdout: inherit ? "inherit" : "pipe",
|
|
stderr: inherit ? "inherit" : "pipe",
|
|
}),
|
|
);
|
|
const [stdout, stderr, code] = yield* Effect.all(
|
|
[collect(child.stdout), collect(child.stderr), child.exitCode],
|
|
{ concurrency: "unbounded" },
|
|
);
|
|
if (code !== 0)
|
|
return yield* new NativeClientError({
|
|
message: `${program} ${args[0]} failed (${code}): ${stderr || "see build output"}`,
|
|
});
|
|
return stdout.trim();
|
|
}, Effect.scoped);
|
|
|
|
const fingerprint = Effect.fn("nativeClient.fingerprint")(function* (platform: NativePlatform) {
|
|
const output = yield* command(yield* HostProcessExecutablePath, [
|
|
"--eval",
|
|
`require('expo/fingerprint').createFingerprintAsync(process.cwd(), { platforms: [process.argv[1]], silent: true }).then(fp => console.log('T3_NATIVE_FINGERPRINT=' + fp.hash)).catch(e => { console.error(e); process.exitCode = 1; });`,
|
|
platform,
|
|
]);
|
|
const hash = output
|
|
.split("\n")
|
|
.find((line) => line.startsWith("T3_NATIVE_FINGERPRINT="))
|
|
?.split("=")[1];
|
|
if (!hash || !/^[a-f0-9]{40,64}$/.test(hash))
|
|
return yield* new NativeClientError({ message: "Expo did not return a native fingerprint." });
|
|
return hash;
|
|
});
|
|
|
|
const validateDevice = Effect.fn("nativeClient.validateDevice")(function* (
|
|
platform: NativePlatform,
|
|
device: string,
|
|
) {
|
|
if (platform === "ios") {
|
|
if ((yield* HostProcessPlatform) !== "darwin")
|
|
return yield* new NativeClientError({
|
|
message: "Run iOS check/ensure on the Mac that hosts the simulator.",
|
|
});
|
|
const listing = yield* decodeSimulators(
|
|
yield* command("xcrun", ["simctl", "list", "devices", "available", "--json"]),
|
|
);
|
|
const simulator = Object.values(listing.devices)
|
|
.flat()
|
|
.find((entry) => entry.udid === device);
|
|
if (!simulator)
|
|
return yield* new NativeClientError({
|
|
message: `No available iOS simulator with UDID ${device}.`,
|
|
});
|
|
if (simulator.state !== "Booted")
|
|
return yield* new NativeClientError({
|
|
message: `Boot the selected simulator first: xcrun simctl boot ${device}`,
|
|
});
|
|
} else {
|
|
if ((yield* command("adb", ["-s", device, "get-state"])) !== "device")
|
|
return yield* new NativeClientError({ message: "Android device is not connected." });
|
|
if ((yield* command("adb", ["-s", device, "shell", "getprop", "ro.kernel.qemu"])) !== "1")
|
|
return yield* new NativeClientError({
|
|
message: "Select an Android emulator, not a physical device.",
|
|
});
|
|
}
|
|
});
|
|
|
|
export const installedBinary = Effect.fn("installedBinary")(function* (
|
|
platform: NativePlatform,
|
|
device: string,
|
|
run: typeof command = command,
|
|
) {
|
|
if (platform === "ios") {
|
|
const apps = yield* run("xcrun", ["simctl", "listapps", device]);
|
|
if (!apps.includes(`"${bundleId}"`)) return null;
|
|
return yield* hashBundle(
|
|
yield* run("xcrun", ["simctl", "get_app_container", device, bundleId, "app"]),
|
|
);
|
|
}
|
|
const installed = yield* run("adb", ["-s", device, "shell", "pm", "list", "packages", bundleId]);
|
|
if (!installed.split("\n").some((line) => line.trim() === `package:${bundleId}`)) return null;
|
|
const packages = yield* run("adb", ["-s", device, "shell", "pm", "path", bundleId]);
|
|
const apks = packages
|
|
.split("\n")
|
|
.filter((line) => line.startsWith("package:"))
|
|
.map((line) => line.slice(8).trim())
|
|
.sort();
|
|
if (apks.length === 0) return null;
|
|
const hashes = yield* Effect.forEach(apks, (apk) =>
|
|
Effect.gen(function* () {
|
|
if (!/^\/[\w/+=.~-]+\.apk$/.test(apk))
|
|
return yield* new NativeClientError({ message: "Unexpected installed APK path." });
|
|
const hash = (yield* run("adb", ["-s", device, "shell", "sha256sum", apk])).split(/\s/)[0];
|
|
if (!hash || !/^[a-f0-9]{64}$/.test(hash))
|
|
return yield* new NativeClientError({ message: "Could not hash installed APK." });
|
|
return hash;
|
|
}),
|
|
);
|
|
return yield* digest(hashes.sort().join("\n"));
|
|
});
|
|
|
|
const main = Command.make(
|
|
"mobile-native-client",
|
|
{
|
|
mode: Argument.Literals("mode", ["check", "ensure"]),
|
|
platform: Argument.Literals("platform", ["ios", "android"]),
|
|
device: Argument.String("device"),
|
|
},
|
|
Effect.fn("nativeClient.main")(function* ({ mode, platform, device }) {
|
|
yield* validateDevice(platform, device);
|
|
const fs = yield* FileSystem.FileSystem;
|
|
const path = yield* Path.Path;
|
|
const environment = yield* HostProcessEnvironment;
|
|
const home = environment.HOME ?? environment.USERPROFILE;
|
|
if (!home)
|
|
return yield* new NativeClientError({
|
|
message: "HOME or USERPROFILE must be set to store native client records.",
|
|
});
|
|
const recordPath = path.join(
|
|
home,
|
|
".cache/t3code/native-clients",
|
|
platform,
|
|
`${yield* digest(device)}.json`,
|
|
);
|
|
const operations = {
|
|
fingerprint: fingerprint(platform),
|
|
installedBinary: installedBinary(platform, device),
|
|
readRecord: fs.readFileString(recordPath).pipe(
|
|
Effect.flatMap(decodeRecord),
|
|
Effect.catchTags({ SchemaError: () => Effect.succeed(null) }),
|
|
Effect.catchIf(
|
|
(error) => error.reason._tag === "NotFound",
|
|
() => Effect.succeed(null),
|
|
),
|
|
),
|
|
build: Effect.gen(function* () {
|
|
yield* Console.error(
|
|
"Native client is missing, stale, or unverified. Building and installing a development client...",
|
|
);
|
|
const tracked = yield* command(
|
|
"git",
|
|
["ls-files", `apps/mobile/${platform}`],
|
|
false,
|
|
(yield* roots).repo,
|
|
);
|
|
if (tracked)
|
|
return yield* new NativeClientError({
|
|
message:
|
|
"Native directory contains tracked files; clean prebuild would overwrite them.",
|
|
});
|
|
yield* command(
|
|
"vp",
|
|
["exec", "expo", "prebuild", "--clean", "--platform", platform, "--no-install"],
|
|
true,
|
|
);
|
|
if (platform === "ios") {
|
|
const output = yield* fs.makeTempDirectoryScoped({ prefix: "t3-native-client-" });
|
|
const { mobile } = yield* roots;
|
|
yield* command("pod", ["install"], true, path.join(mobile, "ios"));
|
|
// Target this simulator only, without Expo's desktop activation or log streaming.
|
|
yield* command(
|
|
"xcrun",
|
|
[
|
|
"xcodebuild",
|
|
"-workspace",
|
|
path.join(mobile, "ios/T3CodeDev.xcworkspace"),
|
|
"-scheme",
|
|
"T3CodeDev",
|
|
"-configuration",
|
|
"Debug",
|
|
"-destination",
|
|
`id=${device}`,
|
|
"-derivedDataPath",
|
|
output,
|
|
"build",
|
|
],
|
|
true,
|
|
);
|
|
yield* command(
|
|
"xcrun",
|
|
[
|
|
"simctl",
|
|
"install",
|
|
device,
|
|
path.join(output, "Build/Products/Debug-iphonesimulator/T3CodeDev.app"),
|
|
],
|
|
true,
|
|
);
|
|
} else {
|
|
yield* command(
|
|
"vp",
|
|
[
|
|
"exec",
|
|
"expo",
|
|
"run:android",
|
|
"--device",
|
|
device,
|
|
"--no-bundler",
|
|
"--variant",
|
|
"debug",
|
|
],
|
|
true,
|
|
);
|
|
}
|
|
}).pipe(Effect.scoped),
|
|
saveRecord: Effect.fn(function* (record: NativeClientRecord) {
|
|
yield* fs.makeDirectory(path.dirname(recordPath), { recursive: true });
|
|
yield* fs.writeFileString(recordPath, yield* encodeRecord(record));
|
|
}),
|
|
};
|
|
if (mode === "ensure") {
|
|
yield* Console.log(yield* encodeOutput(yield* ensureClient(operations)));
|
|
} else {
|
|
const current = yield* operations.fingerprint;
|
|
const status = clientStatus(
|
|
current,
|
|
yield* operations.installedBinary,
|
|
yield* operations.readRecord,
|
|
);
|
|
yield* Console.log(
|
|
yield* encodeOutput({
|
|
status,
|
|
fingerprint: current,
|
|
next:
|
|
status === "compatible"
|
|
? "Start Metro with vp run dev:client"
|
|
: `node scripts/mobile-native-client.ts ensure ${platform} ${device}`,
|
|
}),
|
|
);
|
|
process.exitCode = status === "compatible" ? 0 : 2;
|
|
}
|
|
}),
|
|
);
|
|
|
|
if (import.meta.main) {
|
|
Command.run(main, { version: "0.0.0" }).pipe(
|
|
Effect.scoped,
|
|
Effect.provide(NodeServices.layer),
|
|
NodeRuntime.runMain,
|
|
);
|
|
}
|