relay-ota-react-native 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +376 -0
- package/dist/client.d.ts +3 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +16 -0
- package/dist/client.js.map +1 -0
- package/dist/downloader.d.ts +6 -0
- package/dist/downloader.d.ts.map +1 -0
- package/dist/downloader.js +65 -0
- package/dist/downloader.js.map +1 -0
- package/dist/filesystem.d.ts +4 -0
- package/dist/filesystem.d.ts.map +1 -0
- package/dist/filesystem.js +63 -0
- package/dist/filesystem.js.map +1 -0
- package/dist/hooks.d.ts +15 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +34 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/installer.d.ts +6 -0
- package/dist/installer.d.ts.map +1 -0
- package/dist/installer.js +46 -0
- package/dist/installer.js.map +1 -0
- package/dist/provider.d.ts +18 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +82 -0
- package/dist/provider.js.map +1 -0
- package/dist/storage.d.ts +12 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +44 -0
- package/dist/storage.js.map +1 -0
- package/dist/types.d.ts +55 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/updater.d.ts +17 -0
- package/dist/updater.d.ts.map +1 -0
- package/dist/updater.js +98 -0
- package/dist/updater.js.map +1 -0
- package/package.json +60 -0
- package/src/client.ts +19 -0
- package/src/downloader.ts +79 -0
- package/src/filesystem.ts +62 -0
- package/src/hooks.ts +46 -0
- package/src/index.ts +21 -0
- package/src/installer.ts +42 -0
- package/src/native/OtaBundleUpdater.h +5 -0
- package/src/native/OtaBundleUpdater.m +56 -0
- package/src/native/OtaBundleUpdaterModule.kt +73 -0
- package/src/native/OtaBundleUpdaterPackage.kt +14 -0
- package/src/provider.tsx +77 -0
- package/src/storage.ts +42 -0
- package/src/types.ts +68 -0
- package/src/updater.ts +115 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { IFileSystemAdapter } from './types';
|
|
2
|
+
|
|
3
|
+
// Expo adapter — requires expo-file-system
|
|
4
|
+
export function createExpoFileSystemAdapter(): IFileSystemAdapter {
|
|
5
|
+
// Lazily require so non-Expo projects don't fail at import time
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
7
|
+
const ExpoFS = require('expo-file-system') as any;
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
getDocumentDirectory() {
|
|
11
|
+
return ExpoFS.documentDirectory ?? '';
|
|
12
|
+
},
|
|
13
|
+
async exists(path) {
|
|
14
|
+
const info = await ExpoFS.getInfoAsync(path);
|
|
15
|
+
return info.exists;
|
|
16
|
+
},
|
|
17
|
+
async readAsString(path, encoding) {
|
|
18
|
+
return ExpoFS.readAsStringAsync(path, {
|
|
19
|
+
encoding: encoding === 'base64' ? ExpoFS.EncodingType.Base64 : ExpoFS.EncodingType.UTF8,
|
|
20
|
+
});
|
|
21
|
+
},
|
|
22
|
+
async writeAsString(path, content, encoding) {
|
|
23
|
+
await ExpoFS.writeAsStringAsync(path, content, {
|
|
24
|
+
encoding: encoding === 'base64' ? ExpoFS.EncodingType.Base64 : ExpoFS.EncodingType.UTF8,
|
|
25
|
+
});
|
|
26
|
+
},
|
|
27
|
+
async deleteFile(path) {
|
|
28
|
+
await ExpoFS.deleteAsync(path, { idempotent: true });
|
|
29
|
+
},
|
|
30
|
+
async makeDirectory(path) {
|
|
31
|
+
await ExpoFS.makeDirectoryAsync(path, { intermediates: true });
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Bare React Native adapter — requires react-native-fs
|
|
37
|
+
export function createRNFSFileSystemAdapter(): IFileSystemAdapter {
|
|
38
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
39
|
+
const RNFS = require('react-native-fs') as any;
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
getDocumentDirectory() {
|
|
43
|
+
return `${RNFS.DocumentDirectoryPath}/`;
|
|
44
|
+
},
|
|
45
|
+
async exists(path) {
|
|
46
|
+
return RNFS.exists(path);
|
|
47
|
+
},
|
|
48
|
+
async readAsString(path, encoding) {
|
|
49
|
+
return RNFS.readFile(path, encoding);
|
|
50
|
+
},
|
|
51
|
+
async writeAsString(path, content, encoding) {
|
|
52
|
+
await RNFS.writeFile(path, content, encoding);
|
|
53
|
+
},
|
|
54
|
+
async deleteFile(path) {
|
|
55
|
+
const exists = await RNFS.exists(path);
|
|
56
|
+
if (exists) await RNFS.unlink(path);
|
|
57
|
+
},
|
|
58
|
+
async makeDirectory(path) {
|
|
59
|
+
await RNFS.mkdir(path);
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useCallback } from 'react';
|
|
2
|
+
import { useOtaContext } from './provider';
|
|
3
|
+
import type { UpdateState } from './types';
|
|
4
|
+
|
|
5
|
+
export interface UseOtaUpdateReturn extends UpdateState {
|
|
6
|
+
checkForUpdates: () => Promise<void>;
|
|
7
|
+
downloadUpdate: () => Promise<void>;
|
|
8
|
+
applyUpdate: () => Promise<void>;
|
|
9
|
+
rollback: () => Promise<void>;
|
|
10
|
+
isLoading: boolean;
|
|
11
|
+
hasUpdate: boolean;
|
|
12
|
+
isReady: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useOtaUpdate(): UseOtaUpdateReturn {
|
|
16
|
+
const { state, checkForUpdates, downloadUpdate, applyUpdate, rollback } = useOtaContext();
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
...state,
|
|
20
|
+
checkForUpdates: useCallback(checkForUpdates, [checkForUpdates]),
|
|
21
|
+
downloadUpdate: useCallback(downloadUpdate, [downloadUpdate]),
|
|
22
|
+
applyUpdate: useCallback(applyUpdate, [applyUpdate]),
|
|
23
|
+
rollback: useCallback(rollback, [rollback]),
|
|
24
|
+
isLoading: state.status === 'checking' || state.status === 'downloading',
|
|
25
|
+
hasUpdate: state.status === 'update_available',
|
|
26
|
+
isReady: state.status === 'ready',
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Hook that auto-downloads and applies mandatory updates
|
|
31
|
+
export function useAutoUpdate(options?: { applyImmediately?: boolean }) {
|
|
32
|
+
const ota = useOtaUpdate();
|
|
33
|
+
const applyImmediately = options?.applyImmediately ?? false;
|
|
34
|
+
|
|
35
|
+
// Auto-download when update is detected
|
|
36
|
+
if (ota.hasUpdate && ota.release?.isMandatory) {
|
|
37
|
+
ota.downloadUpdate();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Auto-apply when ready and applyImmediately is set
|
|
41
|
+
if (ota.isReady && applyImmediately) {
|
|
42
|
+
ota.applyUpdate();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return ota;
|
|
46
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export { OtaProvider, useOtaContext } from './provider';
|
|
2
|
+
export { useOtaUpdate, useAutoUpdate } from './hooks';
|
|
3
|
+
export { OtaUpdater } from './updater';
|
|
4
|
+
export { checkForUpdate } from './client';
|
|
5
|
+
export { downloadBundle } from './downloader';
|
|
6
|
+
export { createExpoFileSystemAdapter, createRNFSFileSystemAdapter } from './filesystem';
|
|
7
|
+
export {
|
|
8
|
+
isNativeModuleAvailable,
|
|
9
|
+
setStagedBundlePath,
|
|
10
|
+
getStagedBundlePath,
|
|
11
|
+
clearStagedBundlePath,
|
|
12
|
+
reload,
|
|
13
|
+
} from './installer';
|
|
14
|
+
export type {
|
|
15
|
+
OtaConfig,
|
|
16
|
+
UpdateStatus,
|
|
17
|
+
UpdateState,
|
|
18
|
+
IFileSystemAdapter,
|
|
19
|
+
INativeBundleUpdater,
|
|
20
|
+
Platform,
|
|
21
|
+
} from './types';
|
package/src/installer.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { NativeModules } from 'react-native';
|
|
2
|
+
import type { INativeBundleUpdater } from './types';
|
|
3
|
+
|
|
4
|
+
// User must register their native module under this name
|
|
5
|
+
const NATIVE_MODULE_NAME = 'OtaBundleUpdater';
|
|
6
|
+
|
|
7
|
+
function getNativeModule(): INativeBundleUpdater | null {
|
|
8
|
+
return (NativeModules[NATIVE_MODULE_NAME] as INativeBundleUpdater) ?? null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function isNativeModuleAvailable(): boolean {
|
|
12
|
+
return getNativeModule() !== null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function setStagedBundlePath(path: string): Promise<void> {
|
|
16
|
+
const mod = getNativeModule();
|
|
17
|
+
if (!mod) throw new Error(`Native module '${NATIVE_MODULE_NAME}' not found. See SDK setup docs.`);
|
|
18
|
+
await mod.setBundlePath(path);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function getStagedBundlePath(): Promise<string | null> {
|
|
22
|
+
const mod = getNativeModule();
|
|
23
|
+
if (!mod) return null;
|
|
24
|
+
return mod.getBundlePath();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function clearStagedBundlePath(): Promise<void> {
|
|
28
|
+
const mod = getNativeModule();
|
|
29
|
+
if (!mod) return;
|
|
30
|
+
await mod.clearBundlePath();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function reload(): void {
|
|
34
|
+
const mod = getNativeModule();
|
|
35
|
+
if (mod) {
|
|
36
|
+
mod.reload();
|
|
37
|
+
} else {
|
|
38
|
+
// Dev fallback
|
|
39
|
+
const devSettings = NativeModules['DevSettings'] as { reload?: () => void } | undefined;
|
|
40
|
+
devSettings?.reload?.();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#import "OtaBundleUpdater.h"
|
|
2
|
+
#import <React/RCTBridge.h>
|
|
3
|
+
#import <React/RCTRootView.h>
|
|
4
|
+
|
|
5
|
+
static NSString *const kBundlePathKey = @"OtaBundleUpdaterPath";
|
|
6
|
+
|
|
7
|
+
@implementation OtaBundleUpdater
|
|
8
|
+
|
|
9
|
+
RCT_EXPORT_MODULE();
|
|
10
|
+
|
|
11
|
+
// Called by React Native bridge to determine which bundle to load.
|
|
12
|
+
// Add to AppDelegate.mm:
|
|
13
|
+
// #import "OtaBundleUpdater.h"
|
|
14
|
+
// - (NSURL *)sourceURLForBridge:(RCTBridge *)bridge {
|
|
15
|
+
// return [OtaBundleUpdater bundleURLForBridge:bridge];
|
|
16
|
+
// }
|
|
17
|
+
+ (NSURL *)bundleURLForBridge:(RCTBridge *)bridge {
|
|
18
|
+
NSString *storedPath = [[NSUserDefaults standardUserDefaults] stringForKey:kBundlePathKey];
|
|
19
|
+
if (storedPath && [[NSFileManager defaultManager] fileExistsAtPath:storedPath]) {
|
|
20
|
+
return [NSURL fileURLWithPath:storedPath];
|
|
21
|
+
}
|
|
22
|
+
#if DEBUG
|
|
23
|
+
return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"];
|
|
24
|
+
#else
|
|
25
|
+
return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
|
|
26
|
+
#endif
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
RCT_EXPORT_METHOD(setBundlePath:(NSString *)path
|
|
30
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
31
|
+
reject:(RCTPromiseRejectBlock)reject) {
|
|
32
|
+
[[NSUserDefaults standardUserDefaults] setObject:path forKey:kBundlePathKey];
|
|
33
|
+
[[NSUserDefaults standardUserDefaults] synchronize];
|
|
34
|
+
resolve(nil);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
RCT_EXPORT_METHOD(getBundlePath:(RCTPromiseResolveBlock)resolve
|
|
38
|
+
reject:(RCTPromiseRejectBlock)reject) {
|
|
39
|
+
NSString *path = [[NSUserDefaults standardUserDefaults] stringForKey:kBundlePathKey];
|
|
40
|
+
resolve(path);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
RCT_EXPORT_METHOD(clearBundlePath:(RCTPromiseResolveBlock)resolve
|
|
44
|
+
reject:(RCTPromiseRejectBlock)reject) {
|
|
45
|
+
[[NSUserDefaults standardUserDefaults] removeObjectForKey:kBundlePathKey];
|
|
46
|
+
[[NSUserDefaults standardUserDefaults] synchronize];
|
|
47
|
+
resolve(nil);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
RCT_EXPORT_METHOD(reload) {
|
|
51
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
52
|
+
RCTTriggerReloadCommandListeners(@"OTA update applied");
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
package com.ota.sdk
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import com.facebook.react.bridge.*
|
|
5
|
+
|
|
6
|
+
private const val PREF_KEY = "OtaBundleUpdaterPath"
|
|
7
|
+
private const val PREFS_NAME = "OtaBundleUpdater"
|
|
8
|
+
|
|
9
|
+
class OtaBundleUpdaterModule(reactContext: ReactApplicationContext) :
|
|
10
|
+
ReactContextBaseJavaModule(reactContext) {
|
|
11
|
+
|
|
12
|
+
override fun getName() = "OtaBundleUpdater"
|
|
13
|
+
|
|
14
|
+
@ReactMethod
|
|
15
|
+
fun setBundlePath(path: String, promise: Promise) {
|
|
16
|
+
try {
|
|
17
|
+
reactApplicationContext
|
|
18
|
+
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
19
|
+
.edit()
|
|
20
|
+
.putString(PREF_KEY, path)
|
|
21
|
+
.apply()
|
|
22
|
+
promise.resolve(null)
|
|
23
|
+
} catch (e: Exception) {
|
|
24
|
+
promise.reject("SET_BUNDLE_PATH_ERROR", e.message, e)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@ReactMethod
|
|
29
|
+
fun getBundlePath(promise: Promise) {
|
|
30
|
+
val path = reactApplicationContext
|
|
31
|
+
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
32
|
+
.getString(PREF_KEY, null)
|
|
33
|
+
promise.resolve(path)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@ReactMethod
|
|
37
|
+
fun clearBundlePath(promise: Promise) {
|
|
38
|
+
try {
|
|
39
|
+
reactApplicationContext
|
|
40
|
+
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
41
|
+
.edit()
|
|
42
|
+
.remove(PREF_KEY)
|
|
43
|
+
.apply()
|
|
44
|
+
promise.resolve(null)
|
|
45
|
+
} catch (e: Exception) {
|
|
46
|
+
promise.reject("CLEAR_BUNDLE_PATH_ERROR", e.message, e)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@ReactMethod
|
|
51
|
+
fun reload() {
|
|
52
|
+
currentActivity?.runOnUiThread {
|
|
53
|
+
val ctx = currentActivity ?: return@runOnUiThread
|
|
54
|
+
val intent = ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)
|
|
55
|
+
intent?.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
|
56
|
+
ctx.startActivity(intent)
|
|
57
|
+
android.os.Process.killProcess(android.os.Process.myPid())
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
companion object {
|
|
62
|
+
// Call from MainApplication.kt to resolve the bundle path.
|
|
63
|
+
// In MainApplication.kt:
|
|
64
|
+
// override fun getJSBundleFile(): String? =
|
|
65
|
+
// OtaBundleUpdaterModule.getJSBundleFile(applicationContext) ?: super.getJSBundleFile()
|
|
66
|
+
fun getJSBundleFile(context: Context): String? {
|
|
67
|
+
val path = context
|
|
68
|
+
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
69
|
+
.getString(PREF_KEY, null)
|
|
70
|
+
return if (path != null && java.io.File(path).exists()) path else null
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
package com.ota.sdk
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.ReactPackage
|
|
4
|
+
import com.facebook.react.bridge.NativeModule
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.uimanager.ViewManager
|
|
7
|
+
|
|
8
|
+
class OtaBundleUpdaterPackage : ReactPackage {
|
|
9
|
+
override fun createNativeModules(context: ReactApplicationContext): List<NativeModule> =
|
|
10
|
+
listOf(OtaBundleUpdaterModule(context))
|
|
11
|
+
|
|
12
|
+
override fun createViewManagers(context: ReactApplicationContext): List<ViewManager<*, *>> =
|
|
13
|
+
emptyList()
|
|
14
|
+
}
|
package/src/provider.tsx
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import { AppState, type AppStateStatus } from 'react-native';
|
|
10
|
+
import { OtaUpdater } from './updater';
|
|
11
|
+
import type { OtaConfig, UpdateState, IFileSystemAdapter } from './types';
|
|
12
|
+
|
|
13
|
+
interface OtaContextValue {
|
|
14
|
+
state: UpdateState;
|
|
15
|
+
checkForUpdates: () => Promise<void>;
|
|
16
|
+
downloadUpdate: () => Promise<void>;
|
|
17
|
+
applyUpdate: () => Promise<void>;
|
|
18
|
+
rollback: () => Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const OtaContext = createContext<OtaContextValue | null>(null);
|
|
22
|
+
|
|
23
|
+
interface OtaProviderProps {
|
|
24
|
+
config: OtaConfig;
|
|
25
|
+
fileSystem: IFileSystemAdapter;
|
|
26
|
+
children: ReactNode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function OtaProvider({ config, fileSystem, children }: OtaProviderProps) {
|
|
30
|
+
const updaterRef = useRef<OtaUpdater | null>(null);
|
|
31
|
+
|
|
32
|
+
if (!updaterRef.current) {
|
|
33
|
+
updaterRef.current = new OtaUpdater(config, fileSystem);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const updater = updaterRef.current;
|
|
37
|
+
const [state, setState] = useState<UpdateState>(updater.getState());
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const unsub = updater.subscribe(setState);
|
|
41
|
+
return unsub;
|
|
42
|
+
}, [updater]);
|
|
43
|
+
|
|
44
|
+
// Check on foreground (AppState active)
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (config.checkOnForeground === false) return;
|
|
47
|
+
|
|
48
|
+
const handler = (nextState: AppStateStatus) => {
|
|
49
|
+
if (nextState === 'active') {
|
|
50
|
+
updater.checkForUpdates();
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const sub = AppState.addEventListener('change', handler);
|
|
55
|
+
|
|
56
|
+
// Initial check on mount
|
|
57
|
+
updater.checkForUpdates();
|
|
58
|
+
|
|
59
|
+
return () => sub.remove();
|
|
60
|
+
}, [updater, config.checkOnForeground]);
|
|
61
|
+
|
|
62
|
+
const value: OtaContextValue = {
|
|
63
|
+
state,
|
|
64
|
+
checkForUpdates: () => updater.checkForUpdates(),
|
|
65
|
+
downloadUpdate: () => updater.downloadUpdate(),
|
|
66
|
+
applyUpdate: () => updater.applyUpdate(),
|
|
67
|
+
rollback: () => updater.rollback(),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return <OtaContext.Provider value={value}>{children}</OtaContext.Provider>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function useOtaContext(): OtaContextValue {
|
|
74
|
+
const ctx = useContext(OtaContext);
|
|
75
|
+
if (!ctx) throw new Error('useOtaContext must be used inside <OtaProvider>');
|
|
76
|
+
return ctx;
|
|
77
|
+
}
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
2
|
+
import type { OtaCheckResponse } from './types';
|
|
3
|
+
|
|
4
|
+
const KEY_PENDING_RELEASE = '@ota/pending_release';
|
|
5
|
+
const KEY_APPLIED_RELEASE = '@ota/applied_release';
|
|
6
|
+
const KEY_BUNDLE_PATH = '@ota/bundle_path';
|
|
7
|
+
|
|
8
|
+
type StoredRelease = NonNullable<OtaCheckResponse['release']>;
|
|
9
|
+
|
|
10
|
+
export async function storePendingRelease(release: StoredRelease): Promise<void> {
|
|
11
|
+
await AsyncStorage.setItem(KEY_PENDING_RELEASE, JSON.stringify(release));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function getPendingRelease(): Promise<StoredRelease | null> {
|
|
15
|
+
const raw = await AsyncStorage.getItem(KEY_PENDING_RELEASE);
|
|
16
|
+
return raw ? (JSON.parse(raw) as StoredRelease) : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function clearPendingRelease(): Promise<void> {
|
|
20
|
+
await AsyncStorage.removeItem(KEY_PENDING_RELEASE);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function storeAppliedRelease(release: StoredRelease): Promise<void> {
|
|
24
|
+
await AsyncStorage.setItem(KEY_APPLIED_RELEASE, JSON.stringify(release));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function getAppliedRelease(): Promise<StoredRelease | null> {
|
|
28
|
+
const raw = await AsyncStorage.getItem(KEY_APPLIED_RELEASE);
|
|
29
|
+
return raw ? (JSON.parse(raw) as StoredRelease) : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function storeBundlePath(path: string): Promise<void> {
|
|
33
|
+
await AsyncStorage.setItem(KEY_BUNDLE_PATH, path);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function getBundlePath(): Promise<string | null> {
|
|
37
|
+
return AsyncStorage.getItem(KEY_BUNDLE_PATH);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function clearBundlePath(): Promise<void> {
|
|
41
|
+
await AsyncStorage.removeItem(KEY_BUNDLE_PATH);
|
|
42
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export type Platform = 'IOS' | 'ANDROID';
|
|
2
|
+
|
|
3
|
+
export interface OtaCheckRequest {
|
|
4
|
+
appId: string;
|
|
5
|
+
platform: Platform;
|
|
6
|
+
currentVersion: string;
|
|
7
|
+
runtimeVersion: string;
|
|
8
|
+
channel: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface OtaCheckResponse {
|
|
12
|
+
updateAvailable: boolean;
|
|
13
|
+
release?: {
|
|
14
|
+
id: string;
|
|
15
|
+
version: string;
|
|
16
|
+
downloadUrl: string;
|
|
17
|
+
checksum: string;
|
|
18
|
+
fileSize: number;
|
|
19
|
+
isMandatory: boolean;
|
|
20
|
+
releaseNotes?: string;
|
|
21
|
+
metadata?: Record<string, unknown>;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface OtaConfig {
|
|
26
|
+
serverUrl: string;
|
|
27
|
+
appId: string;
|
|
28
|
+
channel: string;
|
|
29
|
+
platform: Platform;
|
|
30
|
+
currentVersion: string;
|
|
31
|
+
runtimeVersion: string;
|
|
32
|
+
checkOnForeground?: boolean;
|
|
33
|
+
onUpdate?: (release: NonNullable<OtaCheckResponse['release']>) => void;
|
|
34
|
+
onError?: (error: Error) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type UpdateStatus =
|
|
38
|
+
| 'idle'
|
|
39
|
+
| 'checking'
|
|
40
|
+
| 'up_to_date'
|
|
41
|
+
| 'update_available'
|
|
42
|
+
| 'downloading'
|
|
43
|
+
| 'ready'
|
|
44
|
+
| 'error';
|
|
45
|
+
|
|
46
|
+
export interface UpdateState {
|
|
47
|
+
status: UpdateStatus;
|
|
48
|
+
progress: number;
|
|
49
|
+
release: NonNullable<OtaCheckResponse['release']> | null;
|
|
50
|
+
error: Error | null;
|
|
51
|
+
localBundlePath: string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface IFileSystemAdapter {
|
|
55
|
+
getDocumentDirectory(): string;
|
|
56
|
+
exists(path: string): Promise<boolean>;
|
|
57
|
+
readAsString(path: string, encoding: 'utf8' | 'base64'): Promise<string>;
|
|
58
|
+
writeAsString(path: string, content: string, encoding: 'utf8' | 'base64'): Promise<void>;
|
|
59
|
+
deleteFile(path: string): Promise<void>;
|
|
60
|
+
makeDirectory(path: string): Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface INativeBundleUpdater {
|
|
64
|
+
setBundlePath(path: string): Promise<void>;
|
|
65
|
+
getBundlePath(): Promise<string | null>;
|
|
66
|
+
clearBundlePath(): Promise<void>;
|
|
67
|
+
reload(): void;
|
|
68
|
+
}
|
package/src/updater.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { checkForUpdate } from './client';
|
|
2
|
+
import { downloadBundle } from './downloader';
|
|
3
|
+
import { setStagedBundlePath, clearStagedBundlePath, reload } from './installer';
|
|
4
|
+
import {
|
|
5
|
+
storePendingRelease,
|
|
6
|
+
clearPendingRelease,
|
|
7
|
+
storeAppliedRelease,
|
|
8
|
+
getAppliedRelease,
|
|
9
|
+
storeBundlePath,
|
|
10
|
+
clearBundlePath,
|
|
11
|
+
} from './storage';
|
|
12
|
+
import type { OtaConfig, UpdateState, IFileSystemAdapter } from './types';
|
|
13
|
+
|
|
14
|
+
export type StateListener = (state: UpdateState) => void;
|
|
15
|
+
|
|
16
|
+
const initialState: UpdateState = {
|
|
17
|
+
status: 'idle',
|
|
18
|
+
progress: 0,
|
|
19
|
+
release: null,
|
|
20
|
+
error: null,
|
|
21
|
+
localBundlePath: null,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export class OtaUpdater {
|
|
25
|
+
private state: UpdateState = { ...initialState };
|
|
26
|
+
private listeners: Set<StateListener> = new Set();
|
|
27
|
+
private config: OtaConfig;
|
|
28
|
+
private fs: IFileSystemAdapter;
|
|
29
|
+
|
|
30
|
+
constructor(config: OtaConfig, fs: IFileSystemAdapter) {
|
|
31
|
+
this.config = config;
|
|
32
|
+
this.fs = fs;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
getState(): UpdateState {
|
|
36
|
+
return this.state;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
subscribe(listener: StateListener): () => void {
|
|
40
|
+
this.listeners.add(listener);
|
|
41
|
+
return () => this.listeners.delete(listener);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private emit(patch: Partial<UpdateState>): void {
|
|
45
|
+
this.state = { ...this.state, ...patch };
|
|
46
|
+
this.listeners.forEach((l) => l(this.state));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async checkForUpdates(): Promise<void> {
|
|
50
|
+
if (this.state.status === 'checking' || this.state.status === 'downloading') return;
|
|
51
|
+
|
|
52
|
+
this.emit({ status: 'checking', error: null });
|
|
53
|
+
try {
|
|
54
|
+
const applied = await getAppliedRelease();
|
|
55
|
+
const currentVersion = applied?.version ?? this.config.currentVersion;
|
|
56
|
+
const result = await checkForUpdate(this.config.serverUrl, {
|
|
57
|
+
appId: this.config.appId,
|
|
58
|
+
channel: this.config.channel,
|
|
59
|
+
platform: this.config.platform,
|
|
60
|
+
currentVersion,
|
|
61
|
+
runtimeVersion: this.config.runtimeVersion,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (!result.updateAvailable || !result.release) {
|
|
65
|
+
this.emit({ status: 'up_to_date', release: null });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
this.emit({ status: 'update_available', release: result.release });
|
|
70
|
+
await storePendingRelease(result.release);
|
|
71
|
+
this.config.onUpdate?.(result.release);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
74
|
+
this.emit({ status: 'error', error });
|
|
75
|
+
this.config.onError?.(error);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async downloadUpdate(): Promise<void> {
|
|
80
|
+
if (!this.state.release || this.state.status !== 'update_available') return;
|
|
81
|
+
|
|
82
|
+
const release = this.state.release;
|
|
83
|
+
this.emit({ status: 'downloading', progress: 0 });
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const bundlePath = await downloadBundle(release, this.fs, (progress) => {
|
|
87
|
+
this.emit({ progress });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await setStagedBundlePath(bundlePath);
|
|
91
|
+
await storeBundlePath(bundlePath);
|
|
92
|
+
|
|
93
|
+
this.emit({ status: 'ready', localBundlePath: bundlePath, progress: 100 });
|
|
94
|
+
} catch (err) {
|
|
95
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
96
|
+
this.emit({ status: 'error', error });
|
|
97
|
+
this.config.onError?.(error);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async applyUpdate(): Promise<void> {
|
|
102
|
+
if (this.state.status !== 'ready' || !this.state.release) return;
|
|
103
|
+
await storeAppliedRelease(this.state.release);
|
|
104
|
+
await clearPendingRelease();
|
|
105
|
+
reload();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async rollback(): Promise<void> {
|
|
109
|
+
await clearStagedBundlePath();
|
|
110
|
+
await clearBundlePath();
|
|
111
|
+
await clearPendingRelease();
|
|
112
|
+
this.emit({ ...initialState, status: 'idle' });
|
|
113
|
+
reload();
|
|
114
|
+
}
|
|
115
|
+
}
|