1
0
mirror of https://gitcode.com/gh_mirrors/re/react-native-pushy.git synced 2025-09-18 21:10:40 +08:00
Code Issues Packages Projects Releases Wiki Activity GitHub Gitee
This commit is contained in:
sunnylqm
2024-01-18 00:22:17 +08:00
parent 7229f8847a
commit 9d93faab31
18 changed files with 2593 additions and 1200 deletions

311
src/client.tsx Normal file
View File

@@ -0,0 +1,311 @@
import { CheckResult, PushyOptions, ProgressData } from './type';
import { assertRelease, log } from './utils';
import {
EmitterSubscription,
PermissionsAndroid,
Platform,
} from 'react-native';
import {
PushyModule,
buildTime,
cInfo,
pushyNativeEventEmitter,
currentVersion,
packageVersion,
report,
rolledBackVersion,
setLocalHashInfo,
} from './core';
const defaultServer = {
main: 'https://update.react-native.cn/api',
backups: ['https://update.reactnative.cn/api'],
queryUrl:
'https://raw.githubusercontent.com/reactnativecn/react-native-pushy/master/endpoints.json',
};
const empty = {};
export class Pushy {
options: PushyOptions = {
appKey: '',
server: defaultServer,
autoMarkSuccess: true,
useAlert: true,
};
lastChecking: number;
lastResult: CheckResult;
progressHandlers: Record<string, EmitterSubscription> = {};
downloadedHash: string;
marked = false;
applyingUpdate = false;
constructor(options: PushyOptions) {
if (!options.appKey) {
throw new Error('appKey is required for withUpdates()');
}
for (const [key, value] of Object.entries(options)) {
if (value !== undefined) {
this.options[key] = value;
}
}
}
getCheckUrl = (endpoint: string = this.options.server!.main) => {
return `${endpoint}/checkUpdate/${this.options.appKey}`;
};
assertHash = (hash: string) => {
if (!this.downloadedHash) {
return;
}
if (hash !== this.downloadedHash) {
log(`use downloaded hash ${this.downloadedHash} first`);
return;
}
return true;
};
markSuccess = () => {
assertRelease();
if (this.marked) {
return;
}
this.marked = true;
PushyModule.markSuccess();
report({ type: 'markSuccess' });
};
switchVersion = (hash: string) => {
assertRelease();
if (this.assertHash(hash) && !this.applyingUpdate) {
log('switchVersion: ' + hash);
this.applyingUpdate = true;
PushyModule.reloadUpdate({ hash });
}
};
switchVersionLater = (hash: string) => {
assertRelease();
if (this.assertHash(hash)) {
log('switchVersionLater: ' + hash);
PushyModule.setNeedUpdate({ hash });
}
};
checkUpdate = async () => {
assertRelease();
const now = Date.now();
if (
this.lastResult &&
this.lastChecking &&
now - this.lastChecking < 1000 * 5
) {
return this.lastResult;
}
this.lastChecking = now;
report({ type: 'checking' });
const fetchPayload = {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
packageVersion,
hash: currentVersion,
buildTime,
cInfo,
}),
};
let resp;
try {
resp = await fetch(this.getCheckUrl(), fetchPayload);
} catch (e) {
report({
type: 'errorChecking',
message: 'Can not connect to update server. Trying backup endpoints.',
});
const backupEndpoints = await this.getBackupEndpoints();
if (backupEndpoints) {
try {
resp = await Promise.race(
backupEndpoints.map((endpoint) =>
fetch(this.getCheckUrl(endpoint), fetchPayload),
),
);
} catch {}
}
}
if (!resp) {
report({
type: 'errorChecking',
message: 'Can not connect to update server. Please check your network.',
});
return this.lastResult || empty;
}
const result: CheckResult = await resp.json();
this.lastResult = result;
if (resp.status !== 200) {
report({
type: 'errorChecking',
//@ts-ignore
message: result.message,
});
}
return result;
};
getBackupEndpoints = async () => {
const { server } = this.options;
if (!server) {
return [];
}
if (server.queryUrl) {
try {
const resp = await fetch(server.queryUrl);
const remoteEndpoints = await resp.json();
log('fetch endpoints:', remoteEndpoints);
if (Array.isArray(remoteEndpoints)) {
server.backups = Array.from(
new Set([...(server.backups || []), ...remoteEndpoints]),
);
}
} catch (e) {
log('failed to fetch endpoints from: ', server.queryUrl);
}
}
return server.backups;
};
downloadUpdate = async (
info: CheckResult,
onDownloadProgress?: (data: ProgressData) => void,
) => {
assertRelease();
if (!('update' in info)) {
return;
}
const { hash, diffUrl, pdiffUrl, updateUrl, name, description, metaInfo } =
info;
if (rolledBackVersion === hash) {
log(`rolledback hash ${rolledBackVersion}, ignored`);
return;
}
if (this.downloadedHash === hash) {
log(`duplicated downloaded hash ${this.downloadedHash}, ignored`);
return this.downloadedHash;
}
if (this.progressHandlers[hash]) {
return;
}
if (onDownloadProgress) {
this.progressHandlers[hash] = pushyNativeEventEmitter.addListener(
'RCTPushyDownloadProgress',
(progressData) => {
if (progressData.hash === hash) {
onDownloadProgress(progressData);
}
},
);
}
let succeeded = false;
report({ type: 'downloading' });
if (diffUrl) {
log('downloading diff');
try {
await PushyModule.downloadPatchFromPpk({
updateUrl: diffUrl,
hash,
originHash: currentVersion,
});
succeeded = true;
} catch (e) {
log(`diff error: ${e.message}, try pdiff`);
}
}
if (!succeeded && pdiffUrl) {
log('downloading pdiff');
try {
await PushyModule.downloadPatchFromPackage({
updateUrl: pdiffUrl,
hash,
});
succeeded = true;
} catch (e) {
log(`pdiff error: ${e.message}, try full patch`);
}
}
if (!succeeded && updateUrl) {
log('downloading full patch');
try {
await PushyModule.downloadFullUpdate({
updateUrl: updateUrl,
hash,
});
succeeded = true;
} catch (e) {
log(`full patch error: ${e.message}`);
}
}
if (this.progressHandlers[hash]) {
this.progressHandlers[hash].remove();
delete this.progressHandlers[hash];
}
if (!succeeded) {
return report({
type: 'errorUpdate',
data: { newVersion: hash },
});
}
setLocalHashInfo(hash, {
name,
description,
metaInfo,
});
this.downloadedHash = hash;
return hash;
};
downloadAndInstallApk = async (
url: string,
onDownloadProgress?: (data: ProgressData) => void,
) => {
if (Platform.OS !== 'android') {
return;
}
report({ type: 'downloadingApk' });
if (Platform.Version <= 23) {
try {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE,
);
if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
return report({ type: 'rejectStoragePermission' });
}
} catch (err) {
return report({ type: 'errorStoragePermission' });
}
}
const progressKey = 'downloadingApk';
if (onDownloadProgress) {
this.progressHandlers[progressKey] = pushyNativeEventEmitter.addListener(
'RCTPushyDownloadProgress',
(progressData: ProgressData) => {
if (progressData.hash === progressKey) {
onDownloadProgress(progressData);
}
},
);
}
await PushyModule.downloadAndInstallApk({
url,
target: 'update.apk',
hash: progressKey,
}).catch(() => {
report({ type: 'errowDownloadAndInstallApk' });
});
if (this.progressHandlers[progressKey]) {
this.progressHandlers[progressKey].remove();
delete this.progressHandlers[progressKey];
}
};
}

26
src/context.ts Normal file
View File

@@ -0,0 +1,26 @@
import { createContext, useContext } from 'react';
import { CheckResult, ProgressData } from './type';
const empty = {};
const noop = () => {};
export const defaultContext = {
checkUpdate: () => Promise.resolve(empty),
switchVersion: noop,
switchVersionLater: noop,
markSuccess: noop,
dismissError: noop,
};
export const PushyContext = createContext<{
checkUpdate: () => void;
switchVersion: () => void;
switchVersionLater: () => void;
progress?: ProgressData;
markSuccess: () => void;
updateInfo?: CheckResult;
lastError?: Error;
dismissError: () => void;
}>(defaultContext);
export const usePushy = () => useContext(PushyContext);

106
src/core.ts Normal file
View File

@@ -0,0 +1,106 @@
import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
import { EventType, UpdateEventsLogger } from './type';
import { log } from './utils';
const {
version: v,
} = require('react-native/Libraries/Core/ReactNativeVersion');
const RNVersion = `${v.major}.${v.minor}.${v.patch}`;
const isTurboModuleEnabled = global.__turboModuleProxy != null;
export const PushyModule = isTurboModuleEnabled
? require('./turboModuleSpec').default
: NativeModules.Pushy;
if (!PushyModule) {
throw new Error('react-native-update模块无法加载请对照安装文档检查配置。');
}
const PushyConstants = isTurboModuleEnabled
? PushyModule.getConstants()
: PushyModule;
export const downloadRootDir = PushyConstants.downloadRootDir;
export const packageVersion = PushyConstants.packageVersion;
export const currentVersion = PushyConstants.currentVersion;
export const isFirstTime = PushyConstants.isFirstTime;
export const rolledBackVersion = PushyConstants.rolledBackVersion;
export const isRolledBack = typeof rolledBackVersion === 'string';
export const buildTime = PushyConstants.buildTime;
let uuid = PushyConstants.uuid;
if (Platform.OS === 'android' && !PushyConstants.isUsingBundleUrl) {
throw new Error(
'react-native-update模块无法加载请对照文档检查Bundle URL的配置',
);
}
export function setLocalHashInfo(hash: string, info: Record<string, any>) {
PushyModule.setLocalHashInfo(hash, JSON.stringify(info));
}
async function getLocalHashInfo(hash: string) {
return JSON.parse(await PushyModule.getLocalHashInfo(hash));
}
export async function getCurrentVersionInfo(): Promise<{
name?: string;
description?: string;
metaInfo?: string;
}> {
return currentVersion ? (await getLocalHashInfo(currentVersion)) || {} : {};
}
export const pushyNativeEventEmitter = new NativeEventEmitter(PushyModule);
if (!uuid) {
uuid = require('nanoid/non-secure').nanoid();
PushyModule.setUuid(uuid);
}
const noop = () => {};
let reporter: UpdateEventsLogger = noop;
export function onPushyEvents(customReporter: UpdateEventsLogger) {
reporter = customReporter;
if (isRolledBack) {
report({
type: 'rollback',
data: {
rolledBackVersion,
},
});
}
}
export function report({
type,
message = '',
data = {},
}: {
type: EventType;
message?: string;
data?: Record<string, string | number>;
}) {
log(type + ' ' + message);
reporter({
type,
data: {
currentVersion,
cInfo,
packageVersion,
buildTime,
message,
...data,
},
});
}
log('uuid: ' + uuid);
export const cInfo = {
pushy: require('../package.json').version,
rn: RNVersion,
os: Platform.OS + ' ' + Platform.Version,
uuid,
};

3
src/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export { Pushy } from './client';
export { PushyContext, usePushy } from './context';
export { PushyProvider } from './provider';

17
src/index.web.js Normal file
View File

@@ -0,0 +1,17 @@
import { Fragment } from 'react';
const noop = () => {};
export class Pushy {
constructor() {
console.warn('react-native-update is not supported and will do nothing on web.');
return new Proxy(this, {
get() {
return noop;
},
});
}
}
export { PushyContext, usePushy } from './context';
export const PushyProvider = Fragment;

189
src/provider.tsx Normal file
View File

@@ -0,0 +1,189 @@
import React, {
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import {
Alert,
NativeEventSubscription,
AppState,
Platform,
Linking,
} from 'react-native';
import { Pushy } from './client';
import { isFirstTime } from './core';
import { UpdateAvailableResult, CheckResult } from './type';
import { PushyContext } from './context';
export const PushyProvider = ({
client,
children,
}: {
client: Pushy;
children: ReactNode;
}) => {
const { strategy, useAlert } = client.options;
const stateListener = useRef<NativeEventSubscription>();
const [updateInfo, setUpdateInfo] = useState<CheckResult>();
const [lastError, setLastError] = useState<Error>();
const dismissError = useCallback(() => {
if (lastError) {
setLastError(undefined);
}
}, [lastError]);
const showAlert = useCallback(
(...args: Parameters<typeof Alert.alert>) => {
if (useAlert) {
Alert.alert(...args);
}
},
[useAlert],
);
const switchVersion = useCallback(() => {
if (updateInfo && 'hash' in updateInfo) {
client.switchVersion(updateInfo.hash);
}
}, [client, updateInfo]);
const switchVersionLater = useCallback(() => {
if (updateInfo && 'hash' in updateInfo) {
client.switchVersionLater(updateInfo.hash);
}
}, [client, updateInfo]);
const doUpdate = useCallback(
async (info: UpdateAvailableResult) => {
try {
const hash = await client.downloadUpdate(info);
if (!hash) {
return;
}
setUpdateInfo(info);
stateListener.current && stateListener.current.remove();
showAlert('Download complete', 'Do you want to apply the update now?', [
{
text: 'Later',
style: 'cancel',
onPress: () => {
client.switchVersionLater(hash);
},
},
{
text: 'Now',
style: 'default',
onPress: () => {
client.switchVersion(hash);
},
},
]);
} catch (err) {
setLastError(err);
showAlert('Failed to update', err.message);
}
},
[client, showAlert],
);
const checkUpdate = useCallback(async () => {
let info: CheckResult;
try {
info = await client.checkUpdate();
} catch (err) {
setLastError(err);
showAlert('Failed to check update', err.message);
return;
}
if ('expired' in info) {
const { downloadUrl } = info;
setUpdateInfo(info);
showAlert(
'Major update',
'A full update is required to download and install to continue.',
[
{
text: 'OK',
onPress: () => {
if (downloadUrl) {
if (Platform.OS === 'android' && downloadUrl.endsWith('.apk')) {
client.downloadAndInstallApk(downloadUrl);
} else {
Linking.openURL(downloadUrl);
}
}
},
},
],
);
} else if ('update' in info) {
showAlert(
`Version ${info.name} available`,
`What's new\n
${info.description}
`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'OK',
style: 'default',
onPress: () => {
doUpdate(info as UpdateAvailableResult);
},
},
],
);
}
}, [client, doUpdate, showAlert]);
const markSuccess = client.markSuccess;
useEffect(() => {
if (isFirstTime) {
markSuccess();
}
if (strategy === 'both' || strategy === 'onAppResume') {
stateListener.current = AppState.addEventListener(
'change',
(nextAppState) => {
if (nextAppState === 'active') {
checkUpdate();
}
},
);
}
if (strategy === 'both' || strategy === 'onAppStart') {
checkUpdate();
}
let dismissErrorTimer: ReturnType<typeof setTimeout>;
const { dismissErrorAfter } = client.options;
if (typeof dismissErrorAfter === 'number' && dismissErrorAfter > 0) {
dismissErrorTimer = setTimeout(() => {
dismissError();
}, dismissErrorAfter);
}
return () => {
stateListener.current && stateListener.current.remove();
clearTimeout(dismissErrorTimer);
};
}, [checkUpdate, client.options, dismissError, markSuccess, strategy]);
return (
<PushyContext.Provider
value={{
checkUpdate,
switchVersion,
switchVersionLater,
dismissError,
updateInfo,
lastError,
markSuccess,
}}
>
{children}
</PushyContext.Provider>
);
};

43
src/turboModuleSpec.ts Normal file
View File

@@ -0,0 +1,43 @@
import type { TurboModule } from 'react-native/Libraries/TurboModule/RCTExport';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
getConstants: () => {
downloadRootDir: string;
packageVersion: string;
currentVersion: string;
isFirstTime: boolean;
rolledBackVersion: string;
buildTime: string;
uuid: string;
isUsingBundleUrl: boolean;
};
setLocalHashInfo(hash: string, info: string): Promise<void>;
getLocalHashInfo(hash: string): Promise<string>;
setUuid(uuid: string): Promise<void>;
reloadUpdate(options: { hash: string }): Promise<void>;
setNeedUpdate(options: { hash: string }): Promise<void>;
markSuccess(): Promise<void>;
downloadPatchFromPpk(options: {
updateUrl: string;
hash: string;
originHash: string;
}): Promise<void>;
downloadPatchFromPackage(options: {
updateUrl: string;
hash: string;
}): Promise<void>;
downloadFullUpdate(options: {
updateUrl: string;
hash: string;
}): Promise<void>;
downloadAndInstallApk(options: {
url: string;
target: string;
hash: string;
}): Promise<void>;
addListener(eventName: string): void;
removeListeners(count: number): void;
}
export default TurboModuleRegistry.get<Spec>('Pushy') as Spec | null;

83
src/type.ts Normal file
View File

@@ -0,0 +1,83 @@
export interface ExpiredResult {
expired: true;
downloadUrl: string;
}
export interface UpTodateResult {
upToDate: true;
paused?: 'app' | 'package';
}
export interface UpdateAvailableResult {
upToDate: false;
update: true;
name: string; // version name
hash: string;
description: string;
metaInfo: string;
pdiffUrl: string;
diffUrl?: string;
updateUrl?: string;
}
export type CheckResult =
| ExpiredResult
| UpTodateResult
| UpdateAvailableResult
| {};
export interface ProgressData {
hash: string;
received: number;
total: number;
}
export type EventType =
| 'rollback'
| 'errorChecking'
| 'checking'
| 'downloading'
| 'errorUpdate'
| 'markSuccess'
| 'downloadingApk'
| 'rejectStoragePermission'
| 'errorStoragePermission'
| 'errowDownloadAndInstallApk';
export interface EventData {
currentVersion: string;
cInfo: {
pushy: string;
rn: string;
os: string;
uuid: string;
};
packageVersion: string;
buildTime: number;
message?: string;
rolledBackVersion?: string;
newVersion?: string;
[key: string]: any;
}
export type UpdateEventsLogger = ({
type,
data,
}: {
type: EventType;
data: EventData;
}) => void;
export interface PushyServerConfig {
main: string;
backups?: string[];
queryUrl?: string;
}
export interface PushyOptions {
appKey: string;
server?: PushyServerConfig;
logger?: UpdateEventsLogger;
useAlert?: boolean;
strategy?: 'onAppStart' | 'onAppResume' | 'both';
autoMarkSuccess?: boolean;
dismissErrorAfter?: number;
}

9
src/utils.ts Normal file
View File

@@ -0,0 +1,9 @@
export function log(...args: any[]) {
console.log('pushy: ', ...args);
}
export function assertRelease() {
if (__DEV__) {
throw new Error('react-native-update 只能在 RELEASE 版本中运行.');
}
}