@fancode/react-native-codepush-joystick
Version:
A flexible CodePush Joystick for React Native apps
348 lines • 14.3 kB
JavaScript
import CodePush from "react-native-code-push";
import { CodePushStatus, } from "./types";
import { GitHubProvider } from "./providers/github-provider";
import { formatDateTime } from "./utils";
import { BackHandler, Platform } from "react-native";
export class CodePushManager {
stateMap = new Map();
config;
callbacks;
githubSourceControlProvider;
cicdProvider;
constructor(config) {
this.config = config;
this.callbacks = config.callbacks || {};
this.githubSourceControlProvider = new GitHubProvider(config.sourceControl.config);
this.cicdProvider = config.cicdProvider;
}
async fetchPullRequests(options) {
try {
const pullRequests = await this.githubSourceControlProvider.fetchPullRequests(options);
pullRequests.forEach((pr) => {
if (!this.stateMap.has(pr.id)) {
this.setStateAndNotify(pr, {
status: CodePushStatus.NOT_CHECKED,
loading: false,
progress: null,
message: null,
buildInfo: null,
remotePackage: null,
branchName: pr.head?.ref || null,
targetVersion: this.calculateTargetVersion(pr),
});
}
});
this.callbacks?.onPullRequestsFetched?.(pullRequests);
return pullRequests;
}
catch (error) {
this.handleError(error, "fetchPullRequests");
throw error;
}
}
async checkCodePushUpdate(pullRequest) {
const state = this.getState(pullRequest.id);
try {
this.setStateAndNotify(pullRequest, { loading: true, message: null });
if (!state.targetVersion)
throw new Error("Unable to proceed, please restart app!");
CodePush.overrideAppVersion(state.targetVersion);
const remotePackage = await CodePush.checkForUpdate(this.config.codepush.deploymentKey, undefined, state.targetVersion);
if (!remotePackage) {
this.setStateAndNotify(pullRequest, {
status: CodePushStatus.NOT_RUNNING,
message: "CodePush not found for this branch, checking for running GitHub builds!",
});
await this.checkGitHubWorkflows(pullRequest);
}
else if (remotePackage.appVersion === state.targetVersion) {
this.setStateAndNotify(pullRequest, {
status: CodePushStatus.AVAILABLE,
remotePackage,
});
this.callbacks?.onCodePushAvailable?.(pullRequest, remotePackage);
}
else {
this.setStateAndNotify(pullRequest, {
status: CodePushStatus.ERROR,
message: "There is mismatch, please re-launch the app!",
});
}
this.setStateAndNotify(pullRequest, { loading: false });
return this.getState(pullRequest.id);
}
catch (error) {
this.setStateAndNotify(pullRequest, {
status: CodePushStatus.ERROR,
loading: false,
message: error.message || "Error checking for updates",
});
this.handleError(error, "checkCodePushUpdate", { pullRequest });
throw error;
}
}
async checkGitHubWorkflows(pullRequest) {
if (!pullRequest.head?.ref) {
this.setStateAndNotify(pullRequest, {
loading: false,
message: "No branch reference found for this PR.",
});
return;
}
try {
const workflowRuns = await this.cicdProvider.getWorkflowRuns(pullRequest.head.ref);
const { message, newCPState, buildInfo } = this.findWorkflowStatus(workflowRuns);
const dataToBeUpdated = {
status: newCPState,
buildInfo,
loading: false,
};
if (message)
dataToBeUpdated.message = message;
this.setStateAndNotify(pullRequest, dataToBeUpdated);
}
catch (error) {
this.setStateAndNotify(pullRequest, {
loading: false,
message: "Error checking GitHub workflows.",
});
this.handleError(error, "checkGitHubWorkflows", { pullRequest });
}
}
findWorkflowStatus(workflowRuns) {
let newCPState = CodePushStatus.NOT_RUNNING;
let message = "No active running build found. Please trigger a new build.";
const workflowRun = workflowRuns[0] || null;
const { buildInfo, workflowStatus } = this.cicdProvider.findWorkflowStatus(workflowRun) || {
buildInfo: null,
workflowStatus: null,
};
if (workflowStatus) {
const formattedTime = formatDateTime(workflowStatus.startedAt);
if (workflowStatus.isRunning) {
newCPState = CodePushStatus.ALREADY_RUNNING;
message = `Build #${workflowStatus.id} triggered on ${formattedTime} is running.`;
}
else if (workflowStatus.isFailed) {
newCPState = CodePushStatus.NOT_RUNNING;
message = `Build #${workflowStatus.id} failed. Triggered on ${formattedTime}`;
}
else if (workflowStatus.isCancelled) {
newCPState = CodePushStatus.NOT_RUNNING;
message = `Build #${workflowStatus.id} was cancelled. Triggered on ${formattedTime}`;
}
else if (workflowStatus.isCompleted) {
newCPState = CodePushStatus.NOT_RUNNING;
message = `Build #${workflowStatus.id} completed. Triggered on ${formattedTime}`;
}
}
return {
message,
newCPState,
buildInfo,
};
}
toASCII(str) {
return str.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0);
}
async downloadCodePushUpdate(pullRequest) {
const state = this.getState(pullRequest.id);
try {
this.setStateAndNotify(pullRequest, { loading: true, progress: 0 });
if (!state.remotePackage)
throw new Error("No remote package available");
this.callbacks?.onDownloadStarted?.(pullRequest, state.remotePackage);
const localPackage = await state.remotePackage.download((progress) => {
const percent = Math.round((progress.receivedBytes / progress.totalBytes) * 100);
this.setStateAndNotify(pullRequest, { progress: percent });
this.callbacks?.onDownloadProgress?.(pullRequest, percent);
});
await localPackage.install(CodePush.InstallMode.ON_NEXT_RESTART);
this.callbacks?.onDownloadComplete?.(pullRequest, localPackage);
this.setStateAndNotify(pullRequest, {
status: CodePushStatus.DOWNLOADED,
progress: null,
loading: false,
});
return this.getState(pullRequest.id);
}
catch (error) {
this.setStateAndNotify(pullRequest, {
status: CodePushStatus.ERROR,
progress: null,
loading: false,
message: error.message || "Error downloading update",
});
this.handleError(error, "downloadCodePushUpdate", { pullRequest });
throw error;
}
}
async triggerBuild(pullRequest) {
const state = this.getState(pullRequest.id);
try {
this.setStateAndNotify(pullRequest, { loading: true });
if (!state.branchName || !state.targetVersion)
throw new Error("Missing branch name or target version");
const params = {
branch: state.branchName,
version: state.targetVersion,
};
const buildInfo = await this.cicdProvider.triggerBuild(params);
await new Promise((resolve) => setTimeout(resolve, 5000));
this.setStateAndNotify(pullRequest, {
buildInfo,
status: CodePushStatus.NOT_CHECKED,
message: "CodePush has been triggered",
loading: false,
});
this.callbacks?.onBuildTriggered?.(pullRequest, buildInfo);
return buildInfo;
}
catch (error) {
this.setStateAndNotify(pullRequest, { loading: false });
this.handleError(error, "triggerBuild", { pullRequest });
throw error;
}
}
async cancelBuild(pullRequest) {
const state = this.getState(pullRequest.id);
try {
this.setStateAndNotify(pullRequest, { loading: true });
if (!state.buildInfo?.buildId)
throw new Error("No build to cancel");
await this.cicdProvider.cancelBuild(state.buildInfo.buildId);
this.setStateAndNotify(pullRequest, {
status: CodePushStatus.NOT_CHECKED,
message: "CodePush is stopped now!",
loading: false,
});
}
catch (error) {
this.setStateAndNotify(pullRequest, {
status: CodePushStatus.ERROR,
message: "Unable to stop the CodePush, please retry!",
loading: false,
});
this.handleError(error, "cancelBuild", { pullRequest });
throw error;
}
}
async restartApp() {
try {
if (Platform.OS === "android") {
BackHandler.exitApp();
}
else {
// On iOS, closing the app is not allowed.
console.warn("Programmatic app exit is not supported on iOS.");
}
}
catch (error) {
this.handleError(error, "restartApp");
throw error;
}
}
async processWorkflow(pullRequest) {
const state = this.getState(pullRequest.id);
try {
switch (state.status) {
case CodePushStatus.NOT_CHECKED:
case CodePushStatus.UN_AVAILABLE:
case CodePushStatus.ERROR:
return await this.checkCodePushUpdate(pullRequest);
case CodePushStatus.AVAILABLE:
return await this.downloadCodePushUpdate(pullRequest);
case CodePushStatus.DOWNLOADED:
await this.restartApp();
return this.getState(pullRequest.id);
case CodePushStatus.ALREADY_RUNNING:
await this.cancelBuild(pullRequest);
return this.getState(pullRequest.id);
case CodePushStatus.NOT_RUNNING:
await this.triggerBuild(pullRequest);
return this.getState(pullRequest.id);
default:
this.setStateAndNotify(pullRequest, {
loading: false,
message: "Unknown state, please try again",
});
return this.getState(pullRequest.id);
}
}
catch (error) {
this.setStateAndNotify(pullRequest, {
status: CodePushStatus.ERROR,
loading: false,
message: error.message || "An error occurred",
});
this.handleError(error, "processWorkflow", { pullRequest });
throw error;
}
}
getState(pullRequestId) {
return (this.stateMap.get(pullRequestId) || {
status: CodePushStatus.NOT_CHECKED,
loading: false,
progress: null,
message: null,
buildInfo: null,
remotePackage: null,
branchName: null,
targetVersion: null,
});
}
setStateAndNotify(pullRequest, updates) {
const prevState = this.getState(pullRequest.id);
const newState = { ...prevState, ...updates };
this.stateMap.set(pullRequest.id, newState);
this.notifyStateChange(pullRequest, newState, prevState);
}
calculateTargetVersion(pullRequest) {
if (this.config.versioning?.strategy === "custom" &&
this.config.versioning.customCalculator) {
return this.config.versioning.customCalculator(pullRequest, this.config.appVersion);
}
const versionParts = this.config.appVersion.split(".");
const lastPartIndex = versionParts.length - 1;
if (lastPartIndex >= 0 && pullRequest.head?.ref) {
const commitId = pullRequest.statuses_url.split("/").pop()?.substring(0, 5) || "";
const commitNumber = this.toASCII(commitId);
versionParts[lastPartIndex] = String(Number(versionParts[lastPartIndex]) + pullRequest.number + commitNumber);
return versionParts.join(".");
}
return null;
}
formatVersionForStorage(localPackage) {
let version = localPackage.label.replace("v", "");
if (localPackage.description) {
version += "/" + localPackage.description;
}
return version;
}
notifyStateChange(pullRequest, newState, oldState) {
this.callbacks?.onStateChange?.(pullRequest, newState, oldState);
}
handleError(error, context, metadata) {
this.callbacks?.onError?.(error, context, metadata);
}
async getAppUpdateMetadata() {
let updateMetadata = null;
try {
updateMetadata = await CodePush.getUpdateMetadata();
}
catch (e) { }
return { updateMetadata };
}
setCallbacks(callbacks) {
this.callbacks = callbacks || {};
}
resetState(pullRequestId) {
if (pullRequestId !== undefined) {
this.stateMap.delete(pullRequestId);
}
else {
this.stateMap.clear();
}
}
}
//# sourceMappingURL=codepush-manager.js.map