Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add custom fingerprinting and build cache #589

Merged
merged 5 commits into from
Oct 4, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 20 additions & 8 deletions packages/docs/docs/launch-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,15 +129,23 @@ Below is an example of how the `launch.json` file could look like with android v
Instead of letting Radon IDE build your app, you can use scripts (`buildScript` option) or [Expo
Application Services (EAS)](https://expo.dev/eas) (`eas` option) to do it.

The requirement for scripts is to output the absolute path to the built app as the
last line of the standard output.

Both `buildScript` and `eas` are objects having `ios` and `android` optional
The requirement for scripts is to output the absolute path to the built app as
the last line of the standard output. If custom fingerprint script is used, it
should output fingerprint as the last line of the standard output. When
fingerprint changes between invocations, RN IDE will rebuild the project. The
IDE runs fingerprint quite frequently (i.e., on every file save), so this
process should be fast and avoid over the network communication.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should explain the concept of fingerprint here. Specifically that it is a string that allows us to tell whether new native build needs to be run


Both `customBuild` and `eas` are objects having `ios` and `android` optional
keys. You can't specify one platform in both custom script and EAS build
options.

`buildScript.ios` and `buildScript.android` are string keys, representing custom
command used to build the app. Example below:
`customBuild.ios` and `customBuild.android` have following structure with
optional keys that can be used independently:
- `buildScript` – string, specifies a command used for building.
- `fingerprintScript` – string, specifies a command used for creating fingerprint.

Example:
```json
{
"version": "0.2.0",
Expand All @@ -146,8 +154,12 @@ command used to build the app. Example below:
"type": "radon-ide",
"request": "launch",
"name": "Radon IDE panel",
"buildScript": {
"android": "npm run build:ftp-fetch-android"
"customBuild": {
"android": { "buildScript": "npm run build:ftp-fetch-android" }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't a script, so maybe we should rename to buildCommand? On the other hand in package.json you also have scripts sections where you can put commands and not just scripts.

"ios": {
"buildScript": "npm run build:ftp-fetch-ios",
"fingerprintScript": "date '+%Y-%m-%d'"
}
}
}
]
Expand Down
32 changes: 26 additions & 6 deletions packages/vscode-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -208,17 +208,37 @@
"type": "string",
"description": "Location of Metro config relative to the workspace. This is used for using custom configs for e.g. development."
},
"buildScript": {
"customBuild": {
"type": "object",
"description": "Scripts used to build Android or iOS app or fetch them from known location. Executed as a part of building process. Should print a JSON result from `eas build` command or a filesystem path to the built app as the last line of the standard output.\nIf using EAS, it should be invoked with --json --non-interactive flags and use a profile for development, iOS additionally needs `\"ios.simulator\": true` config option in eas.json",
"description": "Configuration for using custom scripts for building and fingerprinting the app.",
"properties": {
"ios": {
"type": "string",
"description": "Script used to build iOS app."
"type": "object",
"description": "Configuration for iOS.",
"properties": {
"buildScript": {
"type": "string",
"description": "Script used to build the app. It should build the app in debug mode and print path to the built app as a last line of its standard output."
},
"fingerprintScript": {
"type": "string",
"description": "Script used to fingerprint the app. It should print workspace hash as a last line of its standard output."
}
}
},
"android": {
"type": "string",
"description": "Script used to build Android app."
"type": "object",
"description": "Configuration for Android.",
"properties": {
"buildScript": {
"type": "string",
"description": "Script used to build the app. It should build the app in debug mode and print path to the built app as a last line of its standard output."
},
"fingerprintScript": {
"type": "string",
"description": "Script used to fingerprint the app. It should print workspace hash as a last line of its standard output."
}
}
}
}
},
Expand Down
94 changes: 13 additions & 81 deletions packages/vscode-extension/src/builders/BuildManager.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,15 @@
import fs from "fs";
import { Disposable, OutputChannel, window } from "vscode";
import { Logger } from "../Logger";
import { generateWorkspaceFingerprint } from "../utilities/fingerprint";
import { PlatformBuildCache } from "./PlatformBuildCache";
import { AndroidBuildResult, buildAndroid } from "./buildAndroid";
import { IOSBuildResult, buildIos } from "./buildIOS";
import { calculateMD5 } from "../utilities/common";
import { DeviceInfo, DevicePlatform } from "../common/DeviceManager";
import { extensionContext, getAppRootFolder } from "../utilities/extensionContext";
import { getAppRootFolder } from "../utilities/extensionContext";
import { DependencyManager } from "../dependency/DependencyManager";
import { CancelToken } from "./cancelToken";

export type BuildResult = IOSBuildResult | AndroidBuildResult;

type BuildCacheInfo = {
fingerprint: string;
buildHash: string;
buildResult: AndroidBuildResult | IOSBuildResult;
};

export interface DisposableBuild<R> extends Disposable {
readonly build: Promise<R>;
}
Expand All @@ -40,22 +32,25 @@ export class BuildManager {
public startBuild(deviceInfo: DeviceInfo, options: BuildOptions): DisposableBuild<BuildResult> {
const { clean: forceCleanBuild, progressListener, onSuccess } = options;
const { platform } = deviceInfo;

const cancelToken = new CancelToken();
const buildCache = PlatformBuildCache.forPlatform(platform);

const buildApp = async () => {
let newFingerprint = await generateWorkspaceFingerprint();
const currentFingerprint = await buildCache.calculateFingerprint();
if (forceCleanBuild) {
// we reset the cache when force clean build is requested as the newly
// started build may end up being cancelled
await storeCachedBuild(platform, undefined);
await buildCache.clearCache();
} else {
const cachedBuild = await loadCachedBuild(platform, newFingerprint);
const cachedBuild = await buildCache.getBuild(currentFingerprint);
if (cachedBuild) {
return cachedBuild;
}
}

let buildResult: BuildResult;
let buildFingerprint = currentFingerprint;
if (platform === DevicePlatform.Android) {
this.buildOutputChannel = window.createOutputChannel("Radon IDE (Android build)", {
log: true,
Expand All @@ -75,10 +70,10 @@ export class BuildManager {
const podsInstalled = await this.dependencyManager.isInstalled("pods");
if (!podsInstalled) {
Logger.info("Pods installation is missing or outdated. Installing Pods.");
// installing pods may impact the fingerprint as new pods may be created under the project directory
// for this reason we need to recalculate the fingerprint after installing pods
await this.dependencyManager.installPods(cancelToken);
newFingerprint = await generateWorkspaceFingerprint();
// Installing pods may impact the fingerprint as new pods may be created under the project directory.
// For this reason we need to recalculate the fingerprint after installing pods.
buildFingerprint = await buildCache.calculateFingerprint();
}
};
buildResult = await buildIos(
Expand All @@ -91,11 +86,8 @@ export class BuildManager {
installPodsIfNeeded
);
}
await storeCachedBuild(platform, {
fingerprint: newFingerprint,
buildHash: await getAppHash(getAppPath(buildResult)),
buildResult,
});

await buildCache.storeBuild(buildFingerprint, buildResult);

return buildResult;
};
Expand All @@ -111,63 +103,3 @@ export class BuildManager {
return disposableBuild;
}
}

async function loadCachedBuild(platform: DevicePlatform, newFingerprint: string) {
const cacheInfo = getCachedBuild(platform);
const fingerprintsMatch = cacheInfo?.fingerprint === newFingerprint;
if (!fingerprintsMatch) {
Logger.info("Fingerprint mismatch, cannot use cached build.");
return undefined;
}

const build = cacheInfo.buildResult;
const appPath = getAppPath(build);
try {
const builtAppExists = fs.existsSync(appPath);
if (!builtAppExists) {
Logger.info("Couldn't use cached build. App artifact not found.");
return undefined;
}

const hash = await getAppHash(appPath);
const hashesMatch = hash === cacheInfo.buildHash;
if (hashesMatch) {
Logger.info("Using cached build.");
return build;
}
} catch (e) {
// we only log the error and ignore it to allow new build to start
Logger.error("Error while attempting to load cached build", e);
return undefined;
}
}

export async function didFingerprintChange(platform: DevicePlatform) {
const newFingerprint = await generateWorkspaceFingerprint();
const { fingerprint } = getCachedBuild(platform) ?? {};

return newFingerprint !== fingerprint;
}

async function getAppHash(appPath: string) {
return (await calculateMD5(appPath)).digest("hex");
}

function getAppPath(build: BuildResult) {
return build.platform === DevicePlatform.Android ? build.apkPath : build.appPath;
}

const ANDROID_BUILD_CACHE_KEY = "android_build_cache";
const IOS_BUILD_CACHE_KEY = "ios_build_cache";

async function storeCachedBuild(platform: DevicePlatform, build: BuildCacheInfo | undefined) {
const cacheKey =
platform === DevicePlatform.Android ? ANDROID_BUILD_CACHE_KEY : IOS_BUILD_CACHE_KEY;
await extensionContext.workspaceState.update(cacheKey, build);
}

function getCachedBuild(platform: DevicePlatform) {
const cacheKey =
platform === DevicePlatform.Android ? ANDROID_BUILD_CACHE_KEY : IOS_BUILD_CACHE_KEY;
return extensionContext.workspaceState.get<BuildCacheInfo>(cacheKey);
}
161 changes: 161 additions & 0 deletions packages/vscode-extension/src/builders/PlatformBuildCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import path from "path";
import fs from "fs";
import { createFingerprintAsync } from "@expo/fingerprint";
import { Logger } from "../Logger";
import { extensionContext, getAppRootFolder } from "../utilities/extensionContext";
import { DevicePlatform } from "../common/DeviceManager";
import { IOSBuildResult } from "./buildIOS";
import { AndroidBuildResult } from "./buildAndroid";
import { getLaunchConfiguration } from "../utilities/launchConfiguration";
import { runFingerprintScript } from "./customBuild";
import { CancelToken } from "./cancelToken";
import { calculateMD5 } from "../utilities/common";
import { BuildResult } from "./BuildManager";

const ANDROID_BUILD_CACHE_KEY = "android_build_cache";
const IOS_BUILD_CACHE_KEY = "ios_build_cache";

const IGNORE_PATHS = [
path.join("android", ".gradle/**/*"),
path.join("android", "build/**/*"),
path.join("android", "app", "build/**/*"),
path.join("ios", "build/**/*"),
"**/node_modules/**/android/.cxx/**/*",
"**/node_modules/**/.gradle/**/*",
"**/node_modules/**/android/build/intermediates/cxx/**/*",
];

export type BuildCacheInfo = {
fingerprint: string;
buildHash: string;
buildResult: AndroidBuildResult | IOSBuildResult;
};

export class PlatformBuildCache {
static instances: Record<DevicePlatform, PlatformBuildCache | undefined> = {
[DevicePlatform.Android]: undefined,
[DevicePlatform.IOS]: undefined,
};

static forPlatform(platform: DevicePlatform): PlatformBuildCache {
if (!this.instances[platform]) {
this.instances[platform] = new PlatformBuildCache(platform);
}

return this.instances[platform];
}

private constructor(private readonly platform: DevicePlatform) {}

get cacheKey() {
return this.platform === DevicePlatform.Android ? ANDROID_BUILD_CACHE_KEY : IOS_BUILD_CACHE_KEY;
}

/**
* Passed fingerprint should be calculated at the time build is started.
*/
public async storeBuild(buildFingerprint: string, build: BuildResult) {
const appPath = await getAppHash(getAppPath(build));
await extensionContext.workspaceState.update(this.cacheKey, {
fingerprint: buildFingerprint,
buildHash: appPath,
buildResult: build,
});
}

public async clearCache() {
await extensionContext.workspaceState.update(this.cacheKey, undefined);
}

public async getBuild(currentFingerprint: string) {
const cache = extensionContext.workspaceState.get<BuildCacheInfo>(this.cacheKey);
if (!cache) {
Logger.debug("No cached build found.");
return undefined;
}

const fingerprintsMatch = cache.fingerprint === currentFingerprint;
if (!fingerprintsMatch) {
Logger.info(
`Fingerprint mismatch, cannot use cached build. Old: '${cache.fingerprint}', new: '${currentFingerprint}'.`
);
return undefined;
}

const build = cache.buildResult;
const appPath = getAppPath(build);
try {
const builtAppExists = fs.existsSync(appPath);
if (!builtAppExists) {
Logger.info("Couldn't use cached build. App artifact not found.");
return undefined;
}

const appHash = await getAppHash(appPath);
const hashesMatch = appHash === cache.buildHash;
if (hashesMatch) {
Logger.info("Using cached build.");
return build;
}
} catch (e) {
// we only log the error and ignore it to allow new build to start
Logger.error("Error while attempting to load cached build: ", e);
return undefined;
}
}

public async isCacheStale() {
const currentFingerprint = await this.calculateFingerprint();
const { fingerprint } =
extensionContext.workspaceState.get<BuildCacheInfo>(this.cacheKey) ?? {};

return currentFingerprint !== fingerprint;
}

public async calculateFingerprint() {
const customFingerprint = await this.calculateCustomFingerprint();

if (customFingerprint) {
return customFingerprint;
}

const fingerprint = await createFingerprintAsync(getAppRootFolder(), {
ignorePaths: IGNORE_PATHS,
});
Logger.log(`Workspace fingerprint: '${fingerprint.hash}'`);
return fingerprint.hash;
}

private async calculateCustomFingerprint() {
const { customBuild, env } = getLaunchConfiguration();
const configPlatform = (
{
[DevicePlatform.Android]: "android",
[DevicePlatform.IOS]: "ios",
} as const
)[this.platform];
const fingerprintScript = customBuild?.[configPlatform]?.fingerprintScript;

if (!fingerprintScript) {
return undefined;
}

Logger.log(`Using custom fingerprint script '${fingerprintScript}'`);
const fingerprint = await runFingerprintScript(fingerprintScript, env);

if (!fingerprint) {
throw new Error("Failed to generate workspace fingerprint using custom script.");
}

Logger.log("Workspace fingerprint", fingerprint);
return fingerprint;
}
}

function getAppPath(build: BuildResult) {
return build.platform === DevicePlatform.Android ? build.apkPath : build.appPath;
}

async function getAppHash(appPath: string) {
return (await calculateMD5(appPath)).digest("hex");
}
Loading
Loading