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.
Files changed (56) hide show
  1. package/README.md +376 -0
  2. package/dist/client.d.ts +3 -0
  3. package/dist/client.d.ts.map +1 -0
  4. package/dist/client.js +16 -0
  5. package/dist/client.js.map +1 -0
  6. package/dist/downloader.d.ts +6 -0
  7. package/dist/downloader.d.ts.map +1 -0
  8. package/dist/downloader.js +65 -0
  9. package/dist/downloader.js.map +1 -0
  10. package/dist/filesystem.d.ts +4 -0
  11. package/dist/filesystem.d.ts.map +1 -0
  12. package/dist/filesystem.js +63 -0
  13. package/dist/filesystem.js.map +1 -0
  14. package/dist/hooks.d.ts +15 -0
  15. package/dist/hooks.d.ts.map +1 -0
  16. package/dist/hooks.js +34 -0
  17. package/dist/hooks.js.map +1 -0
  18. package/dist/index.d.ts +9 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +25 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/installer.d.ts +6 -0
  23. package/dist/installer.d.ts.map +1 -0
  24. package/dist/installer.js +46 -0
  25. package/dist/installer.js.map +1 -0
  26. package/dist/provider.d.ts +18 -0
  27. package/dist/provider.d.ts.map +1 -0
  28. package/dist/provider.js +82 -0
  29. package/dist/provider.js.map +1 -0
  30. package/dist/storage.d.ts +12 -0
  31. package/dist/storage.d.ts.map +1 -0
  32. package/dist/storage.js +44 -0
  33. package/dist/storage.js.map +1 -0
  34. package/dist/types.d.ts +55 -0
  35. package/dist/types.d.ts.map +1 -0
  36. package/dist/types.js +3 -0
  37. package/dist/types.js.map +1 -0
  38. package/dist/updater.d.ts +17 -0
  39. package/dist/updater.d.ts.map +1 -0
  40. package/dist/updater.js +98 -0
  41. package/dist/updater.js.map +1 -0
  42. package/package.json +60 -0
  43. package/src/client.ts +19 -0
  44. package/src/downloader.ts +79 -0
  45. package/src/filesystem.ts +62 -0
  46. package/src/hooks.ts +46 -0
  47. package/src/index.ts +21 -0
  48. package/src/installer.ts +42 -0
  49. package/src/native/OtaBundleUpdater.h +5 -0
  50. package/src/native/OtaBundleUpdater.m +56 -0
  51. package/src/native/OtaBundleUpdaterModule.kt +73 -0
  52. package/src/native/OtaBundleUpdaterPackage.kt +14 -0
  53. package/src/provider.tsx +77 -0
  54. package/src/storage.ts +42 -0
  55. package/src/types.ts +68 -0
  56. 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';
@@ -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,5 @@
1
+ #import <React/RCTBridgeModule.h>
2
+
3
+ @interface OtaBundleUpdater : NSObject <RCTBridgeModule>
4
+ + (NSURL *)bundleURLForBridge:(RCTBridge *)bridge;
5
+ @end
@@ -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
+ }
@@ -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
+ }