@hot-updater/expo 0.36.7 → 1.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app.plugin.js ADDED
@@ -0,0 +1 @@
1
+ module.exports = require("./plugin/build/withHotUpdater");
package/package.json CHANGED
@@ -1,8 +1,7 @@
1
1
  {
2
2
  "name": "@hot-updater/expo",
3
- "type": "module",
4
- "version": "0.36.7",
5
- "description": "React Native OTA solution for self-hosted",
3
+ "version": "1.0.0-rc.0",
4
+ "description": "Hot Updater build and config plugin for Expo",
6
5
  "main": "./dist/index.cjs",
7
6
  "module": "./dist/index.mjs",
8
7
  "types": "./dist/index.d.cts",
@@ -21,20 +20,24 @@
21
20
  },
22
21
  "files": [
23
22
  "dist",
23
+ "app.plugin.js",
24
+ "plugin/build",
24
25
  "package.json"
25
26
  ],
26
27
  "dependencies": {
27
28
  "@babel/core": "7.26.0",
28
29
  "@babel/types": "7.26.0",
29
30
  "uuidv7": "^1.0.2",
30
- "@hot-updater/cli-tools": "0.36.7",
31
- "@hot-updater/plugin-core": "0.36.7",
32
- "@hot-updater/bare": "0.36.7"
31
+ "@hot-updater/bare": "1.0.0-rc.0",
32
+ "@hot-updater/cli-tools": "1.0.0-rc.0",
33
+ "@hot-updater/plugin-core": "1.0.0-rc.0"
33
34
  },
34
35
  "devDependencies": {
35
36
  "@types/node": "^20",
37
+ "expo": "^50.0.0",
36
38
  "execa": "9.5.2",
37
- "vitest": "4.1.4"
39
+ "vitest": "4.1.4",
40
+ "hot-updater": "1.0.0-rc.0"
38
41
  },
39
42
  "exports": {
40
43
  ".": {
@@ -45,8 +48,21 @@
45
48
  "import": "./dist/babel-plugin.mjs",
46
49
  "require": "./dist/babel-plugin.cjs"
47
50
  },
51
+ "./app.plugin.js": "./app.plugin.js",
48
52
  "./package.json": "./package.json"
49
53
  },
54
+ "peerDependencies": {
55
+ "expo": ">=50.0.0",
56
+ "hot-updater": "*"
57
+ },
58
+ "peerDependenciesMeta": {
59
+ "expo": {
60
+ "optional": true
61
+ },
62
+ "hot-updater": {
63
+ "optional": true
64
+ }
65
+ },
50
66
  "inlinedDependencies": {
51
67
  "@sec-ant/readable-stream": "0.4.1",
52
68
  "@sindresorhus/merge-streams": "4.0.0",
@@ -75,7 +91,7 @@
75
91
  "yoctocolors": "2.1.2"
76
92
  },
77
93
  "scripts": {
78
- "build": "tsdown",
94
+ "build": "tsdown && tsc -p plugin/tsconfig.build.json",
79
95
  "test": "vitest run",
80
96
  "test:type": "tsc --noEmit"
81
97
  }
@@ -0,0 +1,314 @@
1
+ "use strict";
2
+ /**
3
+ * Pure transformation functions for HotUpdater code injection
4
+ * These utilities handle code transformations for different React Native patterns
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.transformAndroid = transformAndroid;
8
+ exports.transformIOS = transformIOS;
9
+ function findMatchingClosingParen(source, openParenIndex) {
10
+ let depth = 0;
11
+ for (let i = openParenIndex; i < source.length; i += 1) {
12
+ const char = source[i];
13
+ if (char === "(") {
14
+ depth += 1;
15
+ continue;
16
+ }
17
+ if (char === ")") {
18
+ depth -= 1;
19
+ if (depth === 0) {
20
+ return i;
21
+ }
22
+ }
23
+ }
24
+ return -1;
25
+ }
26
+ /**
27
+ * Helper to add lines if they don't exist, anchored by a specific string.
28
+ */
29
+ function addLinesOnce(contents, anchor, linesToAdd) {
30
+ if (linesToAdd.every((line) => contents.includes(line))) {
31
+ // All lines already exist, do nothing
32
+ return contents;
33
+ }
34
+ // Check if the anchor exists
35
+ if (!contents.includes(anchor)) {
36
+ // Anchor not found, cannot add lines reliably.
37
+ return contents;
38
+ }
39
+ // Add lines after the anchor
40
+ return contents.replace(anchor, `${anchor}\n${linesToAdd.join("\n")}`);
41
+ }
42
+ /**
43
+ * Android: handle getDefaultReactHost pattern (RN 0.82+ style).
44
+ * Adds jsBundleFilePath parameter to the call.
45
+ */
46
+ function transformAndroidReactHost(contents) {
47
+ const kotlinImport = "import com.hotupdater.HotUpdater";
48
+ const kotlinImportAnchor = "import com.facebook.react.ReactApplication";
49
+ // Quick pattern detection: only touch files using getDefaultReactHost
50
+ // with the new RN 0.82+ parameter style.
51
+ if (!contents.includes("getDefaultReactHost(") ||
52
+ !contents.includes("packageList =")) {
53
+ return contents;
54
+ }
55
+ // 1. Ensure HotUpdater import exists (idempotent via addLinesOnce)
56
+ const result = addLinesOnce(contents, kotlinImportAnchor, [kotlinImport]);
57
+ const callNeedle = "getDefaultReactHost(";
58
+ const callStartIndex = result.indexOf(callNeedle);
59
+ if (callStartIndex === -1) {
60
+ return result;
61
+ }
62
+ const openParenIndex = callStartIndex + callNeedle.length - 1;
63
+ const closeParenIndex = findMatchingClosingParen(result, openParenIndex);
64
+ if (closeParenIndex === -1) {
65
+ return result;
66
+ }
67
+ const callContents = result.slice(callStartIndex, closeParenIndex + 1);
68
+ const kotlinLegacyBundlePathRegex = /^([ \t]*)jsBundleFilePath = HotUpdater\.getJSBundleFile\(applicationContext\),[ \t]*\r?$/m;
69
+ const legacyBundlePathMatch = callContents.match(kotlinLegacyBundlePathRegex);
70
+ if (legacyBundlePathMatch) {
71
+ const paramIndent = legacyBundlePathMatch[1];
72
+ const jsBundleLines = [
73
+ `${paramIndent}jsBundleFilePath = if (BuildConfig.DEBUG) {`,
74
+ `${paramIndent} null`,
75
+ `${paramIndent}} else {`,
76
+ `${paramIndent} HotUpdater.getJSBundleFile(applicationContext)`,
77
+ `${paramIndent}},`,
78
+ ];
79
+ const migratedCallContents = callContents.replace(kotlinLegacyBundlePathRegex, jsBundleLines.join("\n"));
80
+ return `${result.slice(0, callStartIndex)}${migratedCallContents}${result.slice(closeParenIndex + 1)}`;
81
+ }
82
+ if (callContents.includes("jsBundleFilePath")) {
83
+ return result;
84
+ }
85
+ const callLines = callContents.split("\n");
86
+ // Determine the indentation used for parameters (e.g. " ")
87
+ let paramIndent = "";
88
+ for (let i = 1; i < callLines.length; i += 1) {
89
+ const line = callLines[i];
90
+ const trimmed = line.trim();
91
+ if (trimmed.length === 0) {
92
+ continue;
93
+ }
94
+ if (trimmed.startsWith(")")) {
95
+ // No parameters detected, give up safely.
96
+ return result;
97
+ }
98
+ const indentMatch = line.match(/^(\s*)/);
99
+ paramIndent = indentMatch ? indentMatch[1] : "";
100
+ break;
101
+ }
102
+ if (!paramIndent) {
103
+ return result;
104
+ }
105
+ const closingLineStartIndex = result.lastIndexOf("\n", closeParenIndex);
106
+ const insertionIndex = closingLineStartIndex === -1 ? 0 : closingLineStartIndex + 1;
107
+ let prefix = result.slice(0, insertionIndex);
108
+ const suffix = result.slice(insertionIndex);
109
+ let prevNonWhitespaceIndex = prefix.length - 1;
110
+ while (prevNonWhitespaceIndex >= 0 &&
111
+ /\s/.test(prefix[prevNonWhitespaceIndex])) {
112
+ prevNonWhitespaceIndex -= 1;
113
+ }
114
+ if (prevNonWhitespaceIndex >= 0 &&
115
+ prefix[prevNonWhitespaceIndex] !== "," &&
116
+ prefix[prevNonWhitespaceIndex] !== "(") {
117
+ prefix = `${prefix.slice(0, prevNonWhitespaceIndex + 1)},${prefix.slice(prevNonWhitespaceIndex + 1)}`;
118
+ }
119
+ const jsBundleLines = [
120
+ `${paramIndent}jsBundleFilePath = if (BuildConfig.DEBUG) {`,
121
+ `${paramIndent} null`,
122
+ `${paramIndent}} else {`,
123
+ `${paramIndent} HotUpdater.getJSBundleFile(applicationContext)`,
124
+ `${paramIndent}},`,
125
+ ];
126
+ return `${prefix}${jsBundleLines.join("\n")}\n${suffix}`;
127
+ }
128
+ /**
129
+ * Android: DefaultReactNativeHost pattern (RN 0.81 / Expo 54).
130
+ * Adds getJSBundleFile() override to the host.
131
+ */
132
+ function transformAndroidDefaultHost(contents) {
133
+ const kotlinImport = "import com.hotupdater.HotUpdater";
134
+ const kotlinImportAnchor = "import com.facebook.react.ReactApplication";
135
+ const kotlinReactNativeHostAnchor = "object : DefaultReactNativeHost(this) {";
136
+ const kotlinMethodCheck = "HotUpdater.getJSBundleFile(applicationContext)";
137
+ const kotlinLegacyMethodRegex = /^([ \t]*)override fun getJSBundleFile\(\): String\? \{[ \t]*\r?\n([ \t]*)return HotUpdater\.getJSBundleFile\(applicationContext\)[ \t]*\r?\n\1\}[ \t]*$/m;
138
+ const kotlinExistingMethodRegex = /^\s*override fun getJSBundleFile\(\): String\?\s*\{[\s\S]*?^\s*\}/gm;
139
+ const kotlinHermesAnchor = "override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED";
140
+ const kotlinNewArchAnchor = "override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED";
141
+ // Check if this is the old pattern with DefaultReactNativeHost
142
+ if (!contents.includes(kotlinReactNativeHostAnchor)) {
143
+ return contents;
144
+ }
145
+ // 1. Add import if missing
146
+ let result = addLinesOnce(contents, kotlinImportAnchor, [kotlinImport]);
147
+ const legacyMethodMatch = result.match(kotlinLegacyMethodRegex);
148
+ if (legacyMethodMatch) {
149
+ const methodIndent = legacyMethodMatch[1];
150
+ const bodyIndent = legacyMethodMatch[2];
151
+ const methodLines = [
152
+ `${methodIndent}override fun getJSBundleFile(): String? {`,
153
+ `${bodyIndent}return if (BuildConfig.DEBUG) {`,
154
+ `${bodyIndent} null`,
155
+ `${bodyIndent}} else {`,
156
+ `${bodyIndent} HotUpdater.getJSBundleFile(applicationContext)`,
157
+ `${bodyIndent}}`,
158
+ `${methodIndent}}`,
159
+ ];
160
+ result = result.replace(kotlinLegacyMethodRegex, methodLines.join("\n"));
161
+ }
162
+ // 2. Add/Replace getJSBundleFile method if needed
163
+ if (!result.includes(kotlinMethodCheck)) {
164
+ // Remove potentially existing (different) override first
165
+ result = result.replace(kotlinExistingMethodRegex, "");
166
+ const lines = result.split("\n");
167
+ const findLineIndex = (needle) => {
168
+ for (let i = 0; i < lines.length; i += 1) {
169
+ if (lines[i].includes(needle)) {
170
+ return i;
171
+ }
172
+ }
173
+ return -1;
174
+ };
175
+ // Prefer inserting after Hermes line, then after new architecture line
176
+ let anchorIndex = findLineIndex(kotlinHermesAnchor);
177
+ if (anchorIndex === -1) {
178
+ anchorIndex = findLineIndex(kotlinNewArchAnchor);
179
+ }
180
+ if (anchorIndex !== -1) {
181
+ const indentMatch = lines[anchorIndex].match(/^\s*/);
182
+ const indent = indentMatch ? indentMatch[0] : "";
183
+ const objectLine = lines.find((line) => line.includes("object : DefaultReactNativeHost"));
184
+ let indentSize = 2;
185
+ if (objectLine) {
186
+ const objectIndent = (objectLine.match(/^\s*/)?.[0] || "").length;
187
+ const propertyIndent = indent.length;
188
+ const diff = propertyIndent - objectIndent;
189
+ if (diff > 0) {
190
+ indentSize = diff;
191
+ }
192
+ }
193
+ const spaces = indentSize === 2 ? " " : " ";
194
+ const bodyIndent = indent + spaces;
195
+ const methodLines = [
196
+ "", // blank line
197
+ `${indent}override fun getJSBundleFile(): String? {`,
198
+ `${bodyIndent}return if (BuildConfig.DEBUG) {`,
199
+ `${bodyIndent} null`,
200
+ `${bodyIndent}} else {`,
201
+ `${bodyIndent} HotUpdater.getJSBundleFile(applicationContext)`,
202
+ `${bodyIndent}}`,
203
+ `${indent}}`,
204
+ ];
205
+ const insertIndex = anchorIndex + 1;
206
+ lines.splice(insertIndex, 0, ...methodLines);
207
+ result = lines.join("\n");
208
+ }
209
+ else {
210
+ // Fallback: insert before the closing brace of the object block
211
+ const hostStartIndex = lines.findIndex((line) => line.includes("object : DefaultReactNativeHost"));
212
+ if (hostStartIndex === -1) {
213
+ throw new Error("[transformAndroidDefaultHost] Could not find DefaultReactNativeHost block.");
214
+ }
215
+ let hostEndIndex = -1;
216
+ for (let i = lines.length - 1; i > hostStartIndex; i -= 1) {
217
+ if (lines[i].trim() === "}") {
218
+ hostEndIndex = i;
219
+ break;
220
+ }
221
+ }
222
+ if (hostEndIndex === -1) {
223
+ throw new Error("[transformAndroidDefaultHost] Could not find end of DefaultReactNativeHost block.");
224
+ }
225
+ const indentMatch = lines[hostEndIndex].match(/^\s*/);
226
+ const indent = indentMatch ? indentMatch[0] : "";
227
+ const bodyIndent = `${indent} `;
228
+ const methodLines = [
229
+ `${indent}override fun getJSBundleFile(): String? {`,
230
+ `${bodyIndent}return if (BuildConfig.DEBUG) {`,
231
+ `${bodyIndent} null`,
232
+ `${bodyIndent}} else {`,
233
+ `${bodyIndent} HotUpdater.getJSBundleFile(applicationContext)`,
234
+ `${bodyIndent}}`,
235
+ `${indent}}`,
236
+ ];
237
+ lines.splice(hostEndIndex, 0, ...methodLines);
238
+ result = lines.join("\n");
239
+ }
240
+ }
241
+ return result;
242
+ }
243
+ /**
244
+ * Public Android transformer that applies all Android-specific transforms.
245
+ */
246
+ function transformAndroid(contents) {
247
+ let result = contents;
248
+ result = transformAndroidReactHost(result);
249
+ result = transformAndroidDefaultHost(result);
250
+ return result;
251
+ }
252
+ /**
253
+ * iOS: Objective-C AppDelegate transformation.
254
+ * Replaces NSBundle-based bundleURL with HotUpdater bundleURL.
255
+ */
256
+ function transformIOSObjC(contents) {
257
+ const iosImport = "#import <HotUpdater/HotUpdater.h>";
258
+ const iosBundleUrl = "[HotUpdater bundleURL]";
259
+ const iosOriginalBundleUrlRegex = /\[\[NSBundle mainBundle\] URLForResource:@"main" withExtension:@"jsbundle"\]/g;
260
+ const iosAppDelegateHeader = '#import "AppDelegate.h"';
261
+ // Check if it's likely Obj-C
262
+ if (!contents.includes(iosAppDelegateHeader)) {
263
+ return contents;
264
+ }
265
+ let result = contents;
266
+ // 1. Ensure HotUpdater import is present
267
+ if (!result.includes(iosImport)) {
268
+ result = addLinesOnce(result, iosAppDelegateHeader, [iosImport]);
269
+ }
270
+ // 2. Swap NSBundle-based URL with HotUpdater bundleURL, but only once
271
+ if (!result.includes(iosBundleUrl) &&
272
+ iosOriginalBundleUrlRegex.test(result)) {
273
+ result = result.replace(iosOriginalBundleUrlRegex, iosBundleUrl);
274
+ }
275
+ return result;
276
+ }
277
+ /**
278
+ * iOS: Swift / Expo AppDelegate transformation.
279
+ * Replaces Bundle.main.url-based bundleURL with HotUpdater.bundleURL().
280
+ */
281
+ function transformIOSSwift(contents) {
282
+ const swiftImport = "import HotUpdater";
283
+ const swiftBundleUrl = "HotUpdater.bundleURL()";
284
+ const swiftOriginalBundleUrlRegex = /Bundle\.main\.url\(forResource: "?main"?, withExtension: "jsbundle"\)/g;
285
+ // Check if it's likely Swift AppDelegate code
286
+ if (!contents.includes("import ")) {
287
+ return contents;
288
+ }
289
+ // 1. Add import if missing - find the last import statement and add after it
290
+ let result = contents;
291
+ if (!result.includes(swiftImport)) {
292
+ // Find the last import statement
293
+ const lastImportMatch = result.match(/^import .*$/gm);
294
+ if (lastImportMatch) {
295
+ const lastImport = lastImportMatch[lastImportMatch.length - 1];
296
+ result = result.replace(lastImport, `${lastImport}\n${swiftImport}`);
297
+ }
298
+ }
299
+ // 2. Replace bundleURL provider if the original exists and hasn't been replaced
300
+ if (!result.includes(swiftBundleUrl) &&
301
+ swiftOriginalBundleUrlRegex.test(result)) {
302
+ result = result.replace(swiftOriginalBundleUrlRegex, swiftBundleUrl);
303
+ }
304
+ return result;
305
+ }
306
+ /**
307
+ * Public iOS transformer that applies both Objective-C and Swift transforms.
308
+ */
309
+ function transformIOS(contents) {
310
+ let result = contents;
311
+ result = transformIOSObjC(result);
312
+ result = transformIOSSwift(result);
313
+ return result;
314
+ }
@@ -0,0 +1,222 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getPublicKeyFromConfig = void 0;
7
+ const node_crypto_1 = require("node:crypto");
8
+ const promises_1 = require("node:fs/promises");
9
+ const path_1 = __importDefault(require("path"));
10
+ const config_plugins_1 = require("expo/config-plugins");
11
+ const package_json_1 = __importDefault(require("../../package.json"));
12
+ const transformers_1 = require("./transformers");
13
+ const loadCliTools = () => import("@hot-updater/cli-tools");
14
+ const loadHotUpdater = () => import("hot-updater");
15
+ const canonicalizeRsaSpkiPublicKey = (publicKeyPem) => {
16
+ const trimmed = publicKeyPem.trim();
17
+ if (!trimmed.startsWith("-----BEGIN PUBLIC KEY-----") ||
18
+ !trimmed.endsWith("-----END PUBLIC KEY-----") ||
19
+ trimmed.includes("PRIVATE KEY")) {
20
+ throw new Error("not spki");
21
+ }
22
+ const publicKey = (0, node_crypto_1.createPublicKey)(trimmed);
23
+ if (publicKey.asymmetricKeyType !== "rsa" ||
24
+ (publicKey.asymmetricKeyDetails?.modulusLength ?? 0) < 2048) {
25
+ throw new Error("not rsa");
26
+ }
27
+ return publicKey.export({ format: "pem", type: "spki" }).toString().trim();
28
+ };
29
+ const ANDROID_META_DATA_KEYS = {
30
+ channel: "com.hotupdater.CHANNEL",
31
+ fingerprintHash: "com.hotupdater.FINGERPRINT_HASH",
32
+ publicKey: "com.hotupdater.PUBLIC_KEY",
33
+ };
34
+ const removeAndroidMetaData = (application, name) => {
35
+ const metaData = application["meta-data"];
36
+ if (!metaData) {
37
+ return;
38
+ }
39
+ const filtered = (Array.isArray(metaData) ? metaData : [metaData]).filter((item) => item?.$?.["android:name"] !== name);
40
+ if (filtered.length === 0) {
41
+ delete application["meta-data"];
42
+ }
43
+ else {
44
+ application["meta-data"] = filtered;
45
+ }
46
+ };
47
+ const upsertAndroidMetaData = (application, name, value) => {
48
+ removeAndroidMetaData(application, name);
49
+ const metaData = Array.isArray(application["meta-data"])
50
+ ? application["meta-data"]
51
+ : application["meta-data"]
52
+ ? [application["meta-data"]]
53
+ : [];
54
+ metaData.push({
55
+ $: {
56
+ "android:name": name,
57
+ "android:value": value,
58
+ },
59
+ });
60
+ application["meta-data"] = metaData;
61
+ };
62
+ let fingerprintCache = null;
63
+ const getFingerprint = async () => {
64
+ if (fingerprintCache) {
65
+ return fingerprintCache;
66
+ }
67
+ const { createFingerprintJSON, generateFingerprints } = await loadHotUpdater();
68
+ fingerprintCache = await generateFingerprints();
69
+ await createFingerprintJSON(fingerprintCache);
70
+ return fingerprintCache;
71
+ };
72
+ /** Uses public-key-only configuration, or the v0 local key sources when omitted. */
73
+ const getPublicKeyFromConfig = async (signingConfig) => {
74
+ if (!signingConfig ||
75
+ ("enabled" in signingConfig && !signingConfig.enabled)) {
76
+ return null;
77
+ }
78
+ // Retain the v0 EAS key source only for local configs without an explicit pin.
79
+ if ("enabled" in signingConfig && signingConfig.publicKeyPath === undefined) {
80
+ const envPrivateKey = process.env.HOT_UPDATER_PRIVATE_KEY;
81
+ if (envPrivateKey) {
82
+ try {
83
+ const pem = envPrivateKey.includes("-----BEGIN")
84
+ ? envPrivateKey
85
+ : await (0, promises_1.readFile)(path_1.default.resolve(process.cwd(), envPrivateKey), "utf8");
86
+ return canonicalizeRsaSpkiPublicKey((0, node_crypto_1.createPublicKey)((0, node_crypto_1.createPrivateKey)(pem))
87
+ .export({ format: "pem", type: "spki" })
88
+ .toString());
89
+ }
90
+ catch {
91
+ // As in v0, try the configured local files if the environment source fails.
92
+ }
93
+ }
94
+ }
95
+ try {
96
+ const { getBundleSigningPublicKey } = await loadCliTools();
97
+ return ((await getBundleSigningPublicKey(signingConfig, { cwd: process.cwd() }))?.trim() ?? null);
98
+ }
99
+ catch {
100
+ throw new Error(signingConfig.publicKeyPath !== undefined
101
+ ? "[hot-updater] Failed to load publicKeyPath for bundle signing."
102
+ : "[hot-updater] Failed to load public key for bundle signing.");
103
+ }
104
+ };
105
+ exports.getPublicKeyFromConfig = getPublicKeyFromConfig;
106
+ /**
107
+ * Native code modifications - should only run once
108
+ */
109
+ const withHotUpdaterNativeCode = (config) => {
110
+ let modifiedConfig = config;
111
+ // === iOS: Objective-C & Swift in AppDelegate ===
112
+ modifiedConfig = (0, config_plugins_1.withAppDelegate)(modifiedConfig, (cfg) => {
113
+ let contents = cfg.modResults.contents;
114
+ contents = (0, transformers_1.transformIOS)(contents);
115
+ cfg.modResults.contents = contents;
116
+ return cfg;
117
+ });
118
+ // === Android: Kotlin in MainApplication ===
119
+ modifiedConfig = (0, config_plugins_1.withMainApplication)(modifiedConfig, (cfg) => {
120
+ let contents = cfg.modResults.contents;
121
+ contents = (0, transformers_1.transformAndroid)(contents);
122
+ cfg.modResults.contents = contents;
123
+ return cfg;
124
+ });
125
+ return modifiedConfig;
126
+ };
127
+ /**
128
+ * Configuration updates - should run every time
129
+ */
130
+ const withHotUpdaterConfigAsync = (props) => (config) => {
131
+ const channel = props.channel || "production";
132
+ let hotUpdaterConfigPromise;
133
+ const getHotUpdaterConfig = async () => {
134
+ if (!hotUpdaterConfigPromise) {
135
+ const { loadConfig } = await loadCliTools();
136
+ hotUpdaterConfigPromise = loadConfig(null);
137
+ }
138
+ return hotUpdaterConfigPromise;
139
+ };
140
+ let publicKeyPromise;
141
+ const getPublicKey = async () => {
142
+ if (!publicKeyPromise) {
143
+ publicKeyPromise = getHotUpdaterConfig().then(($config) => (0, exports.getPublicKeyFromConfig)($config.signing));
144
+ }
145
+ return publicKeyPromise;
146
+ };
147
+ let modifiedConfig = config;
148
+ // === iOS: Add channel and fingerprint to Info.plist ===
149
+ modifiedConfig = (0, config_plugins_1.withInfoPlist)(modifiedConfig, async (cfg) => {
150
+ let fingerprintHash = null;
151
+ const hotUpdaterConfig = await getHotUpdaterConfig();
152
+ if (hotUpdaterConfig.updateStrategy !== "appVersion") {
153
+ const fingerprint = await getFingerprint();
154
+ fingerprintHash = fingerprint.ios.hash;
155
+ }
156
+ // Load public key if signing is enabled
157
+ const publicKey = await getPublicKey();
158
+ cfg.modResults.HOT_UPDATER_CHANNEL = channel;
159
+ if (fingerprintHash) {
160
+ cfg.modResults.HOT_UPDATER_FINGERPRINT_HASH = fingerprintHash;
161
+ }
162
+ if (publicKey) {
163
+ cfg.modResults.HOT_UPDATER_PUBLIC_KEY = publicKey;
164
+ }
165
+ else {
166
+ delete cfg.modResults.HOT_UPDATER_PUBLIC_KEY;
167
+ }
168
+ return cfg;
169
+ });
170
+ // === Android: Add channel and fingerprint to AndroidManifest.xml ===
171
+ modifiedConfig = (0, config_plugins_1.withAndroidManifest)(modifiedConfig, async (cfg) => {
172
+ let fingerprintHash = null;
173
+ const hotUpdaterConfig = await getHotUpdaterConfig();
174
+ if (hotUpdaterConfig.updateStrategy !== "appVersion") {
175
+ const fingerprint = await getFingerprint();
176
+ fingerprintHash = fingerprint.android.hash;
177
+ }
178
+ // Load public key if signing is enabled
179
+ const publicKey = await getPublicKey();
180
+ const application = cfg.modResults.manifest.application?.[0];
181
+ if (!application) {
182
+ return cfg;
183
+ }
184
+ upsertAndroidMetaData(application, ANDROID_META_DATA_KEYS.channel, channel);
185
+ if (fingerprintHash) {
186
+ upsertAndroidMetaData(application, ANDROID_META_DATA_KEYS.fingerprintHash, fingerprintHash);
187
+ }
188
+ if (publicKey) {
189
+ upsertAndroidMetaData(application, ANDROID_META_DATA_KEYS.publicKey, publicKey);
190
+ }
191
+ else {
192
+ removeAndroidMetaData(application, ANDROID_META_DATA_KEYS.publicKey);
193
+ }
194
+ return cfg;
195
+ });
196
+ // Remove legacy Hot Updater string resources when prebuild reuses a tree.
197
+ modifiedConfig = (0, config_plugins_1.withStringsXml)(modifiedConfig, (cfg) => {
198
+ const strings = cfg.modResults.resources?.string;
199
+ if (!strings) {
200
+ return cfg;
201
+ }
202
+ cfg.modResults.resources.string = (Array.isArray(strings) ? strings : [strings]).filter((item) => item.$?.name !== "hot_updater_channel" &&
203
+ item.$?.name !== "hot_updater_fingerprint_hash" &&
204
+ item.$?.name !== "hot_updater_public_key");
205
+ return cfg;
206
+ });
207
+ return modifiedConfig;
208
+ };
209
+ /**
210
+ * Main plugin that combines both native code (run once) and config (run always)
211
+ */
212
+ const withHotUpdater = (config, props = {}) => {
213
+ // Apply plugins in order
214
+ return (0, config_plugins_1.withPlugins)(config, [
215
+ // Native code modifications - wrapped with createRunOncePlugin
216
+ (0, config_plugins_1.createRunOncePlugin)(withHotUpdaterNativeCode, `${package_json_1.default.name}-native`, package_json_1.default.version),
217
+ // Configuration updates - runs every time
218
+ withHotUpdaterConfigAsync(props),
219
+ ]);
220
+ };
221
+ // Export the main plugin
222
+ exports.default = withHotUpdater;