leonitousconforti/tinyburg

View on GitHub
packages/fount/src/index.ts

Summary

Maintainability
A
0 mins
Test Coverage
import * as path from "node:path";
import * as url from "node:url";

import * as Command from "@effect/platform/Command";
import * as CommandExecutor from "@effect/platform/CommandExecutor";
import * as PlatformError from "@effect/platform/Error";
import * as FileSystem from "@effect/platform/FileSystem";
import * as HttpClient from "@effect/platform/HttpClient";
import * as ParseResult from "@effect/schema/ParseResult";
import * as Schema from "@effect/schema/Schema";
import * as ReadonlyArray from "effect/Array";
import * as Effect from "effect/Effect";
import * as Function from "effect/Function";
import * as HashMap from "effect/HashMap";
import * as Match from "effect/Match";
import * as Option from "effect/Option";
import * as Stream from "effect/Stream";

import { ApksupportScrapingError, IPuppeteerDetails, getApksupportDetails } from "./puppeteer.js";
import { getSemanticVersionsByRelativeVersions, trackedVersions } from "./versions.js";

import {
    Games,
    RelativeVersion,
    SemanticVersion,
    type AllTrackedVersions,
    type AnyVersion,
    type SemanticVersionAndAppVersionCode,
    type SemanticVersionsByRelativeVersions,
    type TrackedVersion,
} from "./schemas.js";

/** @internal */
export const defaultCacheDirectory: string = url.fileURLToPath(new URL(`../downloads`, import.meta.url));

/**
 * Loads the specific version of the desired game. If the apk is not already
 * downloaded, it will download the apk and cache it in the downloads folder.
 * Following requests for the same apk will return the cached apk immediately.
 * The version does not have to match the cached version exactly, as long as
 * they resolve to the same version it will be a cache hit. For example, if
 * 4.20.0 is the most recent version of TinyTower and you make two invocations
 * one for "latest version" and one for "4.24.0", only the first invocation will
 * make a network request. The second invocation will resolve immediately with
 * the cached apk.
 */
export const loadApk = <T extends Games, U extends Extract<TrackedVersion<T>, AnyVersion>>(
    game: T,
    version: SemanticVersion | RelativeVersion | U = "latest version",
    cacheDirectory: string = defaultCacheDirectory
): Effect.Effect<
    string,
    | ParseResult.ParseError
    | PlatformError.BadArgument
    | PlatformError.SystemError
    | HttpClient.error.HttpClientError
    | ApksupportScrapingError,
    FileSystem.FileSystem
> =>
    Effect.gen(function* () {
        // Obtain resources and ensure the cache directory exists
        const fs: FileSystem.FileSystem = yield* FileSystem.FileSystem;
        yield* fs.makeDirectory(cacheDirectory, { recursive: true });
        yield* Effect.logInfo(`Using cache directory ${cacheDirectory}`);

        // Quick short circuit to avoid needing to open puppeteer
        const fileNames: readonly string[] = yield* fs.readDirectory(cacheDirectory);
        if (Schema.is(SemanticVersion)(version)) {
            const desiredApkFilename: string = `${game}_${version}.apk`;
            const maybeCachedApk: string | undefined = fileNames.find((fileName) =>
                fileName.includes(desiredApkFilename)
            );
            if (maybeCachedApk) {
                yield* Effect.logInfo(`Found ${maybeCachedApk} in cache directory via quick short circuit`);
                return path.join(cacheDirectory, maybeCachedApk);
            }
        }

        // Convert from whatever version was given to a semantic version
        const svbrv: SemanticVersionsByRelativeVersions = yield* getSemanticVersionsByRelativeVersions(game);
        const versionInfo: SemanticVersionAndAppVersionCode = Function.pipe(
            Match.value<SemanticVersion | RelativeVersion | AllTrackedVersions>(version),
            Match.when(
                (v): v is AllTrackedVersions => Object.keys(trackedVersions[game]).includes(v),
                (v) => trackedVersions[game][v as U] as SemanticVersionAndAppVersionCode
            ),
            Match.when(Schema.is(SemanticVersion), (v) =>
                svbrv.pipe(
                    HashMap.filter(({ semanticVersion }) => semanticVersion === v),
                    HashMap.values,
                    ReadonlyArray.fromIterable,
                    ReadonlyArray.head,
                    Option.getOrThrow
                )
            ),
            Match.when(Schema.is(RelativeVersion), (v) => svbrv.pipe(HashMap.get(v), Option.getOrThrow)),
            Match.exhaustive
        );
        yield* Effect.logInfo(`App version code for ${version} = ${versionInfo.appVersionCode}`);
        yield* Effect.logInfo(`Semantic version for ${version} = ${versionInfo.semanticVersion}`);

        // Check to see if the apk already exists in the cache directory
        const desiredApkFilename: string = `${game}_${versionInfo.semanticVersion}.apk`;
        const maybeCachedApk: string | undefined = fileNames.find((fileName) => fileName.includes(desiredApkFilename));
        if (maybeCachedApk) {
            yield* Effect.logInfo(`Found ${maybeCachedApk} in cache directory`);
            return path.join(cacheDirectory, maybeCachedApk);
        }

        // Not found in cache directory, need to download it
        const results: readonly [string, IPuppeteerDetails] = yield* getApksupportDetails(game, versionInfo);
        yield* Effect.logInfo(`Puppeteer scraping results: ${results[0]}`);
        if (!results[0].startsWith("https://play.googleapis.com/download/")) {
            return yield* new ApksupportScrapingError({ message: `${results[0]} is not a googleapis download url` });
        }

        // Stream the download directly to the downloads folder
        const downloadedFile: string = `${cacheDirectory}/${desiredApkFilename}`;
        const request: HttpClient.response.ClientResponse = yield* HttpClient.request
            .get(results[0])
            .pipe(HttpClient.client.fetchOk);
        yield* request.stream.pipe(Stream.run(fs.sink(downloadedFile)));
        yield* Effect.logInfo(`Successfully downloaded ${game} ${version} to ${downloadedFile}`);
        return `${cacheDirectory}/${desiredApkFilename}`;
    }).pipe(Effect.scoped);

/**
 * Loads the specific version of the desired game. If the apk is not already
 * downloaded, it will download the apk and cache it in the downloads folder.
 * Patches the apk to trust user certificates to assist with https traffic
 * inspection, adds a Network Security Configuration, and attempts to disable
 * certificate pinning.
 */
export const patchApk = <T extends Games, U extends Extract<TrackedVersion<T>, AnyVersion>>(
    game: T,
    version: SemanticVersion | RelativeVersion | U = "latest version",
    cacheDirectory: string = defaultCacheDirectory
): Effect.Effect<
    string,
    | ParseResult.ParseError
    | PlatformError.BadArgument
    | PlatformError.SystemError
    | HttpClient.error.HttpClientError
    | ApksupportScrapingError,
    FileSystem.FileSystem | CommandExecutor.CommandExecutor
> =>
    loadApk(game, version, cacheDirectory).pipe(
        Effect.andThen((apkPath) =>
            Effect.gen(function* () {
                const fs: FileSystem.FileSystem = yield* FileSystem.FileSystem;
                const commandExecutor: CommandExecutor.CommandExecutor = yield* CommandExecutor.CommandExecutor;

                const parsedPath: path.ParsedPath = path.parse(apkPath);
                const patchedApkFilename: string = path.join(
                    parsedPath.dir,
                    `${parsedPath.name}-patched${parsedPath.ext}`
                );
                const maybeCachedApk: boolean = yield* fs.exists(patchedApkFilename);
                if (maybeCachedApk) {
                    return patchedApkFilename;
                }

                const command: Command.Command = Command.make("apk-mitm", apkPath);
                const exitCode: CommandExecutor.ExitCode = yield* commandExecutor.exitCode(command);
                if (exitCode !== 0) {
                    yield* Effect.fail(
                        PlatformError.SystemError({
                            method: "",
                            reason: "Unknown",
                            module: "Command",
                            pathOrDescriptor: "apk-mitm",
                            message: `Failed to patch apk: ${apkPath}`,
                        })
                    );
                }
                return patchedApkFilename;
            })
        )
    );

export { Games } from "./schemas.js";
export default { loadApk, patchApk, Games, defaultCacheDirectory };