Complete step-by-step instructions to register your app, configure native Android/iOS layers, implement in-app update modals, and release your first Over-The-Air bundle.
Configures dependencies, native Android/iOS layers, network security, isolated src/ota/ modules, and high-velocity mobile release scripts automatically in seconds.
@hot-updater/react-native & runs iOS CocoaPods gracefully.ota-release-key.pem at project root (auto-ignored in .gitignore) and embeds src/ota/certs/ota-public-key.pem.src/ota/ including ModalUpdateOta.tsx and AppVersion.tsx.MainApplication.kt).NSAllowsLocalNetworking (Info.plist) & AppDelegate.swift.yarn opa -s, yarn opa -p, yarn opi -p, yarn opb -p.
Pull the latest ota-internal repository and link the CLI tools (ota-setup, ota-deploy) to your global PATH:
# 1. Navigate to ota-internal directory
cd /path/to/tools/ota-internal
# 2. Pull the latest tool updates
git pull origin develop
# 3. Link CLI tools to global PATH (run once)
npm install -g .
# 4. Verify that the CLI tools are ready
which ota-setup
which ota-deploy
Open terminal in your React Native project root (e.g. salonx-owner-application) and run:
# Run global CLI for this application
ota-setup --app=salonx-owner
# (Optional) Skip CocoaPods if you want to run pod install manually later:
ota-setup --app=salonx-owner --skip-pods
certs/ota-release-key.pem (or project root) and appends it to .gitignore.certs/ota-release-key.pem to your team's 1Password / Secret Vault. Without this private key, end-user mobile apps will reject your OTA updates.OTA_SIGNING_PRIVATE_KEY.
Open your root component (e.g. App.tsx or MainApp.tsx) and mount <ModalUpdateOta /> once globally:
import React from 'react';
import { RootProvider } from '@/providers';
import { NavigationContainer } from '@react-navigation/native';
import { ModalUpdateOta } from '@/ota'; // or './src/ota'
export default function App() {
return (
<RootProvider>
<NavigationContainer>
{/* Your application screens & navigators */}
</NavigationContainer>
{/* Global Singleton OTA Update Modal (Mounted once at root) */}
<ModalUpdateOta />
</RootProvider>
);
}
The generated <AppVersion /> component displays a live status indicator dot (Red = Update ready, Pulsing Yellow = Downloading, Green = Ready to reload, Slate = Up to date) with pre-configured bottom or header positioning:
import React from 'react';
import { View, StyleSheet, KeyboardAvoidingView, Platform } from 'react-native';
import { AppVersion, AppStatus } from '@/ota'; // or './src/ota'
export default function SignInScreen() {
return (
<View style={styles.container}>
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
{/* Your login form inputs and submit button */}
</KeyboardAvoidingView>
{/* Option A: Bottom Center Badge (Default - bottom: 40) */}
<AppVersion position="bottom" />
{/* Option B: Top-Right Header Badge (top: 40, right: 16) */}
{/* <AppVersion position="header" /> */}
{/* Option C: Ambient Indicator Dot Only (pulsing dot without text, ideal across any screen) */}
{/* <AppStatus position="header" /> */}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
});
Use the HotUpdater client from @/ota anywhere in your app (e.g. Settings / About page):
import { HotUpdater } from '@/ota';
// 1. Get Current App & Bundle Info
const appVersion = HotUpdater.getAppVersion(); // e.g. "1.0.0"
const bundleId = HotUpdater.getBundleId(); // e.g. "0191eb5d-..." or null
const channel = HotUpdater.getChannel(); // e.g. "production"
// 2. Check for Latest Available Update from Server
async function checkServerUpdate() {
const updateInfo = await HotUpdater.checkForUpdate({});
if (updateInfo) {
console.log('New update available:', updateInfo.id);
console.log('Changelog:', updateInfo.message);
console.log('Force update:', updateInfo.shouldForceUpdate);
} else {
console.log('App is up to date.');
}
}
Fast, intuitive shortcuts configured directly in package.json:
# 🟢 Android Releases (OPA = OTA Publish Android)
yarn opa -s # Deploy Android OTA ➔ Staging
yarn opa -p # Deploy Android OTA ➔ Production
yarn opa -p -f # Deploy Android Production with Force Reload (-f)
yarn opa -s -m "Hotfix cart UI" # Deploy Android Staging with release note (-m)
# 🍏 iOS Releases (OPI = OTA Publish iOS)
yarn opi -s # Deploy iOS OTA ➔ Staging
yarn opi -p # Deploy iOS OTA ➔ Production
yarn opi -p -f # Deploy iOS Production with Force Reload (-f)
# 🌐 Both Platforms Simultaneously (OPB = OTA Publish Both)
yarn opb -s # Deploy Both Android & iOS ➔ Staging
yarn opb -p # Deploy Both Android & iOS ➔ Production
# 💡 Quick Cheat Sheet & Help
yarn o # Show Cheat Sheet & commands quick reference
yarn ota # Show Cheat Sheet & commands quick reference
yarn o -h # Or yarn opa -h / yarn ota -h
# 🔄 Environment Management & Cache Cleaning
yarn env:staging # Sync OTA_CHANNEL=staging in .env
yarn env:prod # Sync OTA_CHANNEL=production in .env
yarn clean:watchman # Reset Metro & Watchman cache
# ⚙️ Explicit Version Override (Optional, auto-detects from package.json by default)
yarn opa -p -v 1.2.0 # Force target native version 1.2.0
Before writing mobile client code, create your application entry on the OTA Web Console.
SalonX Owner App) and unique App ID (e.g. salonx-owner).ota_salonx_owner and MinIO S3 storage.
Install the official Hot Updater native bridge in your React Native project. No custom local wrapper package is required.
# Install official Hot Updater React Native package
yarn add @hot-updater/react-native@^0.36.13
# Install iOS CocoaPods
cd ios && pod install && cd ..
Configure cleartext network security for LAN Wi-Fi testing and inject the OTA JavaScript bundle loader.
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
<!-- Include your Host Mac LAN IP -->
<domain includeSubdomains="true" class="dynamic-ip-domain">192.168.68.125</domain>
</domain-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>
<application
android:name=".MainApplication"
android:networkSecurityConfig="@xml/network_security_config"
... >
import com.hotupdater.HotUpdater
class MainApplication : Application(), ReactApplication {
override val reactHost: ReactHost by lazy {
getDefaultReactHost(
context = applicationContext,
packageList = PackageList(this).packages,
jsBundleFilePath = if (BuildConfig.DEBUG) {
null
} else {
HotUpdater.getJSBundleFile(applicationContext)
},
)
}
}
Permit local networking in Info.plist and inject the bundle URL in AppDelegate.
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
import HotUpdater
class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
override func bundleURL() -> URL? {
#if DEBUG
RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
#else
HotUpdater.bundleURL()
#endif
}
}
Create src/ota/ota.config.ts and src/ota/ota.service.ts to configure connection endpoints:
/**
* ============================================================================
* AUTO-GENERATED BY @ota-internal (ota-setup)
* OTA Environment Configuration
* Verify/adjust the AppENV import path to match your project's env directory.
* ============================================================================
*/
import { AppENV } from '@/constants/env';
export const OTA_CONFIG = {
serverUrl: AppENV?.OTA_SERVER_URL || 'https://ota-api.skydev.vn:8401',
storageUrl: AppENV?.OTA_STORAGE_URL || 'https://ota-api.skydev.vn:8490',
appId: AppENV?.OTA_APP_ID || 'salonx-owner',
} as const;
/* eslint-disable */
// @ts-nocheck
import { HotUpdater } from '@hot-updater/react-native';
import { OTA_CONFIG } from './ota.config';
HotUpdater.init({
baseURL: `${OTA_CONFIG.serverUrl}/apps/${OTA_CONFIG.appId}`,
requestHeaders: {},
});
export { HotUpdater };
Check for updates and display a clean update modal with live progress and "Restart Now" vs "Later" options:
/* eslint-disable */
// @ts-nocheck
import { useCallback, useEffect, useState } from 'react';
import { HotUpdater, useHotUpdaterStore } from '@hot-updater/react-native';
import './ota.service';
// 1. Utilities
const formatCurrentTime = () => {
const now = new Date();
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
return `${hours}:${minutes}`;
};
const resolveStage = ({ downloadError, hasUpdateInfo, isDownloading, isUpdateDownloaded }) => {
if (downloadError) return 'UPDATE_FAILED';
if (isDownloading) return 'DOWNLOADING';
if (isUpdateDownloaded) return 'READY_TO_RELOAD';
if (hasUpdateInfo) return 'UPDATE_READY';
return 'UP_TO_DATE';
};
const resolveStageMeta = (stage, { lastCheckedTime, progressPercent }) => {
const timeSuffix = lastCheckedTime ? ` (${lastCheckedTime})` : '';
switch (stage) {
case 'UPDATE_FAILED':
return {
primaryActionLabel: 'Retry Update',
statusText: `Update failed${timeSuffix}`,
title: 'Update Failed',
};
case 'DOWNLOADING':
return {
primaryActionLabel: `Downloading (${progressPercent}%)`,
statusText: `Downloading (${progressPercent}%)`,
title: 'Downloading Update...',
};
case 'READY_TO_RELOAD':
return {
primaryActionLabel: 'Restart App Now',
statusText: `Update ready to reload${timeSuffix}`,
title: 'Update Ready',
};
case 'UPDATE_READY':
return {
primaryActionLabel: 'Download & Install',
statusText: `Update ready${timeSuffix}`,
title: 'New Update Available',
};
case 'UP_TO_DATE':
default:
return {
primaryActionLabel: 'OK',
statusText: `Up to date${timeSuffix}`,
title: 'Up to Date',
};
}
};
// 2. Main Hook
export default function useOtaUpdate() {
const [downloadError, setDownloadError] = useState(null);
const [isDismissed, setIsDismissed] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [lastCheckedTime, setLastCheckedTime] = useState('');
const [updateInfo, setUpdateInfo] = useState(null);
const isUpdateDownloaded = useHotUpdaterStore((state) => state.isUpdateDownloaded);
const progress = useHotUpdaterStore((state) => state.progress);
useEffect(() => {
HotUpdater.checkForUpdate({})
.then(async (info) => {
setUpdateInfo(info);
setLastCheckedTime(formatCurrentTime());
if (info && !isUpdateDownloaded) {
setIsDownloading(true);
setDownloadError(null);
try {
await info.updateBundle();
} catch (err) {
setDownloadError(err?.message || 'Download failed');
} finally {
setIsDownloading(false);
}
}
})
.catch((err) => {
setDownloadError(err?.message || 'Check update failed');
setLastCheckedTime(formatCurrentTime());
});
}, [isUpdateDownloaded]);
const handleDownloadOrRestart = useCallback(async () => {
try {
if (isUpdateDownloaded) {
await HotUpdater.reload();
} else if (updateInfo && !isDownloading) {
setIsDownloading(true);
setDownloadError(null);
await updateInfo.updateBundle();
} else if (downloadError) {
setDownloadError(null);
setIsDownloading(true);
const info = await HotUpdater.checkForUpdate({});
setUpdateInfo(info);
setLastCheckedTime(formatCurrentTime());
if (info && !isUpdateDownloaded) {
await info.updateBundle();
}
}
} catch (err) {
setDownloadError(err?.message || 'Update failed');
} finally {
setIsDownloading(false);
}
}, [downloadError, isDownloading, isUpdateDownloaded, updateInfo]);
const handleLater = useCallback(() => {
setIsDismissed(true);
}, []);
const progressPercent = Math.round((progress || 0) * 100);
const isForceUpdate = Boolean(updateInfo?.shouldForceUpdate);
const hasOtaUpdate = Boolean(updateInfo) || isUpdateDownloaded || isDownloading || Boolean(downloadError);
const isVisible = hasOtaUpdate && (!isDismissed || isForceUpdate);
const appVersion = HotUpdater.getAppVersion?.() || '1.0.0';
const bundleId = HotUpdater.getBundleId?.() || null;
const isUpToDate = !downloadError && !updateInfo && !isDownloading && !isUpdateDownloaded;
const currentStage = resolveStage({
downloadError,
hasUpdateInfo: Boolean(updateInfo),
isDownloading,
isUpdateDownloaded,
});
const stageMeta = resolveStageMeta(currentStage, {
lastCheckedTime,
progressPercent,
});
const statusText = `v${appVersion} • ${stageMeta.statusText}`;
const updateStatus = stageMeta.statusText;
return {
appVersion,
bundleId,
currentStage,
downloadError,
isDownloading,
isError: Boolean(downloadError),
isForceUpdate,
isLoading: isDownloading,
isReadyToReload: isUpdateDownloaded,
isUpToDate,
isVisible,
lastCheckedTime,
message: updateInfo?.message || (downloadError ? `Update failed: ${downloadError}. Tap to retry.` : 'A new update with improvements is ready to be installed.'),
onPrimaryPress: handleDownloadOrRestart,
onSecondaryPress: handleLater,
primaryLabel: stageMeta.primaryActionLabel,
progressPercent,
secondaryLabel: !isForceUpdate ? 'Later' : null,
statusText,
title: stageMeta.title,
updateStatus,
};
}
/* eslint-disable */
// @ts-nocheck
import React from 'react';
import { Modal, View, Text, TouchableOpacity, ActivityIndicator, StyleSheet } from 'react-native';
import useOtaUpdate from './useOtaUpdate';
export default function ModalUpdateOta() {
const {
isVisible,
isDownloading,
isLoading,
progressPercent,
title,
message,
primaryLabel,
secondaryLabel,
onPrimaryPress,
onSecondaryPress,
} = useOtaUpdate();
if (!isVisible) return null;
return (
<Modal transparent animationType="fade" visible={isVisible} statusBarTranslucent>
<View style={styles.overlay}>
<View style={styles.card}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.message}>{message}</Text>
{isDownloading && (
<View style={styles.progressContainer}>
<View style={styles.progressBarBackground}>
<View style={[styles.progressBarFill, { width: `${Math.max(5, progressPercent)}%` }]} />
</View>
<Text style={styles.progressText}>{progressPercent}%</Text>
</View>
)}
<View style={styles.actionsContainer}>
{secondaryLabel && !isDownloading && (
<TouchableOpacity activeOpacity={0.7} onPress={onSecondaryPress} disabled={isLoading} style={styles.secondaryButton}>
<Text style={styles.secondaryButtonText}>{secondaryLabel}</Text>
</TouchableOpacity>
)}
<TouchableOpacity activeOpacity={0.7} onPress={onPrimaryPress} disabled={isLoading} style={[styles.primaryButton, !secondaryLabel && { flex: 1 }]}>
{isLoading && !isDownloading ? (
<ActivityIndicator size="small" color="#ffffff" />
) : (
<Text style={styles.primaryButtonText}>{primaryLabel}</Text>
)}
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: { flex: 1, backgroundColor: 'rgba(15, 23, 42, 0.75)', justifyContent: 'center', alignItems: 'center', padding: 24 },
card: { width: '100%', maxWidth: 380, backgroundColor: '#ffffff', borderRadius: 16, padding: 24, elevation: 8 },
title: { fontSize: 18, fontWeight: '700', color: '#0f172a', marginBottom: 8 },
message: { fontSize: 14, color: '#475569', lineHeight: 20, marginBottom: 20 },
progressContainer: { marginBottom: 20 },
progressBarBackground: { height: 8, backgroundColor: '#e2e8f0', borderRadius: 4, overflow: 'hidden', marginBottom: 6 },
progressBarFill: { height: '100%', backgroundColor: '#0284c7', borderRadius: 4 },
progressText: { fontSize: 12, color: '#64748b', textAlign: 'right', fontWeight: '500' },
actionsContainer: { flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', gap: 12 },
secondaryButton: { minHeight: 44, paddingVertical: 10, paddingHorizontal: 16, borderRadius: 8, borderWidth: 1, borderColor: '#cbd5e1', justifyContent: 'center', alignItems: 'center' },
secondaryButtonText: { fontSize: 14, fontWeight: '600', color: '#475569' },
primaryButton: { minHeight: 44, paddingVertical: 10, paddingHorizontal: 20, borderRadius: 8, backgroundColor: '#0284c7', justifyContent: 'center', alignItems: 'center' },
primaryButtonText: { fontSize: 14, fontWeight: '600', color: '#ffffff' },
});
/* eslint-disable */
// @ts-nocheck
import React, { useCallback, useEffect, useRef } from 'react';
import { Animated, StyleProp, StyleSheet, Text, TextStyle, TouchableOpacity, View, ViewStyle } from 'react-native';
import useOtaUpdate from './useOtaUpdate';
export interface AppVersionProps {
activeOpacity?: number;
badgeStyle?: StyleProp<ViewStyle>;
dotStyle?: StyleProp<ViewStyle>;
position?: 'bottom' | 'header' | 'inline';
mode?: 'bottom' | 'header' | 'inline';
onPress?: () => void;
showDot?: boolean;
statusOnly?: boolean;
dotOnly?: boolean;
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
}
export const getDotColor = (stage) => {
switch (stage) {
case 'UPDATE_FAILED': return '#ef4444'; // Crimson Red (failure / verification error)
case 'DOWNLOADING': return '#eab308'; // Amber (fast pulsing animation)
case 'READY_TO_RELOAD': return '#22c55e'; // Emerald Green (downloaded & verified, gentle breathe)
case 'UPDATE_READY': return '#38bdf8'; // Sky Blue (available on server)
case 'UP_TO_DATE':
default: return '#94a3b8'; // Slate Gray (up to date)
}
};
export default function AppVersion({
activeOpacity = 0.7,
badgeStyle,
dotStyle,
position,
mode,
onPress,
showDot = true,
statusOnly = false,
dotOnly = false,
style,
textStyle,
}: AppVersionProps) {
const isStatusOnly = Boolean(statusOnly || dotOnly);
const resolvedPosition = position ?? mode ?? (isStatusOnly ? 'header' : 'bottom');
const { currentStage, isDownloading, isReadyToReload, onPrimaryPress, statusText } = useOtaUpdate();
const pulseAnim = useRef(new Animated.Value(1)).current;
useEffect(() => {
if (isDownloading || isReadyToReload) {
const anim = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, { duration: isDownloading ? 500 : 900, toValue: 0.3, useNativeDriver: true }),
Animated.timing(pulseAnim, { duration: isDownloading ? 500 : 900, toValue: 1, useNativeDriver: true }),
])
);
anim.start();
return () => anim.stop();
}
pulseAnim.setValue(1);
}, [isDownloading, isReadyToReload, pulseAnim]);
const handlePress = useCallback(() => {
if (onPress) onPress();
else if (isReadyToReload || currentStage === 'UPDATE_READY' || currentStage === 'UPDATE_FAILED') onPrimaryPress();
}, [currentStage, isReadyToReload, onPrimaryPress, onPress]);
const dotColor = getDotColor(currentStage);
const containerStyle = resolvedPosition === 'header' ? styles.headerContainer : resolvedPosition === 'inline' ? styles.inlineContainer : styles.bottomContainer;
return (
<View pointerEvents="box-none" style={[containerStyle, style]}>
<TouchableOpacity
activeOpacity={activeOpacity}
disabled={!onPress && currentStage === 'UP_TO_DATE' && !isDownloading}
hitSlop={{ bottom: 12, left: 12, right: 12, top: 12 }}
onPress={handlePress}
style={[styles.badge, isStatusOnly && styles.badgeStatusOnly, badgeStyle]}>
{showDot && (
<Animated.View
style={[
styles.dot,
isStatusOnly && styles.dotStatusOnly,
{ backgroundColor: dotColor },
(isDownloading || isReadyToReload) && { opacity: pulseAnim },
dotStyle,
]}
/>
)}
{!isStatusOnly && <Text style={[styles.versionText, textStyle]}>{statusText}</Text>}
</TouchableOpacity>
</View>
);
}
export function AppStatus(props: Omit<AppVersionProps, 'statusOnly' | 'dotOnly'>) {
return <AppVersion statusOnly position="header" {...props} />;
}
const styles = StyleSheet.create({
badge: { alignItems: 'center', flexDirection: 'row', justifyContent: 'center', paddingHorizontal: 8, paddingVertical: 4 },
badgeStatusOnly: { paddingHorizontal: 6, paddingVertical: 6 },
bottomContainer: { alignItems: 'center', bottom: 40, justifyContent: 'center', left: 0, position: 'absolute', right: 0, zIndex: 99 },
dot: { borderRadius: 9999, height: 6, marginRight: 6, width: 6 },
dotStatusOnly: { height: 8, marginRight: 0, width: 8 },
headerContainer: { alignItems: 'center', justifyContent: 'center', position: 'absolute', right: 16, top: 40, zIndex: 99 },
inlineContainer: { alignItems: 'center', flexDirection: 'row', justifyContent: 'center' },
versionText: { color: '#64748b', fontSize: 12, fontWeight: '500', letterSpacing: 0.2 },
});
/* eslint-disable */
// @ts-nocheck
export * from './ota.config';
export * from './ota.service';
export { default as useOtaUpdate } from './useOtaUpdate';
export { default as ModalUpdateOta } from './ModalUpdateOta';
export { default as AppVersion, AppStatus } from './AppVersion';
export * from './AppVersion';
Mount <ModalUpdateOta /> once globally in your Root Component (App.tsx / MainApp.tsx). Then mount <AppVersion /> on your Login Screen (fixed absolute) or Profile / Settings Screen (flow inline):
import React from 'react';
import { RootProvider } from '@/providers';
import { NavigationContainer } from '@react-navigation/native';
import { ModalUpdateOta } from '@/ota'; // or './src/ota'
export default function App() {
return (
<RootProvider>
<NavigationContainer>
{/* Your existing application navigators and screens */}
</NavigationContainer>
{/* Global OTA Update Modal (Mounted once at root) */}
<ModalUpdateOta />
</RootProvider>
);
}
import React from 'react';
import { View, StyleSheet, KeyboardAvoidingView, Platform } from 'react-native';
import { AppVersion, AppStatus } from '@/ota';
export default function SignInScreen() {
return (
<View style={styles.container}>
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
{/* Login Screen Form */}
</KeyboardAvoidingView>
{/* Option A: Bottom Center Badge (Default - bottom: 40 absolute) */}
<AppVersion position="bottom" />
{/* Option B: Top-Right Header Badge (top: 40, right: 16 absolute) */}
{/* <AppVersion position="header" /> */}
{/* Option C: Ambient Indicator Dot Only (pulsing dot without text, ideal across any screen) */}
{/* <AppStatus position="header" /> */}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
});
import React from 'react';
import { View, Text, StyleSheet, ScrollView } from 'react-native';
import { AppVersion, useOtaUpdate } from '@/ota';
export default function ProfileScreen() {
// Option 1: Drop-in with position="inline" and custom style overrides
return (
<ScrollView style={styles.container}>
{/* User Info / Settings Items */}
{/* Version Item at bottom of settings */}
<View style={styles.versionRow}>
<Text style={styles.label}>App Version</Text>
<AppVersion
position="inline"
badgeStyle={styles.versionBadge}
textStyle={styles.versionText}
dotStyle={styles.versionDot}
/>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f8fafc', padding: 16 },
versionRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 14, borderTopWidth: 1, borderColor: '#e2e8f0', marginTop: 24 },
label: { fontSize: 14, color: '#334155', fontWeight: '500' },
versionBadge: { backgroundColor: '#f1f5f9', borderRadius: 8, paddingHorizontal: 10, paddingVertical: 4 },
versionText: { fontSize: 13, color: '#0f172a', fontWeight: '600' },
versionDot: { width: 8, height: 8 },
});
To prevent unauthorized bundle deployment and guarantee update provenance (like an Android release.keystore), create an asymmetric RSA-2048 keypair for this specific application.
certs/ota-release-key.pem): Stored locally in the mobile app directory and automatically ignored by .gitignore. The OTA server never stores or sees this key! Only developers or CI/CD pipelines possessing this private key can sign and publish updates.src/ota/certs/ota-public-key.pem): Committed to git and compiled directly into your native app binary so mobile devices verify the cryptographic signature before activating updates.OTA_SIGNING_PRIVATE_KEY.# Generate isolated keypair directly into target mobile application:
npm run ota:keys -- --target=/path/to/your-react-native-app
# 1. Generate RSA-2048 keypair (Private in certs/, Public in src/ota/certs/)
node -e "const crypto=require('crypto'),fs=require('fs');const {publicKey,privateKey}=crypto.generateKeyPairSync('rsa',{modulusLength:2048,publicKeyEncoding:{type:'spki',format:'pem'},privateKeyEncoding:{type:'pkcs8',format:'pem'}});fs.mkdirSync('certs',{recursive:true});fs.writeFileSync('certs/ota-release-key.pem',privateKey,{mode:0o600});fs.mkdirSync('src/ota/certs',{recursive:true});fs.writeFileSync('src/ota/certs/ota-public-key.pem',publicKey);console.log('✔ Generated certs/ota-release-key.pem & src/ota/certs/ota-public-key.pem');"
# 2. Add private keys to .gitignore (NEVER COMMIT PRIVATE KEYS)
echo -e "
# SkyDev OTA Release Private Key (Keep secret)
certs/ota-release-key.pem
ota-release-key.pem
*.pem" >> .gitignore
There are three ways to deploy an OTA update manually, depending on your workflow:
ota-deploy CLI (Fastest)Run directly from your React Native project root or anywhere on your machine:
# Deploy iOS
ota-deploy --app=salonx-owner --platform=ios --target-version=1.0.0 --target=. --message="Fix login layout"
# Deploy Android
ota-deploy --app=salonx-owner --platform=android --target-version=1.0.0 --target=. --message="Fix login layout"
# Deploy Both Simultaneously
ota-deploy --app=salonx-owner --platform=all --target-version=1.0.0 --target=. --message="Production update"
tools/ota-internal workspace# Deploy real bundle from mobile directory
npm run ota:deploy -- --app=salonx-owner --platform=ios --target-version=1.0.0 --target=/path/to/your/app --message="Release notes"
# Deploy fast synthetic mock bundle (for pipeline testing)
npm run ota:deploy -- --app=salonx-owner --platform=android --target-version=1.0.0 --mock
If you ever want to package and release without any CLI tools, here is the complete 5-step lifecycle:
npx react-native bundle --platform <android|ios> --dev false --entry-file index.js --bundle-output dist/index.<android.bundle|ios.bundle> --assets-dest dist/assets/ / res/ folder, and hot-updater-manifest.json (with appId, platform, targetVersion).ota-storage/salonx-owner/bundles/<bundleId>.zip via S3 API (Port 8490).ota_salonx_owner table bundles with file_hash, storage_uri, target_app_version, enabled=true.POST https://ota-api.skydev.vn:8401/apps/salonx-owner/cache/invalidate.