react-native-bspatch 1.1.4 → 2.0.1

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 (89) hide show
  1. package/README.md +22 -32
  2. package/android/src/main/java/vn/reactnativebspatch/BsPatchModule.kt +15 -102
  3. package/android/src/main/java/vn/reactnativebspatch/OtaBundleResolver.kt +21 -1
  4. package/cpp/bsdiff/bspatch.c +1 -0
  5. package/ios/OtaBundleResolver.swift +16 -1
  6. package/ios/RNBsPatch.mm +61 -154
  7. package/lib/commonjs/NativeBsPatch.js.map +1 -1
  8. package/lib/commonjs/index.js +4 -4
  9. package/lib/commonjs/index.js.map +1 -1
  10. package/lib/commonjs/ota/OtaProvider.js +26 -22
  11. package/lib/commonjs/ota/OtaProvider.js.map +1 -1
  12. package/lib/commonjs/ota/otaAssetResolver.js +8 -2
  13. package/lib/commonjs/ota/otaAssetResolver.js.map +1 -1
  14. package/lib/commonjs/ota/otaCore.js +89 -141
  15. package/lib/commonjs/ota/otaCore.js.map +1 -1
  16. package/lib/commonjs/ota/otaLogger.js +0 -2
  17. package/lib/commonjs/ota/otaLogger.js.map +1 -1
  18. package/lib/commonjs/ota/otaMetadata.js +18 -7
  19. package/lib/commonjs/ota/otaMetadata.js.map +1 -1
  20. package/lib/commonjs/ota/storage/GitHubRawStorage.js +3 -1
  21. package/lib/commonjs/ota/storage/GitHubRawStorage.js.map +1 -1
  22. package/lib/commonjs/ota/types.js +1 -10
  23. package/lib/commonjs/ota/types.js.map +1 -1
  24. package/lib/module/NativeBsPatch.js.map +1 -1
  25. package/lib/module/index.js +1 -1
  26. package/lib/module/index.js.map +1 -1
  27. package/lib/module/ota/OtaProvider.js +26 -22
  28. package/lib/module/ota/OtaProvider.js.map +1 -1
  29. package/lib/module/ota/otaAssetResolver.js +8 -2
  30. package/lib/module/ota/otaAssetResolver.js.map +1 -1
  31. package/lib/module/ota/otaCore.js +88 -139
  32. package/lib/module/ota/otaCore.js.map +1 -1
  33. package/lib/module/ota/otaLogger.js +0 -2
  34. package/lib/module/ota/otaLogger.js.map +1 -1
  35. package/lib/module/ota/otaMetadata.js +18 -7
  36. package/lib/module/ota/otaMetadata.js.map +1 -1
  37. package/lib/module/ota/storage/GitHubRawStorage.js +3 -1
  38. package/lib/module/ota/storage/GitHubRawStorage.js.map +1 -1
  39. package/lib/module/ota/types.js +0 -9
  40. package/lib/module/ota/types.js.map +1 -1
  41. package/lib/typescript/NativeBsPatch.d.ts +3 -15
  42. package/lib/typescript/NativeBsPatch.d.ts.map +1 -1
  43. package/lib/typescript/index.d.ts +1 -1
  44. package/lib/typescript/index.d.ts.map +1 -1
  45. package/lib/typescript/ota/OtaProvider.d.ts.map +1 -1
  46. package/lib/typescript/ota/otaAssetResolver.d.ts.map +1 -1
  47. package/lib/typescript/ota/otaCore.d.ts +5 -42
  48. package/lib/typescript/ota/otaCore.d.ts.map +1 -1
  49. package/lib/typescript/ota/otaLogger.d.ts +1 -3
  50. package/lib/typescript/ota/otaLogger.d.ts.map +1 -1
  51. package/lib/typescript/ota/otaMetadata.d.ts +2 -2
  52. package/lib/typescript/ota/otaMetadata.d.ts.map +1 -1
  53. package/lib/typescript/ota/types.d.ts +18 -54
  54. package/lib/typescript/ota/types.d.ts.map +1 -1
  55. package/package.json +3 -6
  56. package/react-native-bspatch.podspec +0 -1
  57. package/src/NativeBsPatch.ts +3 -16
  58. package/src/index.ts +1 -1
  59. package/src/ota/OtaProvider.tsx +24 -23
  60. package/src/ota/otaAssetResolver.ts +5 -2
  61. package/src/ota/otaCore.ts +118 -176
  62. package/src/ota/otaLogger.ts +0 -2
  63. package/src/ota/otaMetadata.ts +18 -8
  64. package/src/ota/storage/GitHubRawStorage.ts +1 -1
  65. package/src/ota/types.ts +18 -57
  66. package/tools/build_ota.sh +117 -183
  67. package/tools/setup_project.js +33 -14
  68. package/tools/update_registry.js +24 -28
  69. package/tools/{upload_ota_custom.sh → upload_ota.sh} +33 -114
  70. package/lib/commonjs/crypto-js.d.js +0 -2
  71. package/lib/commonjs/crypto-js.d.js.map +0 -1
  72. package/lib/commonjs/ota/storage/CustomStorage.js +0 -160
  73. package/lib/commonjs/ota/storage/CustomStorage.js.map +0 -1
  74. package/lib/commonjs/ota/storage/GitHubReleaseStorage.js +0 -99
  75. package/lib/commonjs/ota/storage/GitHubReleaseStorage.js.map +0 -1
  76. package/lib/module/crypto-js.d.js +0 -2
  77. package/lib/module/crypto-js.d.js.map +0 -1
  78. package/lib/module/ota/storage/CustomStorage.js +0 -154
  79. package/lib/module/ota/storage/CustomStorage.js.map +0 -1
  80. package/lib/module/ota/storage/GitHubReleaseStorage.js +0 -93
  81. package/lib/module/ota/storage/GitHubReleaseStorage.js.map +0 -1
  82. package/lib/typescript/ota/storage/CustomStorage.d.ts +0 -12
  83. package/lib/typescript/ota/storage/CustomStorage.d.ts.map +0 -1
  84. package/lib/typescript/ota/storage/GitHubReleaseStorage.d.ts +0 -20
  85. package/lib/typescript/ota/storage/GitHubReleaseStorage.d.ts.map +0 -1
  86. package/src/crypto-js.d.ts +0 -1
  87. package/src/ota/storage/CustomStorage.ts +0 -166
  88. package/src/ota/storage/GitHubReleaseStorage.ts +0 -103
  89. package/tools/bsdiff +0 -0
package/src/ota/types.ts CHANGED
@@ -28,15 +28,6 @@ export enum OtaUpdateMode {
28
28
  BACKGROUND_DOWNLOAD = 'BACKGROUND_DOWNLOAD',
29
29
  }
30
30
 
31
- export enum OtaStorageType {
32
- /** Custom storage server (primary) */
33
- CUSTOM = 'CUSTOM',
34
- /** GitHub Releases — public or token-authenticated */
35
- GITHUB = 'GITHUB',
36
- /** GitHub Raw repository storage */
37
- GITHUB_RAW = 'GITHUB_RAW',
38
- }
39
-
40
31
  export type OtaPatch = {
41
32
  /** Unique ID: `v{from}-to-v{to}` */
42
33
  id: string;
@@ -60,10 +51,10 @@ export type OtaPatch = {
60
51
  minAppVersion?: string;
61
52
  /** URL for full bundle download (fallback when patch fails) */
62
53
  fullBundleUrl?: string;
63
- /** Whether the patch/bundle is gzip compressed */
64
- compressed?: boolean;
65
- /** HMAC-SHA256 signature for security verification */
54
+ /** RSA/ECDSA SHA-256 signature (base64) of the patch file */
66
55
  signature?: string;
56
+ /** RSA/ECDSA SHA-256 signature (base64) of the resulting bundle */
57
+ bundleSignature?: string;
67
58
  /** Whether this update is mandatory */
68
59
  mandatory?: boolean;
69
60
  /** URL to the asset manifest JSON for this version */
@@ -100,6 +91,10 @@ export type OtaMetadata = {
100
91
  previousVersions: number[];
101
92
  rollbackCount: number;
102
93
  lastLaunchSuccessful: boolean;
94
+ /** Native app version the OTA bundles were built for; native resolver drops OTA state when it changes */
95
+ appVersion?: string;
96
+ /** Versions that crashed on boot and were rolled back; never re-applied */
97
+ failedVersions?: number[];
103
98
  };
104
99
 
105
100
  export type OtaMetrics = {
@@ -136,59 +131,25 @@ export type OtaConfig = {
136
131
 
137
132
  /** Telemetry: structured event callback for analytics */
138
133
  onEvent?: OtaEventCallback;
139
- /** Public key for signature verification (base64) */
134
+ /**
135
+ * SPKI public key (PEM or base64) for RSA/ECDSA signature verification.
136
+ * When set, patches and bundles without a valid signature are rejected.
137
+ */
140
138
  publicKey?: string;
141
- /** Base URL for the shared hashed asset store (e.g. a GitHub release URL) */
142
- assetBaseUrl?: string;
143
-
144
- /** Storage strategy */
145
- storageType?: OtaStorageType;
146
-
147
- /** GitHub Releases configuration (used when storageType === GITHUB) */
148
- githubRelease?: {
149
- /** Owner / repo in the format "owner/repo" */
150
- repo: string;
151
- /** Tag name of the release to fetch updates from */
152
- tag: string;
153
- /**
154
- * Path inside the release where ota_registry.json lives.
155
- * If your release contains the file at root, set to "ota_registry.json".
156
- * If it's inside a folder "ota/", set to "ota/ota_registry.json".
157
- */
158
- registryPath?: string;
159
- /**
160
- * Optional GitHub personal access token for private repos or higher rate limits.
161
- * For public repos this is optional.
162
- */
163
- token?: string;
164
- /**
165
- * Asset base URL for content-addressed assets.
166
- * Supports CDN URLs (jsDelivr, GitHub raw) or a custom base.
167
- * Example: "https://cdn.jsdelivr.net/gh/owner/repo@tag/assets"
168
- */
169
- assetBaseUrl?: string;
170
- };
171
139
 
172
- /** Custom storage server configuration */
140
+ /**
141
+ * Static storage layout read by the default provider: `${baseUrl}/${baseFolder}/...`.
142
+ * Any static host/CDN serving the same layout works (e.g. a public bucket).
143
+ * Read-only and public: never put upload credentials here, they ship inside the app.
144
+ */
173
145
  customServer?: {
146
+ /** e.g. https://raw.githubusercontent.com/<owner>/<repo>/<branch> */
174
147
  baseUrl: string;
175
- username: string;
176
- password: string;
177
148
  channel: string;
178
149
  baseFolder?: string;
179
- useMinio?: boolean;
180
- /** Override default API endpoints for non-standard servers */
181
- endpoints?: {
182
- /** Auth login endpoint (default: /public/authen/login) */
183
- login?: string;
184
- /** Generate download link endpoint (default: /api/minio/gen-download-link) */
185
- genDownloadLink?: string;
186
- /** Direct file download endpoint (default: /files/downloadStreamFile) */
187
- downloadFile?: string;
188
- };
189
150
  };
190
151
 
191
- /** Storage provider instance (optional, defaults to CustomStorageProvider) */
152
+ /** Storage provider instance (optional, defaults to GitHubRawStorageProvider) */
192
153
  storage?: OtaStorageProvider;
193
154
  };
194
155
 
@@ -13,7 +13,14 @@
13
13
  # ─────────────────────────────────────────────────────────────────────────────
14
14
 
15
15
  set -e
16
- SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
16
+ # Resolve symlinks: npx runs us via node_modules/.bin/react-native-bspatch
17
+ SOURCE="$0"
18
+ while [ -L "$SOURCE" ]; do
19
+ LINK_DIR="$(cd "$(dirname "$SOURCE")" && pwd)"
20
+ SOURCE="$(readlink "$SOURCE")"
21
+ case "$SOURCE" in /*) ;; *) SOURCE="$LINK_DIR/$SOURCE" ;; esac
22
+ done
23
+ SCRIPT_DIR="$(cd "$(dirname "$SOURCE")" && pwd)"
17
24
  MODULE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
18
25
  ROOT="$PWD"
19
26
  BUNDLE_XCODE_IOS="$ROOT/ios/output/main.jsbundle"
@@ -30,10 +37,7 @@ fi
30
37
 
31
38
  if [ -n "$ENV_FILE" ]; then
32
39
  echo "Loading environment variables from $(basename "$ENV_FILE")..."
33
- export $(grep -v '^#' "$ENV_FILE" | grep -v '^$' | xargs)
34
- # Debug: verify loaded variables
35
- echo " Loaded OTA_UPLOAD_USER=${OTA_UPLOAD_USER:+[SET]}"
36
- echo " Loaded OTA_UPLOAD_PASSWORD=${OTA_UPLOAD_PASSWORD:+[SET]}"
40
+ set -a; . "$ENV_FILE"; set +a
37
41
  else
38
42
  echo " No .env file found in $ROOT or $MODULE_DIR"
39
43
  fi
@@ -75,7 +79,7 @@ while [[ "$#" -gt 0 ]]; do
75
79
  elif [[ "$1" == *.ipa ]]; then
76
80
  SEED_IPA="$1"
77
81
  ACTION="seed"
78
- elif [[ "$1" == *.hbc ]] || [[ "$1" == *.bundle ]]; then
82
+ elif [[ "$1" == *.hbc ]] || [[ "$1" == *.bundle ]] || [[ "$1" == *.jsbundle ]]; then
79
83
  SEED_BUNDLE="$1"
80
84
  ACTION="seed"
81
85
  fi
@@ -88,7 +92,14 @@ FLAVOR_LOWER=$(echo "$FLAVOR" | tr '[:upper:]' '[:lower:]')
88
92
  if [ "$FLAVOR_LOWER" = "dev" ]; then FLAVOR_LOWER="development"; fi
89
93
  if [ "$FLAVOR_LOWER" = "prod" ]; then FLAVOR_LOWER="production"; fi
90
94
 
91
- # ── Setup ──────────────────────────────────────────────────────────────────
95
+ # ── Setup (project wiring only, no build env needed) ─────────────────────────
96
+ if [ "$ACTION" = "setup" ]; then
97
+ echo "Setting up react-native-bspatch in this project..."
98
+ node "$SCRIPT_DIR/setup_project.js" --root "$ROOT"
99
+ exit 0
100
+ fi
101
+
102
+ # ── Build environment ──────────────────────────────────────────────────────
92
103
  echo "Preparing build environment..."
93
104
  # Kill any existing Metro processes to prevent file locks
94
105
  pkill -f "react-native/local-cli/cli.js" || true
@@ -107,156 +118,65 @@ mkdir -p "$ROOT/ios/output"
107
118
  REGISTRY_SHARED="$ROOT/ota_registry.json"
108
119
 
109
120
  # ── Registry Sync ──────────────────────────────────────────────────────────
110
- # Skip registry sync for reset/clean actions
111
- if [ "$ACTION" != "reset" ]; then
121
+ # Reset syncs too: it edits the latest remote registry instead of overwriting it with a stale local copy
122
+ if [ "$ACTION" != "setup" ]; then
112
123
  echo "Syncing remote registry..."
113
124
 
114
- BASE_URL="${OTA_BASE_URL:-https://sync-server.osp.com.vn}"
115
- UPCODE_USER="${OTA_UPLOAD_USER:-}"
116
- UPCODE_PASS="${OTA_UPLOAD_PASSWORD:-}"
125
+ BASE_URL="${OTA_BASE_URL:-}"
117
126
  BASE_FOLDER="${OTA_FOLDER:-ota}"
118
127
 
119
128
  echo " Using URL: $BASE_URL"
120
129
  echo " Using Folder: $BASE_FOLDER"
121
130
 
122
- if [[ "$BASE_URL" == *"github"* ]] || [[ "$BASE_URL" == *"raw.githubusercontent"* ]]; then
123
- echo " Downloading remote registry from GitHub..."
124
-
125
- # Parse OWNER/REPO/BRANCH from the raw.githubusercontent URL or github.com URL
126
- # e.g. https://raw.githubusercontent.com/owner/repo/branch OR
127
- # https://github.com/owner/repo/tree/branch
128
- CLEAN_URL=$(echo "$BASE_URL" | sed 's#https://raw.githubusercontent.com/##' | sed 's#https://github.com/##' | sed 's#/tree/##')
129
- GH_OWNER=$(echo "$CLEAN_URL" | cut -d'/' -f1)
130
- GH_REPO=$(echo "$CLEAN_URL" | cut -d'/' -f2)
131
- GH_BRANCH=$(echo "$CLEAN_URL" | cut -d'/' -f3)
132
- if [[ -z "$GH_BRANCH" ]]; then GH_BRANCH="main"; fi
133
-
134
- # Prefer GitHub CLI token for authenticated requests (higher rate limit, no CDN cache)
135
- GH_CLI_TOKEN=$(gh auth token 2>/dev/null || true)
136
- GH_API_TOKEN="${GH_CLI_TOKEN:-${GH_TOKEN:-${GITHUB_TOKEN:-}}}"
137
-
138
- # Use GitHub Contents API — NOT raw CDN — to bypass the 5-min CDN cache.
139
- # The API always returns the latest committed content.
140
- REGISTRY_API_URL="https://api.github.com/repos/${GH_OWNER}/${GH_REPO}/contents/${BASE_FOLDER}/ota_registry.json?ref=${GH_BRANCH}"
141
-
142
- API_HTTP_FILE=$(mktemp)
143
- API_BODY_FILE=$(mktemp)
144
-
145
- if [[ -n "$GH_API_TOKEN" ]]; then
146
- curl -s -o "$API_BODY_FILE" -w "%{http_code}" \
147
- -H "Authorization: Bearer $GH_API_TOKEN" \
148
- -H "Accept: application/vnd.github.v3+json" \
149
- "$REGISTRY_API_URL" > "$API_HTTP_FILE" 2>/dev/null || true
150
- else
151
- curl -s -o "$API_BODY_FILE" -w "%{http_code}" \
152
- -H "Accept: application/vnd.github.v3+json" \
153
- "$REGISTRY_API_URL" > "$API_HTTP_FILE" 2>/dev/null || true
154
- fi
155
-
156
- API_HTTP=$(cat "$API_HTTP_FILE" 2>/dev/null || echo "0")
157
- rm -f "$API_HTTP_FILE"
158
-
159
- if [[ "$API_HTTP" = "200" ]]; then
160
- # Decode base64 content returned by the GitHub Contents API in one python3 pass
161
- python3 - "$API_BODY_FILE" "$REGISTRY_SHARED.tmp" << 'PYEOF'
162
- import sys, json, base64
163
- body_file, out_file = sys.argv[1], sys.argv[2]
164
- try:
165
- with open(body_file, 'r') as f:
166
- d = json.load(f)
167
- content = d.get('content', '')
168
- decoded = base64.b64decode(content.replace('\n', ''))
169
- with open(out_file, 'wb') as f:
170
- f.write(decoded)
171
- except Exception as e:
172
- sys.exit(1)
173
- PYEOF
174
- rm -f "$API_BODY_FILE"
175
-
176
- if [ -s "$REGISTRY_SHARED.tmp" ]; then
177
- mv "$REGISTRY_SHARED.tmp" "$REGISTRY_SHARED"
178
- echo " ✓ Remote registry synced (via GitHub API)"
179
- else
180
- rm -f "$REGISTRY_SHARED.tmp"
181
- echo " Notice: Remote registry empty or decode failed (starting fresh with local state)."
182
- fi
183
- else
184
- rm -f "$API_BODY_FILE" "$REGISTRY_SHARED.tmp"
185
- echo " Notice: Remote registry not found on GitHub API (HTTP $API_HTTP — starting fresh with local state)."
186
- fi
187
-
131
+ # GitHub is the only storage backend for now
132
+ if [[ "$BASE_URL" != "https://raw.githubusercontent.com/"* ]]; then
133
+ echo " ❌ OTA_BASE_URL must be https://raw.githubusercontent.com/<owner>/<repo>/<branch>"
134
+ exit 1
135
+ fi
136
+ echo " Downloading remote registry from GitHub..."
137
+
138
+ CLEAN_URL="${BASE_URL#https://raw.githubusercontent.com/}"
139
+ GH_OWNER=$(echo "$CLEAN_URL" | cut -d'/' -f1)
140
+ GH_REPO=$(echo "$CLEAN_URL" | cut -d'/' -f2)
141
+ GH_BRANCH=$(echo "$CLEAN_URL" | cut -d'/' -f3)
142
+ if [[ -z "$GH_BRANCH" ]]; then GH_BRANCH="main"; fi
143
+
144
+ # Contents API (not the raw CDN) always returns the latest commit; the raw
145
+ # media type has no 1 MB limit and needs no base64 decoding.
146
+ GH_API_TOKEN="$(gh auth token 2>/dev/null || true)"
147
+ GH_API_TOKEN="${GH_API_TOKEN:-${GH_TOKEN:-${GITHUB_TOKEN:-}}}"
148
+ GH_API_AUTH=()
149
+ if [[ -n "$GH_API_TOKEN" ]]; then GH_API_AUTH=(-H "Authorization: Bearer $GH_API_TOKEN"); fi
150
+
151
+ API_HTTP=$(curl -s -o "$REGISTRY_SHARED.tmp" -w "%{http_code}" "${GH_API_AUTH[@]}" \
152
+ -H "Accept: application/vnd.github.raw" \
153
+ "https://api.github.com/repos/${GH_OWNER}/${GH_REPO}/contents/${BASE_FOLDER}/ota_registry.json?ref=${GH_BRANCH}" || echo 0)
154
+
155
+ if [[ "$API_HTTP" == "200" ]] && node -e "JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'))" "$REGISTRY_SHARED.tmp" 2>/dev/null; then
156
+ mv "$REGISTRY_SHARED.tmp" "$REGISTRY_SHARED"
157
+ echo " ✓ Remote registry synced (via GitHub API)"
158
+ elif [[ "$API_HTTP" == "404" ]]; then
159
+ rm -f "$REGISTRY_SHARED.tmp"
160
+ echo " Notice: No remote registry yet (HTTP 404) — starting fresh with local state."
188
161
  else
189
- if [[ -n "$UPCODE_USER" ]] && [[ -n "$UPCODE_PASS" ]]; then
190
- LOGIN_RES=$(curl -s -X "POST" "$BASE_URL/public/authen/login" \
191
- -H 'Content-Type: application/json; charset=utf-8' \
192
- -w "\n---HTTP_STATUS:%{http_code}" \
193
- -d "{\"username\": \"$UPCODE_USER\", \"password\": \"$UPCODE_PASS\"}")
194
- HTTP_STATUS=$(echo "$LOGIN_RES" | grep -o 'HTTP_STATUS:[0-9]*' | cut -d: -f2)
195
- LOGIN_BODY=$(echo "$LOGIN_RES" | sed 's/---HTTP_STATUS:.*//')
196
-
197
- TOKEN=$(echo "$LOGIN_BODY" | node -e "
198
- try {
199
- const r = JSON.parse(require('fs').readFileSync(0, 'utf8'));
200
- console.log(r.data?.accessTokenInfo?.accessToken || '');
201
- } catch(e) { console.log(''); }
202
- ")
203
-
204
- if [[ -n "$TOKEN" ]]; then
205
- REGISTRY_REMOTE_PATH="$BASE_FOLDER/ota_registry.json"
206
- ENCODED_PATH=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$REGISTRY_REMOTE_PATH', safe=''))")
207
- GEN_LINK_URL="$BASE_URL/api/minio/gen-download-link?pathFile=$ENCODED_PATH"
208
- LINK_RES=$(curl -s -X "GET" "$GEN_LINK_URL" -H "Authorization: Bearer $TOKEN")
209
- REGISTRY_URL=$(echo "$LINK_RES" | node -e "
210
- try {
211
- const r = JSON.parse(require('fs').readFileSync(0, 'utf8'));
212
- console.log(r.data || '');
213
- } catch(e) { console.log(''); }
214
- ")
215
- if [[ -n "$REGISTRY_URL" ]]; then
216
- curl -s -o "$REGISTRY_SHARED" "$REGISTRY_URL"
217
- echo " ✓ Registry synced"
218
- fi
219
- else
220
- echo " ⚠ MinIO login skipped. Proceeding with local state."
221
- fi
222
- else
223
- echo " ⚠ No MinIO credentials. Proceeding with local state."
224
- fi
162
+ # Continuing would later push a stale local registry over the remote one
163
+ rm -f "$REGISTRY_SHARED.tmp"
164
+ echo " ❌ Registry sync failed (HTTP $API_HTTP). Check network / gh auth and retry."
165
+ exit 1
225
166
  fi
226
167
  fi
227
168
 
228
- # ── Helper: Download from Server ──────────────────────────────────────────────
229
- download_from_minio() {
169
+ # ── Helper: Download from remote storage ───────────────────────────────────────
170
+ download_remote() {
230
171
  local REMOTE_PATH="$1"
231
172
  local DEST="$2"
232
- local CB="?cb=$(date +%s)"
233
-
234
- if [[ "$BASE_URL" == *"github"* ]] || [[ "$BASE_URL" == *"raw.githubusercontent"* ]]; then
235
- # Use Cache-Control: no-cache + timestamp query to bypass GitHub CDN 5-min cache
236
- curl -s -f -H "Cache-Control: no-cache" -o "$DEST" "$BASE_URL/$REMOTE_PATH${CB}" 2>/dev/null || \
237
- curl -s -f -H "Cache-Control: no-cache" -o "$DEST" "$BASE_URL/$(basename "$REMOTE_PATH")${CB}" 2>/dev/null
238
- return $?
239
- fi
240
-
241
- if [[ -z "$TOKEN" ]]; then return 1; fi
242
-
243
- local ENCODED_PATH=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$REMOTE_PATH', safe=''))")
244
- local GEN_LINK_URL="$BASE_URL/api/minio/gen-download-link?pathFile=$ENCODED_PATH"
245
- local LINK_RES=$(curl -s -X "GET" "$GEN_LINK_URL" -H "Authorization: Bearer $TOKEN")
246
- local FILE_URL=$(echo "$LINK_RES" | node -e "
247
- try {
248
- const r = JSON.parse(require('fs').readFileSync(0, 'utf8'));
249
- console.log(r.data || '');
250
- } catch(e) { console.log(''); }
251
- ")
252
- if [[ -n "$FILE_URL" ]]; then
253
- # Use -f to return non-zero exit code on HTTP errors (like 404 Not Found)
254
- curl -s -f -o "$DEST" "$FILE_URL"
255
- if [ $? -eq 0 ]; then
256
- return 0
257
- fi
173
+ # no-cache + timestamp query bypasses the GitHub CDN 5-min cache
174
+ if curl -s -f -H "Cache-Control: no-cache" -o "$DEST.tmp" "$BASE_URL/$REMOTE_PATH?cb=$(date +%s)"; then
175
+ mv "$DEST.tmp" "$DEST"
176
+ else
177
+ rm -f "$DEST.tmp"
178
+ return 1
258
179
  fi
259
- return 1
260
180
  }
261
181
 
262
182
  # ── Helper: compute SHA-256 hash ──────────────────────────────────────────────
@@ -389,28 +309,23 @@ if [ "$ACTION" = "reset" ]; then
389
309
  echo '{"apps":{}}' > "$REGISTRY_SHARED"
390
310
  fi
391
311
 
392
- # Delete only output files belonging to this specific flavor
393
- find "$ROOT/android/output" -mindepth 1 -name "*_${FLAVOR_LOWER}.*" -not -name 'base-assets_*' -delete 2>/dev/null || true
394
- find "$ROOT/android/output" -mindepth 1 -name "*_${FLAVOR_LOWER}_*" -not -name 'base-assets_*' -delete 2>/dev/null || true
395
- find "$ROOT/ios/output" -mindepth 1 -name "*_${FLAVOR_LOWER}.*" -not -name 'base-assets_*' -delete 2>/dev/null || true
396
- find "$ROOT/ios/output" -mindepth 1 -name "*_${FLAVOR_LOWER}_*" -not -name 'base-assets_*' -delete 2>/dev/null || true
312
+ # Delete only this app version + flavor. Base bundles (v1 = store binary) are kept:
313
+ # the next build re-registers them automatically. Delete them by hand to re-seed.
314
+ for D in "$ROOT/android/output" "$ROOT/ios/output"; do
315
+ find "$D" -maxdepth 1 -type f -name "*_${APP_VERSION}_*" -name "*_${FLAVOR_LOWER}[._]*" \
316
+ -not -name "bundle_${APP_VERSION}_v1_*" -delete 2>/dev/null || true
317
+ find "$D" -maxdepth 1 -type f -name "asset-manifest-v*_${FLAVOR_LOWER}.json" -delete 2>/dev/null || true # legacy names
318
+ done
397
319
 
398
- echo " ✓ Registry and output files cleared for flavor '$FLAVOR_LOWER' (base-assets preserved)"
320
+ echo " ✓ Registry and output cleared for $APP_VERSION/$FLAVOR_LOWER (base bundle v1 + base-assets kept)"
399
321
 
400
322
  if [ "$PUSH_REMOTE" = true ]; then
401
323
  echo " Syncing reset state for flavor '$FLAVOR_LOWER' to remote storage..."
402
- bash "$SCRIPT_DIR/upload_ota_custom.sh" "$APP_VERSION" "$FLAVOR_LOWER" "0" "" "ios" "--clean-remote"
324
+ bash "$SCRIPT_DIR/upload_ota.sh" "$APP_VERSION" "$FLAVOR_LOWER" "0" "" "ios" "--clean-remote"
403
325
  fi
404
326
  exit 0
405
327
  fi
406
328
 
407
- # ── Setup ─────────────────────────────────────────────────────────────────────
408
- if [ "$ACTION" = "setup" ]; then
409
- echo "Setting up react-native-bspatch in this project..."
410
- node "$SCRIPT_DIR/setup_project.js" --root "$ROOT"
411
- exit 0
412
- fi
413
-
414
329
  # ── Ensure bsdiff compiled ───────────────────────────────────────────────────
415
330
  if [ ! -f "$BSDIFF" ]; then
416
331
  echo "Compiling bsdiff..."
@@ -439,15 +354,23 @@ build_platform() {
439
354
  local FLAVOR="$2"
440
355
  local CURRENT="$3"
441
356
  local NEXT="$4"
442
- local FLAVOR_CAP=$(echo "$FLAVOR" | perl -ne 'print ucfirst')
443
357
  local FLAVOR_LOWER=$(echo "$FLAVOR" | tr '[:upper:]' '[:lower:]')
444
-
358
+ if [ "$FLAVOR_LOWER" = "dev" ]; then FLAVOR_LOWER="development"; fi
359
+ if [ "$FLAVOR_LOWER" = "prod" ]; then FLAVOR_LOWER="production"; fi
360
+ local FLAVOR_CAP=$(echo "$FLAVOR_LOWER" | perl -ne 'print ucfirst')
361
+
445
362
  local OUTPUT="$ROOT/android/output"
446
363
  if [ "$PLATFORM" == "ios" ]; then OUTPUT="$ROOT/ios/output"; fi
447
364
  local REGISTRY="$REGISTRY_SHARED"
448
365
 
449
366
  echo ""
450
367
  echo ">>> Building $PLATFORM | Version: $APP_VERSION | OTA: v$CURRENT → v$NEXT"
368
+ if [ "$PLATFORM" == "ios" ]; then
369
+ local IOS_VER=$(grep -ho 'MARKETING_VERSION = [^;]*' "$ROOT"/ios/*.xcodeproj/project.pbxproj 2>/dev/null | head -n 1 | sed 's/.*= //')
370
+ if [ -n "$IOS_VER" ] && [ "$IOS_VER" != "$APP_VERSION" ]; then
371
+ echo " ⚠ iOS MARKETING_VERSION is $IOS_VER but building for $APP_VERSION — iOS clients on $IOS_VER won't see this OTA"
372
+ fi
373
+ fi
451
374
 
452
375
  # ── Bundle ──
453
376
  # Workspace for building
@@ -461,7 +384,15 @@ build_platform() {
461
384
  cd "$ROOT"
462
385
  BUNDLE_SOURCE="$ROOT/android/app/build/generated/assets/react/${FLAVOR_LOWER}Release/index.android.bundle"
463
386
  else
464
- if [ -f "$BUNDLE_XCODE_IOS" ] && [ "$ACTION" = "base" ] && [ $CURRENT -eq 0 ]; then
387
+ local XCODE_STALE=""
388
+ if [ -f "$BUNDLE_XCODE_IOS" ]; then
389
+ # ponytail: mtime check, any source edit/checkout after the last Xcode build marks it stale
390
+ XCODE_STALE=$(find "$ROOT/src" "$ROOT/App.tsx" "$ROOT/index.js" "$ROOT/package.json" -type f -newer "$BUNDLE_XCODE_IOS" -print -quit 2>/dev/null)
391
+ if [ -n "$XCODE_STALE" ] && [ "$ACTION" = "base" ]; then
392
+ echo " ⚠ Xcode bundle ($(stat -f '%Sm' "$BUNDLE_XCODE_IOS")) is older than $XCODE_STALE — ignoring it"
393
+ fi
394
+ fi
395
+ if [ -f "$BUNDLE_XCODE_IOS" ] && [ -z "$XCODE_STALE" ] && [ "$ACTION" = "base" ] && [ $CURRENT -eq 0 ]; then
465
396
  # Base action: use Xcode-compiled bundle (already Hermes-compiled, matches IPA exactly)
466
397
  echo " Using Xcode bundle as base source..."
467
398
  cp "$BUNDLE_XCODE_IOS" "$WORKSPACE/source.bundle"
@@ -492,7 +423,11 @@ build_platform() {
492
423
  echo " Bundle is already in Hermes bytecode format."
493
424
  else
494
425
  echo " Compiling to Hermes bytecode..."
495
- "$HERMESC_PATH" -emit-binary -out "$WORKSPACE/source.hbc" "$BUNDLE_SOURCE"
426
+ if [ -z "$HERMESC_PATH" ] || [ ! -f "$HERMESC_PATH" ]; then
427
+ echo " ❌ ERROR: hermesc not found"; exit 1
428
+ fi
429
+ # -O matches what Xcode/Gradle use for release, keeping patches against the store bundle small
430
+ "$HERMESC_PATH" -emit-binary -O -out "$WORKSPACE/source.hbc" "$BUNDLE_SOURCE"
496
431
  mv "$WORKSPACE/source.hbc" "$BUNDLE_SOURCE"
497
432
  fi
498
433
 
@@ -517,7 +452,7 @@ build_platform() {
517
452
  fi
518
453
 
519
454
  # ── Assets ──
520
- local ASSET_MANIFEST_FILE="$OUTPUT/asset-manifest-v${NEXT}_${FLAVOR_LOWER}.json"
455
+ local ASSET_MANIFEST_FILE="$OUTPUT/asset-manifest_${APP_VERSION}_v${NEXT}_${PLATFORM}_${FLAVOR_LOWER}.json"
521
456
  local BASE_ASSET_MANIFEST="$OUTPUT/base-assets_${PLATFORM}_${FLAVOR_LOWER}.json"
522
457
  local ASSET_STORE_DIR="$OUTPUT/asset-store"
523
458
  mkdir -p "$ASSET_STORE_DIR"
@@ -525,20 +460,16 @@ build_platform() {
525
460
  if [ "$PLATFORM" == "ios" ]; then ASSETS_SRC_DIR="$OUTPUT/assets"; fi
526
461
 
527
462
  # Load base assets — try server if missing locally (e.g. after ota:clean)
528
- if [ ! -f "$BASE_ASSET_MANIFEST" ] && [ -n "$TOKEN" ]; then
463
+ if [ ! -f "$BASE_ASSET_MANIFEST" ]; then
529
464
  local REMOTE_BASE_ASSET_PATH="$BASE_FOLDER/$APP_VERSION/$FLAVOR_LOWER/$(basename "$BASE_ASSET_MANIFEST")"
530
465
  echo " Base asset manifest missing locally, downloading from server..."
531
- download_from_minio "$REMOTE_BASE_ASSET_PATH" "$BASE_ASSET_MANIFEST" || true
466
+ download_remote "$REMOTE_BASE_ASSET_PATH" "$BASE_ASSET_MANIFEST" || true
532
467
  if [ -f "$BASE_ASSET_MANIFEST" ]; then
533
468
  echo " ✓ base-assets manifest restored from server"
534
469
  else
535
470
  echo " ⚠ base-assets manifest not found on server — all assets will be treated as new"
536
471
  fi
537
472
  fi
538
- local BASE_ASSETS_JSON="{}"
539
- if [ -f "$BASE_ASSET_MANIFEST" ]; then
540
- BASE_ASSETS_JSON=$(cat "$BASE_ASSET_MANIFEST")
541
- fi
542
473
 
543
474
  local ASSET_JSON_LIST="["
544
475
  local FIRST=true
@@ -557,12 +488,10 @@ build_platform() {
557
488
  NEW_BASE_ASSETS+="\"$REL_PATH\": \"$HASH\""
558
489
 
559
490
  # Optimization: Skip if hash matches the base APK version (client already has it)
560
- local IS_IN_BASE=$(node -e "
561
- try {
562
- const base = JSON.parse(require('fs').readFileSync('$BASE_ASSET_MANIFEST', 'utf8'));
563
- console.log(base['$REL_PATH'] === '$HASH' ? 'true' : 'false');
564
- } catch(e) { console.log('false'); }
565
- ")
491
+ local IS_IN_BASE=false
492
+ if [ -f "$BASE_ASSET_MANIFEST" ] && grep -qF "\"$REL_PATH\": \"$HASH\"" "$BASE_ASSET_MANIFEST"; then
493
+ IS_IN_BASE=true
494
+ fi
566
495
  # Always add ALL assets to the manifest so the resolver can find them at runtime.
567
496
  # Only skip the file copy if the asset is unchanged from base (avoids redundant uploads).
568
497
  local ASSET_IS_BASE="false"
@@ -594,13 +523,16 @@ EOF
594
523
  local NEW_HASH=$(sha256 "$NEW_BUNDLE_PATH")
595
524
  if [ $CURRENT -gt 0 ]; then
596
525
  PATCHES_JSON="["
526
+ # Patches from v1 (fresh store installs) + the last 5 versions. Older clients
527
+ # fall back to the full bundle instead of bsdiff-ing every version each build.
597
528
  for (( i=1; i<$NEXT; i++ )); do
529
+ if [ $i -gt 1 ] && [ $i -lt $((NEXT - 5)) ]; then continue; fi
598
530
  local OLD_B="bundle_${APP_VERSION}_v${i}_${PLATFORM}_${FLAVOR_LOWER}.hbc"
599
531
  local OLD_P="$OUTPUT/$OLD_B"
600
532
  if [ ! -f "$OLD_P" ]; then
601
533
  echo " Missing local bundle for v$i. Attempting to download from server..."
602
534
  local REMOTE_BUNDLE_PATH="$BASE_FOLDER/$APP_VERSION/$FLAVOR_LOWER/$OLD_B"
603
- download_from_minio "$REMOTE_BUNDLE_PATH" "$OLD_P" || true
535
+ download_remote "$REMOTE_BUNDLE_PATH" "$OLD_P" || true
604
536
  fi
605
537
  if [ ! -f "$OLD_P" ]; then
606
538
  echo " ⚠ Warning: Could not find or download bundle v$i. Skipping patch generation from v$i."
@@ -616,7 +548,7 @@ EOF
616
548
 
617
549
  # ── Finalize ──
618
550
  node "$SCRIPT_DIR/update_registry.js" "$REGISTRY_SHARED" "$APP_VERSION" "$NEXT" "$PATCHES_JSON" "$PLATFORM" "$ASSET_MANIFEST_FILE" "$FLAVOR_LOWER"
619
- bash "$SCRIPT_DIR/upload_ota_custom.sh" "$APP_VERSION" "$FLAVOR_LOWER" "$NEXT" "$NEW_BUNDLE_PATH" "$PLATFORM"
551
+ bash "$SCRIPT_DIR/upload_ota.sh" "$APP_VERSION" "$FLAVOR_LOWER" "$NEXT" "$NEW_BUNDLE_PATH" "$PLATFORM"
620
552
  }
621
553
 
622
554
  seed_platform() {
@@ -642,7 +574,7 @@ seed_platform() {
642
574
 
643
575
  local TARGET_BUNDLE="$OUTPUT/bundle_${APP_VERSION}_v1_${PLATFORM}_${FLAVOR_LOWER}.hbc"
644
576
  local BASE_ASSET_MANIFEST="$OUTPUT/base-assets_${PLATFORM}_${FLAVOR_LOWER}.json"
645
- local ASSET_MANIFEST_FILE="$OUTPUT/asset-manifest-v1_${FLAVOR_LOWER}.json"
577
+ local ASSET_MANIFEST_FILE="$OUTPUT/asset-manifest_${APP_VERSION}_v1_${PLATFORM}_${FLAVOR_LOWER}.json"
646
578
  local TMP_DIR=$(mktemp -d)
647
579
 
648
580
  if [ "$PLATFORM" == "android" ]; then
@@ -666,7 +598,7 @@ seed_platform() {
666
598
  elif unzip -l "$BINARY_PATH" 2>/dev/null | grep -q "main.jsbundle"; then
667
599
  local ANY_BUNDLE=$(unzip -l "$BINARY_PATH" 2>/dev/null | grep -o '[^ ]*main\.jsbundle' | head -n 1)
668
600
  unzip -p "$BINARY_PATH" "$ANY_BUNDLE" > "$TMP_DIR/extracted.bundle"
669
- elif [[ "$BINARY_PATH" == *.bundle ]] || [[ "$BINARY_PATH" == *.hbc ]]; then
601
+ elif [[ "$BINARY_PATH" == *.bundle ]] || [[ "$BINARY_PATH" == *.hbc ]] || [[ "$BINARY_PATH" == *.jsbundle ]]; then
670
602
  cp "$BINARY_PATH" "$TMP_DIR/extracted.bundle"
671
603
  else
672
604
  echo " ❌ ERROR: Could not find 'main.jsbundle' inside $BINARY_PATH"
@@ -688,7 +620,7 @@ seed_platform() {
688
620
  rm -rf "$TMP_DIR"
689
621
  exit 1
690
622
  fi
691
- "$HERMESC_PATH" -emit-binary -out "$TARGET_BUNDLE" "$TMP_DIR/extracted.bundle"
623
+ "$HERMESC_PATH" -emit-binary -O -out "$TARGET_BUNDLE" "$TMP_DIR/extracted.bundle"
692
624
  fi
693
625
 
694
626
  # Extract base assets manifest
@@ -698,7 +630,7 @@ seed_platform() {
698
630
  local FIRST=true
699
631
 
700
632
  if [ "$PLATFORM" == "android" ]; then
701
- unzip -q -o "$BINARY_PATH" "res/drawable*/*" "res/raw/*" -d "$TMP_DIR/assets_unzipped" 2>/dev/null || true
633
+ unzip -q -o "$BINARY_PATH" "res/drawable*/*" "res/raw/*" "base/res/drawable*/*" "base/res/raw/*" -d "$TMP_DIR/assets_unzipped" 2>/dev/null || true
702
634
  else
703
635
  unzip -q -o "$BINARY_PATH" "Payload/*.app/assets/*" -d "$TMP_DIR/assets_unzipped" 2>/dev/null || true
704
636
  fi
@@ -709,7 +641,9 @@ seed_platform() {
709
641
  local FNAME=$(basename "$ASSET_FILE")
710
642
  local EX="${FNAME##*.}"
711
643
  local REL_PATH="${ASSET_FILE#$TMP_DIR/assets_unzipped/}"
712
-
644
+ # Same keys as build_platform: iOS "assets/...", Android "drawable-*/..." / "raw/..."
645
+ REL_PATH=$(echo "$REL_PATH" | sed -E 's#^Payload/[^/]+\.app/##; s#^(base/)?res/##')
646
+
713
647
  if [ "$NEW_BASE_ASSETS" != "{" ]; then NEW_BASE_ASSETS+=", "; fi
714
648
  NEW_BASE_ASSETS+="\"$REL_PATH\": \"$HASH\""
715
649
 
@@ -738,7 +672,7 @@ EOF
738
672
  # Push if requested
739
673
  if [ "$PUSH_REMOTE" = true ]; then
740
674
  echo " Pushing seeded base bundle to remote..."
741
- bash "$SCRIPT_DIR/upload_ota_custom.sh" "$APP_VERSION" "$FLAVOR_LOWER" "1" "$TARGET_BUNDLE" "$PLATFORM"
675
+ bash "$SCRIPT_DIR/upload_ota.sh" "$APP_VERSION" "$FLAVOR_LOWER" "1" "$TARGET_BUNDLE" "$PLATFORM"
742
676
  fi
743
677
 
744
678
  echo " ✓ Seeding complete for $PLATFORM!"
@@ -804,7 +738,7 @@ if [ "$ACTION" = "ota" ] && [ $CURRENT_TOTAL -eq 0 ]; then
804
738
  if [ ! -f "$P_BASE_BUNDLE" ]; then
805
739
  # Try downloading from remote if available
806
740
  REMOTE_BASE_PATH="$BASE_FOLDER/$APP_VERSION/$FLAVOR_LOWER/$(basename "$P_BASE_BUNDLE")"
807
- download_from_minio "$REMOTE_BASE_PATH" "$P_BASE_BUNDLE" || true
741
+ download_remote "$REMOTE_BASE_PATH" "$P_BASE_BUNDLE" || true
808
742
  fi
809
743
 
810
744
  if [ -f "$P_BASE_BUNDLE" ]; then