diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3602f72..daf6cc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,14 +4,26 @@ on: branches: - main - alpha - jobs: build-and-publish: runs-on: ubuntu-latest + # Prevent the app's own release commit from re-triggering this workflow + if: github.actor != 'stallion-release-bot[bot]' permissions: write-all steps: + - name: Generate app token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 + with: + token: ${{ steps.app-token.outputs.token }} + fetch-depth: 0 + persist-credentials: true - name: Setup uses: ./.github/actions/setup @@ -19,25 +31,13 @@ jobs: - name: Typecheck files run: yarn typecheck - - name: Checkout - uses: actions/checkout@v3 - - - name: Setup - uses: ./.github/actions/setup - - name: Run unit tests run: yarn test --maxWorkers=2 --coverage - - name: Checkout - uses: actions/checkout@v3 - - - name: Setup - uses: ./.github/actions/setup - - name: Setup git user run: | - git config --global user.name ${{ secrets.GH_DEPLOY_NAME }} - git config --global user.email ${{ secrets.GH_DEPLOY_EMAIL }} + git config --global user.name "${{ secrets.GH_DEPLOY_NAME }}" + git config --global user.email "${{ secrets.GH_DEPLOY_EMAIL }}" - name: Prep release run: yarn prepare-release @@ -45,7 +45,7 @@ jobs: - name: Release package run: yarn semantic-release env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} GIT_AUTHOR_EMAIL: ${{ secrets.GH_DEPLOY_EMAIL }} GIT_AUTHOR_NAME: ${{ secrets.GH_DEPLOY_NAME }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e06f10..a585bb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,34 @@ +# [2.5.0-alpha.1](https://github.com/stallion-tech/react-native-stallion/compare/v2.4.0...v2.5.0-alpha.1) (2026-06-07) + + +### Bug Fixes + +* added 16kb support for bspatch lib also ([#114](https://github.com/stallion-tech/react-native-stallion/issues/114)) ([017e79a](https://github.com/stallion-tech/react-native-stallion/commit/017e79a124f0043c54cab302a47b1e1833927b16)) +* alpha 2.3.1 release ([5890a27](https://github.com/stallion-tech/react-native-stallion/commit/5890a27f0e39e7f3b698d0f155e50196c36f335d)) +* android 16kb, removed react codegen block ([#91](https://github.com/stallion-tech/react-native-stallion/issues/91)) ([c92e6cd](https://github.com/stallion-tech/react-native-stallion/commit/c92e6cd4dea287643ad1d87262a71303af805d6b)) +* android sdk token expriy logic fixed ([#66](https://github.com/stallion-tech/react-native-stallion/issues/66)) ([6c8202f](https://github.com/stallion-tech/react-native-stallion/commit/6c8202fff4a46c9eaf066d793b402e9d9084bed3)) +* custom base url support ([#109](https://github.com/stallion-tech/react-native-stallion/issues/109)) ([5dc99ec](https://github.com/stallion-tech/react-native-stallion/commit/5dc99ec5f6bd73ae91f0a3ef8f8aa87ba0427e67)) +* exception handling ios newarch ([#53](https://github.com/stallion-tech/react-native-stallion/issues/53)) ([f0d454f](https://github.com/stallion-tech/react-native-stallion/commit/f0d454fe9ebf83bcbc2b09fd96bb17dd5b2a8a82)), closes [#49](https://github.com/stallion-tech/react-native-stallion/issues/49) +* getActiveBundleHash function added ([#101](https://github.com/stallion-tech/react-native-stallion/issues/101)) ([bb51fa5](https://github.com/stallion-tech/react-native-stallion/commit/bb51fa551b05b6eb3d4996e2db0d4c9b895a64d8)) +* ios precompiled deps fix pod deps installation ([#93](https://github.com/stallion-tech/react-native-stallion/issues/93)) ([0cdde47](https://github.com/stallion-tech/react-native-stallion/commit/0cdde4781b5e441e7b84803612e9949d5ccb07f7)) +* ios stage event typo ([c0c9bdb](https://github.com/stallion-tech/react-native-stallion/commit/c0c9bdbc4d15cad7934559fbef6a397dde4a6c50)) +* js error boundary, exception handler safety checks ([#86](https://github.com/stallion-tech/react-native-stallion/issues/86)) ([e7b9443](https://github.com/stallion-tech/react-native-stallion/commit/e7b94434f280f0160630b30890aced8ea36c3410)) +* modifed event emitter init, added fallback for mounting prod bundle ([#32](https://github.com/stallion-tech/react-native-stallion/issues/32)) ([f888820](https://github.com/stallion-tech/react-native-stallion/commit/f888820305f57bc76649673d289ff15949558ab8)) +* release v2.3.0-alpha.5 ([#75](https://github.com/stallion-tech/react-native-stallion/issues/75)) ([044a5ad](https://github.com/stallion-tech/react-native-stallion/commit/044a5addc9b36c153946e847ac9b34c59e5c5be5)) +* removed stallion enabled script reading logic from native, enabled by default ([ab3f0d4](https://github.com/stallion-tech/react-native-stallion/commit/ab3f0d4ba6d20f4277b0611c9a6783d49e44cb3f)) +* removeEventListener exported ([979640a](https://github.com/stallion-tech/react-native-stallion/commit/979640a8b485d8ab0bb03b5799fc176289429228)) + + +### Features + +* 2.4.0 alpha ([#96](https://github.com/stallion-tech/react-native-stallion/issues/96)) ([3430975](https://github.com/stallion-tech/react-native-stallion/commit/3430975b038b0d82f1549d104d7277ce2d540020)) +* added stream downloading for android ([#42](https://github.com/stallion-tech/react-native-stallion/issues/42)) ([3636acd](https://github.com/stallion-tech/react-native-stallion/commit/3636acd4d48c4ae2e5f5659e4f0a31239f745d4b)) +* bundle signing ([#44](https://github.com/stallion-tech/react-native-stallion/issues/44)) ([d4d8433](https://github.com/stallion-tech/react-native-stallion/commit/d4d84335409ef9b8b4eb5c223ef5217f4d0cd54f)), closes [#49](https://github.com/stallion-tech/react-native-stallion/issues/49) +* bundle signing 2.3.0, back merge ([b16b74d](https://github.com/stallion-tech/react-native-stallion/commit/b16b74d7bba4a07aa52af6a1864e2c971cea46ab)) +* restart logic, ui revamp v0, ios sync and other bugfixes ([70b8067](https://github.com/stallion-tech/react-native-stallion/commit/70b806753d2a3784686b93ebe15eef802d2665e8)) +* resume download android ([1471b57](https://github.com/stallion-tech/react-native-stallion/commit/1471b5780608df29437fbe8048205fb3537d2859)) + +# [2.4.0-alpha.7](https://github.com/stallion-tech/react-native-stallion/compare/v2.4.0-alpha.6...v2.4.0-alpha.7) (2026-06-06) # [2.4.0](https://github.com/stallion-tech/react-native-stallion/compare/v2.3.2...v2.4.0) (2026-05-12) @@ -8,6 +39,13 @@ # [2.4.0-alpha.6](https://github.com/stallion-tech/react-native-stallion/compare/v2.4.0-alpha.5...v2.4.0-alpha.6) (2026-04-24) +### Bug Fixes + +* custom base url support ([#109](https://github.com/stallion-tech/react-native-stallion/issues/109)) ([5dc99ec](https://github.com/stallion-tech/react-native-stallion/commit/5dc99ec5f6bd73ae91f0a3ef8f8aa87ba0427e67)) + +# [2.4.0-alpha.6](https://github.com/stallion-tech/react-native-stallion/compare/v2.4.0-alpha.5...v2.4.0-alpha.6) (2026-04-24) + + ### Bug Fixes * added 16kb support for bspatch lib also ([#114](https://github.com/stallion-tech/react-native-stallion/issues/114)) ([017e79a](https://github.com/stallion-tech/react-native-stallion/commit/017e79a124f0043c54cab302a47b1e1833927b16)) diff --git a/android/src/main/java/com/stallion/StallionModule.java b/android/src/main/java/com/stallion/StallionModule.java index 14dfa84..f3bf281 100644 --- a/android/src/main/java/com/stallion/StallionModule.java +++ b/android/src/main/java/com/stallion/StallionModule.java @@ -61,11 +61,21 @@ public String getName() { @ReactMethod public void onLaunch(String launchData) { - // try { - // JSONObject launchDataJson = new JSONObject(launchData); - // } catch (Exception e) { - // e.printStackTrace(); - // } + try { + String customBaseUrl = null; + if (launchData != null && !launchData.isEmpty()) { + JSONObject launchDataJson = new JSONObject(launchData); + if (launchDataJson.has("baseUrl") && !launchDataJson.isNull("baseUrl")) { + String baseUrl = launchDataJson.optString("baseUrl", ""); + if (!baseUrl.isEmpty()) { + customBaseUrl = baseUrl; + } + } + } + com.stallion.utils.StallionApiBaseUrl.set(customBaseUrl); + } catch (Exception e) { + e.printStackTrace(); + } stallionStateManager.setIsMounted(true); DeviceEventManagerModule.RCTDeviceEventEmitter eventEmitter = getReactApplicationContext().getJSModule( DeviceEventManagerModule.RCTDeviceEventEmitter.class diff --git a/android/src/main/java/com/stallion/networkmanager/StallionApiConstants.java b/android/src/main/java/com/stallion/networkmanager/StallionApiConstants.java index 2ba4b4f..8cbb565 100644 --- a/android/src/main/java/com/stallion/networkmanager/StallionApiConstants.java +++ b/android/src/main/java/com/stallion/networkmanager/StallionApiConstants.java @@ -22,7 +22,28 @@ public class StallionApiConstants { public static final String STALLION_SDK_TOKEN_KEY = "x-sdk-pin-access-token"; public static final String STALLION_DEVICE_ID_KEY = "uid"; + /** Legacy global API host; used only to detect implicit defaults in prefs. */ + public static final String LEGACY_DEFAULT_STALLION_API_BASE = "https://api.stalliontech.io"; - public static final String STALLION_API_BASE = "https://api.stalliontech.io"; + public static final String REGIONAL_API_BASE_AP = "https://api-ap.stalliontech.io"; + public static final String REGIONAL_API_BASE_US = "https://api-us.stalliontech.io"; + + /** @deprecated Use LEGACY_DEFAULT_STALLION_API_BASE or getStallionApiBase() */ + @Deprecated + public static final String DEFAULT_STALLION_API_BASE = LEGACY_DEFAULT_STALLION_API_BASE; + + /** + * Gets the API base URL from config or returns default + * @return String - The base URL to use + */ + public static String getStallionApiBase() { + return com.stallion.utils.StallionApiBaseUrl.get(); + } + + // Keep old constant for backward compatibility, but mark as deprecated + /** @deprecated Use getStallionApiBase() instead */ + @Deprecated + public static final String STALLION_API_BASE = DEFAULT_STALLION_API_BASE; + public static final String STALLION_INFO_API_PATH = "/api/v1/promoted/get-update-meta"; } diff --git a/android/src/main/java/com/stallion/networkmanager/StallionSyncHandler.java b/android/src/main/java/com/stallion/networkmanager/StallionSyncHandler.java index 512fbeb..b57fc83 100644 --- a/android/src/main/java/com/stallion/networkmanager/StallionSyncHandler.java +++ b/android/src/main/java/com/stallion/networkmanager/StallionSyncHandler.java @@ -50,7 +50,7 @@ public static void sync() { // Make API call using StallionApiManager JSONObject releaseMeta = StallionApiManager.post( - StallionApiConstants.STALLION_API_BASE + StallionApiConstants.STALLION_INFO_API_PATH, + StallionApiConstants.getStallionApiBase() + StallionApiConstants.STALLION_INFO_API_PATH, requestPayload.toString() ); diff --git a/android/src/main/java/com/stallion/storage/StallionConfig.java b/android/src/main/java/com/stallion/storage/StallionConfig.java index cc5d704..2b48e3b 100644 --- a/android/src/main/java/com/stallion/storage/StallionConfig.java +++ b/android/src/main/java/com/stallion/storage/StallionConfig.java @@ -11,6 +11,9 @@ import java.util.UUID; +import com.stallion.storage.StallionConfigConstants; +import com.stallion.utils.StallionApiBaseUrl; + public class StallionConfig { private String uid; private final String projectId; @@ -22,7 +25,7 @@ public class StallionConfig { private String lastDownloadingUrl; private String lastUnverifiedHash; private final String publicSigningKey; - + private String baseUrl; public StallionConfig(Context context, SharedPreferences sharedPreferences) { this.sharedPreferences = sharedPreferences; @@ -77,6 +80,8 @@ public StallionConfig(Context context, SharedPreferences sharedPreferences) { this.filesDirectory = context.getFilesDir().getAbsolutePath(); this.lastDownloadingUrl = sharedPreferences.getString(StallionConfigConstants.LAST_DOWNLOADING_URL_IDENTIFIER, ""); this.lastUnverifiedHash = sharedPreferences.getString(StallionConfigConstants.LAST_UNVERIFIED_HASH, ""); + String storedBaseUrl = sharedPreferences.getString(StallionConfigConstants.BASE_URL_IDENTIFIER, ""); + this.baseUrl = storedBaseUrl != null ? storedBaseUrl : ""; } public String getLastDownloadingUrl() { @@ -143,6 +148,23 @@ public String getPublicSigningKey() { return this.publicSigningKey; } + public String getBaseUrl() { + return this.baseUrl; + } + + public void setBaseUrl(String baseUrl) { + if (baseUrl == null || baseUrl.isEmpty()) { + this.baseUrl = ""; + sharedPreferences.edit().remove(StallionConfigConstants.BASE_URL_IDENTIFIER).apply(); + return; + } + this.baseUrl = baseUrl; + sharedPreferences + .edit() + .putString(StallionConfigConstants.BASE_URL_IDENTIFIER, this.baseUrl) + .apply(); + } + public JSONObject toJSON() { JSONObject configJson = new JSONObject(); try { @@ -151,6 +173,7 @@ public JSONObject toJSON() { configJson.put("appToken", this.appToken); configJson.put("sdkToken", this.sdkToken); configJson.put("appVersion", this.appVersion); + configJson.put("baseUrl", StallionApiBaseUrl.get()); return configJson; } catch (JSONException ignored) { return new JSONObject(); diff --git a/android/src/main/java/com/stallion/storage/StallionConfigConstants.java b/android/src/main/java/com/stallion/storage/StallionConfigConstants.java index 839b921..a70eb2c 100644 --- a/android/src/main/java/com/stallion/storage/StallionConfigConstants.java +++ b/android/src/main/java/com/stallion/storage/StallionConfigConstants.java @@ -12,6 +12,7 @@ public class StallionConfigConstants { public static final String API_KEY_IDENTIFIER = "x-sdk-access-token"; public static final String LAST_DOWNLOADING_URL_IDENTIFIER = "StallionLastDownloadingUrl"; public static final String LAST_UNVERIFIED_HASH = "LastUnverifiedHash"; + public static final String BASE_URL_IDENTIFIER = "StallionBaseUrl"; public static final String PROD_DIRECTORY = "/StallionProd"; public static final String STAGE_DIRECTORY = "/StallionStage"; diff --git a/android/src/main/java/com/stallion/utils/StallionApiBaseUrl.java b/android/src/main/java/com/stallion/utils/StallionApiBaseUrl.java new file mode 100644 index 0000000..e17d2a7 --- /dev/null +++ b/android/src/main/java/com/stallion/utils/StallionApiBaseUrl.java @@ -0,0 +1,59 @@ +package com.stallion.utils; + +import com.stallion.storage.StallionConfig; +import com.stallion.storage.StallionStateManager; +import com.stallion.networkmanager.StallionApiConstants; + +public class StallionApiBaseUrl { + + /** + * Gets the API base URL: custom baseUrl if set, else regional URL from app token. + * @return String - The base URL to use + */ + public static String get() { + try { + StallionStateManager stateManager = StallionStateManager.getInstance(); + if (stateManager != null && stateManager.getStallionConfig() != null) { + return resolve(stateManager.getStallionConfig()); + } + } catch (Exception e) { + // Fallback to regional default on any error + } + return StallionApiConstants.REGIONAL_API_BASE_AP; + } + + static String resolve(StallionConfig config) { + String stored = config.getBaseUrl(); + if (stored != null && !stored.isEmpty()) { + return stored; + } + + String region = StallionTokenRegion.parseTokenRegion(config.getAppToken()); + if (region == null) { + region = StallionTokenRegion.defaultRegion(); + } + return regionalBaseUrl(region); + } + + static String regionalBaseUrl(String region) { + if ("us".equals(region)) { + return StallionApiConstants.REGIONAL_API_BASE_US; + } + return StallionApiConstants.REGIONAL_API_BASE_AP; + } + + /** + * Sets a custom base URL, or clears it when null/empty. + * @param baseUrl - The custom base URL to set, or null/empty to clear + */ + public static void set(String baseUrl) { + try { + StallionStateManager stateManager = StallionStateManager.getInstance(); + if (stateManager != null && stateManager.getStallionConfig() != null) { + stateManager.getStallionConfig().setBaseUrl(baseUrl); + } + } catch (Exception e) { + // Silently fail + } + } +} diff --git a/android/src/main/java/com/stallion/utils/StallionTokenRegion.java b/android/src/main/java/com/stallion/utils/StallionTokenRegion.java new file mode 100644 index 0000000..34667e8 --- /dev/null +++ b/android/src/main/java/com/stallion/utils/StallionTokenRegion.java @@ -0,0 +1,51 @@ +package com.stallion.utils; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; + +public final class StallionTokenRegion { + + private static final Set VALID_REGIONS = new HashSet<>(Arrays.asList("ap", "us")); + + private StallionTokenRegion() {} + + /** + * Parses the region prefix from a Stallion token. + * Returns "ap", "us", or null. Null means legacy unprefixed token; caller should default to "ap". + */ + public static String parseTokenRegion(String token) { + if (token == null) { + return null; + } + token = token.trim(); + if (token.isEmpty()) { + return null; + } + + // App token: spb__<44-char nanoid> → 49 chars + if (token.startsWith("spb_") && token.length() == 49 && token.charAt(6) == '_') { + return extractRegion(token); + } + + // CI token: stl__<36-char nanoid> → 43 chars + if (token.startsWith("stl_") && token.length() == 43 && token.charAt(6) == '_') { + return extractRegion(token); + } + + return null; + } + + public static String defaultRegion() { + return "ap"; + } + + private static String extractRegion(String token) { + String code = token.substring(4, 6).toLowerCase(Locale.ROOT); + if (!code.matches("[a-z]{2}")) { + return null; + } + return VALID_REGIONS.contains(code) ? code : null; + } +} diff --git a/example/android/app/src/main/res/values/strings.xml b/example/android/app/src/main/res/values/strings.xml index 1ae723b..6be6785 100644 --- a/example/android/app/src/main/res/values/strings.xml +++ b/example/android/app/src/main/res/values/strings.xml @@ -2,11 +2,4 @@ StallionExample 67210b6f3e19d894c6a1a4fb spb_KLNEjOkETM48i3N1SffGDvEl-OsSiHZrhxER2kq3Ok - MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApm9SgaDU9lXLi5OD6sd9 -WpNP8bWeGRzLYOReqH5yuu21kYTDaDt1/+vTpCBNuyX2ZjtKNvKujnAXuWf8Zfj4 -JcWb/r4clDQK0sNy8G7x7s+4Mup6W73lW5AtUnREQZX8IA00lp76r9ii6HYxXTpO -CgHpXyRyPLkMNc+HxaTmDHbldZtRCREcLldI7NnQkFMyZlHK2r1QxiQLpTIEK0vI -IJyXeHOeJUROebZn/UI7UDKiSCvlaHMsJ4pnIAyLrtqFQ8w9rf3aljmYX52m6Rbc -df8fwbWtBCH8OPW5SEDTMDzvE1siAvYwRNxbeXHMrAypPg/DaATdXoKMObjJWRm1 -owIDAQAB diff --git a/ios/main/Stallion.swift b/ios/main/Stallion.swift index 15be93d..6fbb7f1 100644 --- a/ios/main/Stallion.swift +++ b/ios/main/Stallion.swift @@ -28,6 +28,17 @@ class Stallion: RCTEventEmitter { } @objc func onLaunch(_ launchData: String) { + var customBaseUrl = "" + if !launchData.isEmpty, + let data = launchData.data(using: .utf8), + let params = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + params.keys.contains("baseUrl"), + let baseUrl = params["baseUrl"] as? String, + !baseUrl.isEmpty { + customBaseUrl = baseUrl + } + StallionApiBaseUrl.set(customBaseUrl) + stallionStateManager.isMounted = true checkPendingDownloads() let currentReleaseHash = stallionStateManager.stallionMeta.getHashAtCurrentProdSlot() @@ -61,7 +72,9 @@ class Stallion: RCTEventEmitter { @objc func getStallionConfig(_ promise: RCTPromiseResolveBlock, rejecter: RCTPromiseRejectBlock) { do { if let config = stallionStateManager.stallionConfig { - let configJsonData = try JSONSerialization.data(withJSONObject: config.toDictionary(), options: []) + var configDict = config.toDictionary() as? [String: Any] ?? [:] + configDict["baseUrl"] = StallionApiBaseUrl.get() + let configJsonData = try JSONSerialization.data(withJSONObject: configDict, options: []) if let configJsonString = String(data: configJsonData, encoding: .utf8) { promise(configJsonString) } else { diff --git a/ios/main/StallionApiBaseUrl.swift b/ios/main/StallionApiBaseUrl.swift new file mode 100644 index 0000000..bd9c9bc --- /dev/null +++ b/ios/main/StallionApiBaseUrl.swift @@ -0,0 +1,51 @@ +// +// StallionApiBaseUrl.swift +// react-native-stallion +// +// Created for centralized base URL management +// + +import Foundation + +class StallionApiBaseUrl { + /** + * Gets the API base URL: custom baseUrl if set, else regional URL from app token. + */ + static func get() -> String { + guard let stateManager = StallionStateManager.sharedInstance() else { + return StallionConstants.REGIONAL_API_BASE_AP + } + + return resolve(config: stateManager.stallionConfig) + } + + static func resolve(config: StallionConfig) -> String { + let stored = config.baseUrl ?? "" + if !stored.isEmpty { + return stored + } + + var region = StallionTokenRegion.parseTokenRegion(config.appToken) + if region == nil { + region = StallionTokenRegion.defaultRegion() + } + return regionalBaseUrl(region!) + } + + static func regionalBaseUrl(_ region: String) -> String { + if region == "us" { + return StallionConstants.REGIONAL_API_BASE_US + } + return StallionConstants.REGIONAL_API_BASE_AP + } + + /** + * Sets a custom base URL, or clears it when empty. + */ + static func set(_ baseUrl: String) { + guard let stateManager = StallionStateManager.sharedInstance() else { + return + } + stateManager.stallionConfig.updateBaseUrl(baseUrl) + } +} diff --git a/ios/main/StallionConfig.h b/ios/main/StallionConfig.h index 46adde2..7813598 100644 --- a/ios/main/StallionConfig.h +++ b/ios/main/StallionConfig.h @@ -17,11 +17,13 @@ @property (nonatomic, copy, readonly) NSString *filesDirectory; @property (nonatomic, copy, readonly) NSString *publicSigningKey; @property (nonatomic, copy) NSString *lastUnverifiedHash; +@property (nonatomic, copy) NSString *baseUrl; - (instancetype)initWithDefaults:(NSUserDefaults *)defaults; - (void)updateSdkToken:(NSString *)newSdkToken; - (void)updateLastUnverifiedHash:(NSString *)newUnverifiedHash; +- (void)updateBaseUrl:(NSString *)newBaseUrl; - (NSDictionary *)toDictionary; @end diff --git a/ios/main/StallionConfig.m b/ios/main/StallionConfig.m index e75c9a0..d96eb44 100644 --- a/ios/main/StallionConfig.m +++ b/ios/main/StallionConfig.m @@ -7,6 +7,7 @@ #import "StallionConfig.h" #import "StallionConfigConstants.h" +#import "StallionObjConstants.h" @implementation StallionConfig @@ -20,6 +21,7 @@ - (instancetype)initWithDefaults:(NSUserDefaults *)defaults { _filesDirectory = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).firstObject ?: @""; _publicSigningKey = [[NSBundle mainBundle] objectForInfoDictionaryKey:STALLION_PUBLIC_SIGNING_KEY_IDENTIFIER] ?: @""; _lastUnverifiedHash = [defaults stringForKey:LAST_UNVERIFIED_KEY_IDENTIFIER] ?: @""; + _baseUrl = [defaults stringForKey:BASE_URL_IDENTIFIER] ?: @""; NSString *cachedUid = [defaults stringForKey:UNIQUE_ID_IDENTIFIER]; if (cachedUid && ![cachedUid isEqualToString:@""]) { @@ -51,6 +53,17 @@ - (void)updateLastUnverifiedHash:(NSString *)newUnverifiedHash { [[NSUserDefaults standardUserDefaults] synchronize]; } +- (void)updateBaseUrl:(NSString *)newBaseUrl { + if (newBaseUrl == nil || [newBaseUrl length] == 0) { + _baseUrl = @""; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:BASE_URL_IDENTIFIER]; + } else { + _baseUrl = newBaseUrl; + [[NSUserDefaults standardUserDefaults] setObject:_baseUrl forKey:BASE_URL_IDENTIFIER]; + } + [[NSUserDefaults standardUserDefaults] synchronize]; +} + - (NSDictionary *)toDictionary { @try { return @{ @@ -58,7 +71,8 @@ - (NSDictionary *)toDictionary { @"projectId": self.projectId ?: @"", @"appToken": self.appToken ?: @"", @"sdkToken": self.sdkToken ?: @"", - @"appVersion": self.appVersion ?: @"" + @"appVersion": self.appVersion ?: @"", + @"baseUrl": self.baseUrl ?: @"" }; } @catch (NSException *exception) { NSLog(@"Error in toDictionary: %@", exception.reason); diff --git a/ios/main/StallionConfigConstants.h b/ios/main/StallionConfigConstants.h index 7c0d921..7ba39dc 100644 --- a/ios/main/StallionConfigConstants.h +++ b/ios/main/StallionConfigConstants.h @@ -32,5 +32,6 @@ extern NSString *const STALLION_META_IDENTIFIER; extern NSString *const STALLION_PUBLIC_SIGNING_KEY_IDENTIFIER; extern NSString *const LAST_UNVERIFIED_KEY_IDENTIFIER; +extern NSString *const BASE_URL_IDENTIFIER; @end diff --git a/ios/main/StallionConfigConstants.m b/ios/main/StallionConfigConstants.m index 3ab91af..1303431 100644 --- a/ios/main/StallionConfigConstants.m +++ b/ios/main/StallionConfigConstants.m @@ -32,5 +32,6 @@ @implementation StallionConfigConstants NSString *const STALLION_PUBLIC_SIGNING_KEY_IDENTIFIER = @"StallionPublicSigningKey"; NSString *const LAST_UNVERIFIED_KEY_IDENTIFIER = @"LastUnverifiedHash"; +NSString *const BASE_URL_IDENTIFIER = @"StallionBaseUrl"; @end diff --git a/ios/main/StallionConstants.swift b/ios/main/StallionConstants.swift index 4a736d0..8c55183 100644 --- a/ios/main/StallionConstants.swift +++ b/ios/main/StallionConstants.swift @@ -45,7 +45,16 @@ class StallionConstants { static let CURRENT_PROD_SLOT_KEY = "stallionProdCurrentSlot" static let CURRENT_STAGE_SLOT_KEY = "stallionStageCurrentSlot" - static let STALLION_API_BASE = "https://api.stalliontech.io" + // Use utility for base URL (returns custom or default) + static var STALLION_API_BASE: String { + return StallionApiBaseUrl.get() + } + /// Legacy global API host; used only to detect implicit defaults in prefs. + static let LEGACY_DEFAULT_STALLION_API_BASE = "https://api.stalliontech.io" + static let REGIONAL_API_BASE_AP = "https://api-ap.stalliontech.io" + static let REGIONAL_API_BASE_US = "https://api-us.stalliontech.io" + /// @deprecated Use LEGACY_DEFAULT_STALLION_API_BASE or StallionApiBaseUrl.get() + static let DEFAULT_STALLION_API_BASE = LEGACY_DEFAULT_STALLION_API_BASE static let STALLION_INFO_API_PATH = "/api/v1/promoted/get-update-meta" static let STALLION_PROJECT_ID_IDENTIFIER = "StallionProjectId" static let STALLION_APP_TOKEN_IDENTIFIER = "StallionAppToken" diff --git a/ios/main/StallionObjConstants.h b/ios/main/StallionObjConstants.h index e57ae28..d9b6373 100644 --- a/ios/main/StallionObjConstants.h +++ b/ios/main/StallionObjConstants.h @@ -45,5 +45,6 @@ @property (class, nonatomic, readonly) NSString *last_rolled_back_release_hash_key; @property (class, nonatomic, readonly) NSString *auto_rolled_back_prod_event; @property (class, nonatomic, readonly) NSString *is_auto_rollback_key; +@property (class, nonatomic, readonly) NSString *default_stallion_api_base; @end diff --git a/ios/main/StallionObjConstants.m b/ios/main/StallionObjConstants.m index ed6f9f7..e624b4a 100644 --- a/ios/main/StallionObjConstants.m +++ b/ios/main/StallionObjConstants.m @@ -129,4 +129,8 @@ + (NSString *)last_rolled_back_release_hash_key { return @"LAST_ROLLED_BACK_RELEASE_HASH"; } ++ (NSString *)default_stallion_api_base { + return @"https://api.stalliontech.io"; +} + @end diff --git a/ios/main/StallionTokenRegion.swift b/ios/main/StallionTokenRegion.swift new file mode 100644 index 0000000..3a8c69c --- /dev/null +++ b/ios/main/StallionTokenRegion.swift @@ -0,0 +1,44 @@ +// +// StallionTokenRegion.swift +// react-native-stallion +// + +import Foundation + +enum StallionTokenRegion { + private static let validRegions: Set = ["ap", "us"] + + /// Parses the region prefix from a Stallion token. + /// Returns "ap", "us", or nil. Nil means legacy unprefixed token; caller should default to "ap". + static func parseTokenRegion(_ token: String?) -> String? { + guard let token = token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty else { + return nil + } + + // App token: spb__<44-char nanoid> → 49 chars + if token.hasPrefix("spb_"), token.count == 49, token[token.index(token.startIndex, offsetBy: 6)] == "_" { + return extractRegion(from: token) + } + + // CI token: stl__<36-char nanoid> → 43 chars + if token.hasPrefix("stl_"), token.count == 43, token[token.index(token.startIndex, offsetBy: 6)] == "_" { + return extractRegion(from: token) + } + + return nil + } + + static func defaultRegion() -> String { + return "ap" + } + + private static func extractRegion(from token: String) -> String? { + let start = token.index(token.startIndex, offsetBy: 4) + let end = token.index(token.startIndex, offsetBy: 6) + let code = String(token[start.. = ({ slot, isActive }) => { borderWidth: isActive ? 1 : 0, }, ]} - > + /> ); }; diff --git a/src/main/constants/apiConstants.ts b/src/main/constants/apiConstants.ts index b89a4ae..b664ebc 100644 --- a/src/main/constants/apiConstants.ts +++ b/src/main/constants/apiConstants.ts @@ -1,4 +1,8 @@ -export const API_BASE_URL = 'https://api.stalliontech.io'; +import { DEFAULT_API_BASE_URL } from '../utils/getApiBaseUrl'; + +// Keep for backward compatibility, but mark as deprecated +/** @deprecated Use getApiBaseUrl() instead */ +export const API_BASE_URL = DEFAULT_API_BASE_URL; export enum API_PATHS { LOGIN = '/api/v1/sdk/auth/verify-pin', diff --git a/src/main/state/actionCreators/useConfigActions.ts b/src/main/state/actionCreators/useConfigActions.ts index 13974aa..f8a223a 100644 --- a/src/main/state/actionCreators/useConfigActions.ts +++ b/src/main/state/actionCreators/useConfigActions.ts @@ -1,6 +1,7 @@ import React, { useCallback, useEffect } from 'react'; import { getStallionConfigNative } from '../../utils/StallionNativeUtils'; +import { clearApiBaseUrlCache } from '../../utils/getApiBaseUrl'; import { IConfigAction } from '../../../types/config.types'; import { setConfig } from '../actions/configActions'; @@ -10,6 +11,8 @@ const useConfigActions = (dispatch: React.Dispatch) => { try { const stallionConfig = await getStallionConfigNative(); dispatch(setConfig(stallionConfig)); + // Clear base URL cache when config is refreshed to pick up any baseUrl changes + clearApiBaseUrlCache(); } catch (_) {} }, [dispatch]); diff --git a/src/main/state/useStallionEvents.ts b/src/main/state/useStallionEvents.ts index 0ed72f1..3cc12e7 100644 --- a/src/main/state/useStallionEvents.ts +++ b/src/main/state/useStallionEvents.ts @@ -19,6 +19,7 @@ import { useApiClient } from '../utils/useApiClient'; import { API_PATHS } from '../constants/apiConstants'; import debounce from '../utils/debounce'; import { IStallionInitParams } from '../../types/utils.types'; +import { clearApiBaseUrlCache } from '../utils/getApiBaseUrl'; import { IUpdateMetaAction, UpdateMetaActionKind, @@ -160,6 +161,7 @@ export const useStallionEvents = ( ); return; } + clearApiBaseUrlCache(); if (stallionInitParams) { try { onLaunchNative(JSON.stringify(stallionInitParams)); diff --git a/src/main/utils/getApiBaseUrl.ts b/src/main/utils/getApiBaseUrl.ts new file mode 100644 index 0000000..8e5476d --- /dev/null +++ b/src/main/utils/getApiBaseUrl.ts @@ -0,0 +1,51 @@ +import { getStallionConfigNative } from './StallionNativeUtils'; +import { + AP_ORIGIN, + parseTokenRegion, + regionalApiBaseUrl, +} from './parseTokenRegion'; + +export const DEFAULT_API_BASE_URL = AP_ORIGIN; +export const LEGACY_API_BASE_URL = 'https://api.stalliontech.io'; + +// Cache for base URL +let cachedBaseUrl: string | null = null; + +/** + * Gets the API base URL from native config or returns regional default + * @returns Promise - The base URL to use + */ +export const getApiBaseUrl = async (): Promise => { + if (cachedBaseUrl) { + return cachedBaseUrl; + } + + try { + const config = await getStallionConfigNative(); + if (config.baseUrl) { + cachedBaseUrl = config.baseUrl; + return cachedBaseUrl; + } + const region = parseTokenRegion(config.appToken); + cachedBaseUrl = regionalApiBaseUrl(region); + return cachedBaseUrl; + } catch { + return DEFAULT_API_BASE_URL; + } +}; + +/** + * Gets the API base URL synchronously (returns default if not cached) + * Use this when you need immediate access and can't await + * @returns string - The base URL (default if not loaded yet) + */ +export const getApiBaseUrlSync = (): string => { + return cachedBaseUrl || DEFAULT_API_BASE_URL; +}; + +/** + * Clears the cached base URL (useful for testing or config changes) + */ +export const clearApiBaseUrlCache = (): void => { + cachedBaseUrl = null; +}; diff --git a/src/main/utils/parseTokenRegion.ts b/src/main/utils/parseTokenRegion.ts new file mode 100644 index 0000000..89f0c8b --- /dev/null +++ b/src/main/utils/parseTokenRegion.ts @@ -0,0 +1,53 @@ +const AP_ORIGIN = 'https://api-ap.stalliontech.io'; +const US_ORIGIN = 'https://api-us.stalliontech.io'; + +const VALID_REGIONS = new Set(['ap', 'us']); + +export { AP_ORIGIN, US_ORIGIN, VALID_REGIONS }; + +/** + * Parse the region prefix from a Stallion token. + * Returns 'ap' | 'us' | null. Null means legacy unprefixed token; default to 'ap'. + */ +export function parseTokenRegion(token: unknown): 'ap' | 'us' | null { + if (typeof token !== 'string') { + return null; + } + + const trimmed = token.trim(); + if (!trimmed) { + return null; + } + + // App token: spb__<44-char nanoid> → 49 chars + if ( + trimmed.startsWith('spb_') && + trimmed.length === 49 && + trimmed.charAt(6) === '_' + ) { + return extractRegion(trimmed); + } + + // CI token: stl__<36-char nanoid> → 43 chars + if ( + trimmed.startsWith('stl_') && + trimmed.length === 43 && + trimmed.charAt(6) === '_' + ) { + return extractRegion(trimmed); + } + + return null; +} + +function extractRegion(token: string): 'ap' | 'us' | null { + const code = token.substring(4, 6).toLowerCase(); + if (!/^[a-z]{2}$/.test(code)) { + return null; + } + return VALID_REGIONS.has(code) ? (code as 'ap' | 'us') : null; +} + +export function regionalApiBaseUrl(region: string | null): string { + return region === 'us' ? US_ORIGIN : AP_ORIGIN; +} diff --git a/src/main/utils/useApiClient.ts b/src/main/utils/useApiClient.ts index afa5ad0..c151ef2 100644 --- a/src/main/utils/useApiClient.ts +++ b/src/main/utils/useApiClient.ts @@ -1,5 +1,6 @@ -import { useCallback } from 'react'; -import { API_BASE_URL, API_PATHS } from '../constants/apiConstants'; +import { useCallback, useState, useEffect } from 'react'; +import { getApiBaseUrl, DEFAULT_API_BASE_URL } from './getApiBaseUrl'; +import { API_PATHS } from '../constants/apiConstants'; import { IStallionConfigJson } from '../../types/config.types'; type IAuthHandler = (loginRequired: boolean) => void; @@ -8,9 +9,16 @@ export const useApiClient = ( configState: IStallionConfigJson, authHandler?: IAuthHandler ) => { + const [baseUrl, setBaseUrl] = useState(DEFAULT_API_BASE_URL); + + // Load base URL on mount + useEffect(() => { + getApiBaseUrl().then(setBaseUrl); + }, []); + const getData = useCallback( (apiPath: API_PATHS, apiBody: object): Promise => { - const dataRequest = fetch(API_BASE_URL + apiPath, { + const dataRequest = fetch(baseUrl + apiPath, { method: 'POST', body: JSON.stringify(apiBody), headers: { @@ -29,7 +37,7 @@ export const useApiClient = ( }); } else return dataRequest.then((res) => res.json()); }, - [configState, authHandler] + [baseUrl, configState, authHandler] ); return { getData, diff --git a/src/types/config.types.ts b/src/types/config.types.ts index 0657558..aa9f98e 100644 --- a/src/types/config.types.ts +++ b/src/types/config.types.ts @@ -4,6 +4,7 @@ export interface IStallionConfigJson { appToken: string; sdkToken: string; appVersion: string; + baseUrl?: string; } export enum ConfigActionKind { diff --git a/src/types/utils.types.ts b/src/types/utils.types.ts index 23e36eb..5c703a2 100644 --- a/src/types/utils.types.ts +++ b/src/types/utils.types.ts @@ -8,7 +8,9 @@ interface IBundleInfo { hash: string; } -export interface IStallionInitParams {} +export interface IStallionInitParams { + baseUrl?: string; // Optional explicit override; otherwise derived from app token region (default AP) +} export type IWithStallion = ( BaseComponent: React.ComponentType,