expo-iap 3.1.18 → 3.1.19

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.
@@ -36,13 +36,21 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  const config_plugins_1 = require("expo/config-plugins");
37
37
  const fs = __importStar(require("fs"));
38
38
  const path = __importStar(require("path"));
39
+ const withIosAlternativeBilling_1 = require("./withIosAlternativeBilling");
40
+ // Log a message only once per Node process
41
+ const logOnce = (() => {
42
+ const printed = new Set();
43
+ return (msg) => {
44
+ if (!printed.has(msg)) {
45
+ console.log(msg);
46
+ printed.add(msg);
47
+ }
48
+ };
49
+ })();
39
50
  const withLocalOpenIAP = (config, props) => {
40
51
  // Import and apply iOS alternative billing configuration if provided
41
52
  if (props?.iosAlternativeBilling) {
42
- // Import withIosAlternativeBilling from withIAP module
43
- // eslint-disable-next-line @typescript-eslint/no-require-imports
44
- const { withIosAlternativeBilling } = require('./withIAP');
45
- config = withIosAlternativeBilling(config, props.iosAlternativeBilling);
53
+ config = (0, withIosAlternativeBilling_1.withIosAlternativeBilling)(config, props.iosAlternativeBilling);
46
54
  }
47
55
  // Helper to resolve Android module path
48
56
  const resolveAndroidModulePath = (p) => {
@@ -82,7 +90,7 @@ const withLocalOpenIAP = (config, props) => {
82
90
  }
83
91
  let podfileContent = fs.readFileSync(podfilePath, 'utf8');
84
92
  if (podfileContent.includes("pod 'openiap',")) {
85
- console.log('✅ Local OpenIAP pod already configured');
93
+ logOnce('✅ Local OpenIAP pod already configured');
86
94
  return config;
87
95
  }
88
96
  const targetRegex = /target\s+['"][\w]+['"]\s+do\s*\n\s*use_expo_modules!/;
@@ -94,7 +102,7 @@ const withLocalOpenIAP = (config, props) => {
94
102
  pod 'openiap', :path => '${iosPath}'`;
95
103
  });
96
104
  fs.writeFileSync(podfilePath, podfileContent);
97
- console.log(`✅ Added local OpenIAP pod at: ${iosPath}`);
105
+ logOnce(`✅ Added local OpenIAP pod at: ${iosPath}`);
98
106
  }
99
107
  else {
100
108
  console.warn('⚠️ Could not find target block in Podfile');
@@ -168,13 +176,13 @@ const withLocalOpenIAP = (config, props) => {
168
176
  if (!contents.includes(projectDirLine))
169
177
  contents += `${projectDirLine}\n`;
170
178
  settings.contents = contents;
171
- console.log(`✅ Linked local Android module at: ${androidModulePath}`);
179
+ logOnce(`✅ Linked local Android module at: ${androidModulePath}`);
172
180
  return config;
173
181
  });
174
182
  // 2) app/build.gradle: add implementation project(':openiap-google')
175
183
  config = (0, config_plugins_1.withAppBuildGradle)(config, (config) => {
176
- const raw = props?.localPath;
177
184
  const projectRoot = config.modRequest.projectRoot;
185
+ const raw = props?.localPath;
178
186
  const androidInput = typeof raw === 'string' ? undefined : raw?.android;
179
187
  const androidModulePath = resolveAndroidModulePath(androidInput) ||
180
188
  resolveAndroidModulePath(path.resolve(projectRoot, 'openiap-google')) ||
@@ -184,72 +192,65 @@ const withLocalOpenIAP = (config, props) => {
184
192
  }
185
193
  const gradle = config.modResults;
186
194
  const dependencyLine = ` implementation project(':openiap-google')`;
187
- // Remove any previously added Maven deps for openiap-google to avoid duplicate classes
188
- const removalPatterns = [
189
- // Groovy DSL: implementation "io.github.hyochan.openiap:openiap-google:x.y.z" or api "..."
190
- /^\s*(?:implementation|api)\s+["']io\.github\.hyochan\.openiap:openiap-google:[^"']+["']\s*$/gm,
191
- // Kotlin DSL: implementation("io.github.hyochan.openiap:openiap-google:x.y.z") or api("...")
192
- /^\s*(?:implementation|api)\s*\(\s*["']io\.github\.hyochan\.openiap:openiap-google:[^"']+["']\s*\)\s*$/gm,
193
- ];
195
+ const flavor = props?.isHorizonEnabled ? 'horizon' : 'play';
196
+ const strategyLine = ` missingDimensionStrategy "platform", "${flavor}"`;
194
197
  let contents = gradle.contents;
195
- let removedAny = false;
196
- for (const pattern of removalPatterns) {
197
- if (pattern.test(contents)) {
198
- contents = contents.replace(pattern, '\n');
199
- removedAny = true;
200
- }
198
+ // Remove Maven deps (avoid duplicate classes with local module)
199
+ const mavenPattern = /^\s*(?:implementation|api)\s*\(?\s*["']io\.github\.hyochan\.openiap:openiap-google:[^"']+["']\s*\)?\s*$/gm;
200
+ if (mavenPattern.test(contents)) {
201
+ contents = contents.replace(mavenPattern, '\n');
202
+ logOnce('🧹 Removed Maven openiap-google (using local module)');
201
203
  }
202
- if (removedAny) {
203
- gradle.contents = contents;
204
- console.log('🧹 Removed Maven openiap-google to use local :openiap-google');
204
+ // Add missingDimensionStrategy (required for flavored module)
205
+ // Remove any existing platform strategies first to avoid duplicates
206
+ const strategyPattern = /^\s*missingDimensionStrategy\s*\(?\s*["']platform["']\s*,\s*["'](play|horizon)["']\s*\)?\s*$/gm;
207
+ if (strategyPattern.test(contents)) {
208
+ contents = contents.replace(strategyPattern, '');
209
+ logOnce('🧹 Removed existing missingDimensionStrategy for platform');
205
210
  }
206
- if (!gradle.contents.includes(dependencyLine)) {
211
+ if (!contents.includes(strategyLine)) {
212
+ const lines = contents.split('\n');
213
+ const idx = lines.findIndex((line) => line.match(/defaultConfig\s*\{/));
214
+ if (idx !== -1) {
215
+ lines.splice(idx + 1, 0, strategyLine);
216
+ contents = lines.join('\n');
217
+ logOnce(`🛠️ expo-iap: Added missingDimensionStrategy for ${flavor} flavor`);
218
+ }
219
+ }
220
+ // Add project dependency
221
+ if (!contents.includes(dependencyLine)) {
207
222
  const anchor = /dependencies\s*\{/m;
208
- if (anchor.test(gradle.contents)) {
209
- gradle.contents = gradle.contents.replace(anchor, (m) => `${m}\n${dependencyLine}`);
223
+ if (anchor.test(contents)) {
224
+ contents = contents.replace(anchor, (m) => `${m}\n${dependencyLine}`);
210
225
  }
211
226
  else {
212
- gradle.contents += `\n\ndependencies {\n${dependencyLine}\n}\n`;
227
+ contents += `\n\ndependencies {\n${dependencyLine}\n}\n`;
213
228
  }
214
- console.log('🛠️ Added dependency on local :openiap-google project');
229
+ logOnce('🛠️ Added dependency on local :openiap-google project');
215
230
  }
231
+ gradle.contents = contents;
216
232
  return config;
217
233
  });
218
- // 3) Ensure final cleanup in app/build.gradle after all mods are applied
234
+ // 3) Set horizonEnabled in gradle.properties
219
235
  config = (0, config_plugins_1.withDangerousMod)(config, [
220
236
  'android',
221
237
  async (config) => {
222
- try {
223
- const { platformProjectRoot } = config.modRequest;
224
- const appBuildGradle = path.join(platformProjectRoot, 'app', 'build.gradle');
225
- if (fs.existsSync(appBuildGradle)) {
226
- let contents = fs.readFileSync(appBuildGradle, 'utf8');
227
- const patterns = [
228
- // Groovy DSL
229
- /^\s*(?:implementation|api)\s+["']io\.github\.hyochan\.openiap:openiap-google:[^"']+["']\s*$/gm,
230
- // Kotlin DSL
231
- /^\s*(?:implementation|api)\s*\(\s*["']io\.github\.hyochan\.openiap:openiap-google:[^"']+["']\s*\)\s*$/gm,
232
- ];
233
- let changed = false;
234
- for (const p of patterns) {
235
- if (p.test(contents)) {
236
- contents = contents.replace(p, '\n');
237
- changed = true;
238
- }
239
- }
240
- if (changed) {
241
- fs.writeFileSync(appBuildGradle, contents);
242
- console.log('🧹 expo-iap: Cleaned Maven openiap-google for local :openiap-google');
243
- }
244
- }
245
- }
246
- catch (e) {
247
- console.warn('expo-iap: cleanup step failed:', e);
238
+ const { platformProjectRoot } = config.modRequest;
239
+ const gradlePropertiesPath = path.join(platformProjectRoot, 'gradle.properties');
240
+ if (fs.existsSync(gradlePropertiesPath)) {
241
+ let contents = fs.readFileSync(gradlePropertiesPath, 'utf8');
242
+ const isHorizon = props?.isHorizonEnabled ?? false;
243
+ // Update horizonEnabled property
244
+ contents = contents.replace(/^horizonEnabled=.*$/gm, '');
245
+ if (!contents.endsWith('\n'))
246
+ contents += '\n';
247
+ contents += `horizonEnabled=${isHorizon}\n`;
248
+ fs.writeFileSync(gradlePropertiesPath, contents);
249
+ logOnce(`🛠️ expo-iap: Set horizonEnabled=${isHorizon} in gradle.properties`);
248
250
  }
249
251
  return config;
250
252
  },
251
253
  ]);
252
- // (removed) Avoid global root build.gradle mutations; included module should manage its plugins
253
254
  return config;
254
255
  };
255
256
  exports.default = withLocalOpenIAP;
@@ -5,12 +5,14 @@ import {
5
5
  withAndroidManifest,
6
6
  withAppBuildGradle,
7
7
  withDangerousMod,
8
- withEntitlementsPlist,
9
- withInfoPlist,
10
8
  } from 'expo/config-plugins';
11
9
  import * as fs from 'fs';
12
10
  import * as path from 'path';
13
11
  import withLocalOpenIAP from './withLocalOpenIAP';
12
+ import {
13
+ withIosAlternativeBilling,
14
+ type IOSAlternativeBillingConfig,
15
+ } from './withIosAlternativeBilling';
14
16
 
15
17
  const pkg = require('../../package.json');
16
18
  const openiapVersions = JSON.parse(
@@ -55,9 +57,13 @@ const addLineToGradle = (
55
57
  const modifyAppBuildGradle = (
56
58
  gradle: string,
57
59
  language: 'groovy' | 'kotlin',
60
+ isHorizonEnabled?: boolean,
58
61
  ): string => {
59
62
  let modified = gradle;
60
63
 
64
+ // Determine which flavor to use based on isHorizonEnabled
65
+ const flavor = isHorizonEnabled ? 'horizon' : 'play';
66
+
61
67
  // Ensure OpenIAP dependency exists at desired version in app-level build.gradle(.kts)
62
68
  const impl = (ga: string, v: string) =>
63
69
  language === 'kotlin'
@@ -91,27 +97,66 @@ const modifyAppBuildGradle = (
91
97
  );
92
98
  }
93
99
 
100
+ // Add flavor dimension and default config for OpenIAP if horizon is enabled
101
+ if (isHorizonEnabled) {
102
+ // Add missingDimensionStrategy to select horizon flavor
103
+ const defaultConfigRegex = /defaultConfig\s*{/;
104
+ if (defaultConfigRegex.test(modified)) {
105
+ const strategyLine =
106
+ language === 'kotlin'
107
+ ? ` missingDimensionStrategy("platform", "${flavor}")`
108
+ : ` missingDimensionStrategy "platform", "${flavor}"`;
109
+
110
+ // Remove any existing platform strategies first to avoid duplicates
111
+ const strategyPattern =
112
+ /^\s*missingDimensionStrategy\s*\(?\s*["']platform["']\s*,\s*["'](play|horizon)["']\s*\)?\s*$/gm;
113
+ if (strategyPattern.test(modified)) {
114
+ modified = modified.replace(strategyPattern, '');
115
+ logOnce('🧹 Removed existing missingDimensionStrategy for platform');
116
+ }
117
+
118
+ // Add the new strategy
119
+ if (!/missingDimensionStrategy.*platform/.test(modified)) {
120
+ modified = addLineToGradle(
121
+ modified,
122
+ defaultConfigRegex,
123
+ strategyLine,
124
+ 1,
125
+ );
126
+ logOnce(
127
+ `🛠️ expo-iap: Added missingDimensionStrategy for ${flavor} flavor`,
128
+ );
129
+ }
130
+ }
131
+ }
132
+
94
133
  return modified;
95
134
  };
96
135
 
97
- const withIapAndroid: ConfigPlugin<{addDeps?: boolean} | void> = (
98
- config,
99
- props,
100
- ) => {
136
+ const withIapAndroid: ConfigPlugin<
137
+ {
138
+ addDeps?: boolean;
139
+ horizonAppId?: string;
140
+ isHorizonEnabled?: boolean;
141
+ } | void
142
+ > = (config, props) => {
101
143
  const addDeps = props?.addDeps ?? true;
102
144
 
145
+ // Add dependencies if needed (only when not using local module)
103
146
  if (addDeps) {
104
147
  config = withAppBuildGradle(config, (config) => {
105
- // language provided by config-plugins: 'groovy' | 'kotlin'
106
148
  const language = (config.modResults as any).language || 'groovy';
107
149
  config.modResults.contents = modifyAppBuildGradle(
108
150
  config.modResults.contents,
109
151
  language,
152
+ props?.isHorizonEnabled,
110
153
  );
111
154
  return config;
112
155
  });
113
156
  }
114
157
 
158
+ // Note: missingDimensionStrategy for local dev is handled in withLocalOpenIAP
159
+
115
160
  config = withAndroidManifest(config, (config) => {
116
161
  const manifest = config.modResults;
117
162
  if (!manifest.manifest['uses-permission']) {
@@ -133,121 +178,45 @@ const withIapAndroid: ConfigPlugin<{addDeps?: boolean} | void> = (
133
178
  );
134
179
  }
135
180
 
136
- return config;
137
- });
138
-
139
- return config;
140
- };
141
-
142
- export interface IOSAlternativeBillingConfig {
143
- /** Country codes where external purchases are supported (ISO 3166-1 alpha-2) */
144
- countries?: string[];
145
- /** External purchase URLs per country (iOS 15.4+) */
146
- links?: Record<string, string>;
147
- /** Multiple external purchase URLs per country (iOS 17.5+, up to 5 per country) */
148
- multiLinks?: Record<string, string[]>;
149
- /** Custom link regions (iOS 18.1+) */
150
- customLinkRegions?: string[];
151
- /** Streaming link regions for music apps (iOS 18.2+) */
152
- streamingLinkRegions?: string[];
153
- /** Enable external purchase link entitlement */
154
- enableExternalPurchaseLink?: boolean;
155
- /** Enable external purchase link streaming entitlement (music apps only) */
156
- enableExternalPurchaseLinkStreaming?: boolean;
157
- }
158
-
159
- /** Add external purchase entitlements and Info.plist configuration */
160
- const withIosAlternativeBilling: ConfigPlugin<
161
- IOSAlternativeBillingConfig | undefined
162
- > = (config, options) => {
163
- if (!options || !options.countries || options.countries.length === 0) {
164
- return config;
165
- }
166
-
167
- // Add entitlements
168
- config = withEntitlementsPlist(config, (config) => {
169
- // Always add basic external purchase entitlement when countries are specified
170
- config.modResults['com.apple.developer.storekit.external-purchase'] = true;
171
- logOnce(
172
- '✅ Added com.apple.developer.storekit.external-purchase to entitlements',
173
- );
174
-
175
- // Add external purchase link entitlement if enabled
176
- if (options.enableExternalPurchaseLink) {
177
- config.modResults['com.apple.developer.storekit.external-purchase-link'] =
178
- true;
179
- logOnce(
180
- '✅ Added com.apple.developer.storekit.external-purchase-link to entitlements',
181
- );
182
- }
183
-
184
- // Add streaming entitlement if enabled
185
- if (options.enableExternalPurchaseLinkStreaming) {
186
- config.modResults[
187
- 'com.apple.developer.storekit.external-purchase-link-streaming'
188
- ] = true;
189
- logOnce(
190
- '✅ Added com.apple.developer.storekit.external-purchase-link-streaming to entitlements',
191
- );
192
- }
193
-
194
- return config;
195
- });
196
-
197
- // Add Info.plist configuration
198
- config = withInfoPlist(config, (config) => {
199
- const plist = config.modResults;
200
-
201
- // 1. SKExternalPurchase (Required)
202
- plist.SKExternalPurchase = options.countries;
203
- logOnce(
204
- `✅ Added SKExternalPurchase with countries: ${options.countries?.join(
205
- ', ',
206
- )}`,
207
- );
181
+ // Add Meta Horizon App ID if provided
182
+ if (props?.horizonAppId) {
183
+ if (
184
+ !manifest.manifest.application ||
185
+ manifest.manifest.application.length === 0
186
+ ) {
187
+ manifest.manifest.application = [
188
+ {$: {'android:name': '.MainApplication'}},
189
+ ];
190
+ }
208
191
 
209
- // 2. SKExternalPurchaseLink (Optional - iOS 15.4+)
210
- if (options.links && Object.keys(options.links).length > 0) {
211
- plist.SKExternalPurchaseLink = options.links;
212
- logOnce(
213
- `✅ Added SKExternalPurchaseLink for ${
214
- Object.keys(options.links).length
215
- } countries`,
216
- );
217
- }
192
+ const application = manifest.manifest.application![0];
193
+ if (!application['meta-data']) {
194
+ application['meta-data'] = [];
195
+ }
218
196
 
219
- // 3. SKExternalPurchaseMultiLink (iOS 17.5+)
220
- if (options.multiLinks && Object.keys(options.multiLinks).length > 0) {
221
- plist.SKExternalPurchaseMultiLink = options.multiLinks;
222
- logOnce(
223
- `✅ Added SKExternalPurchaseMultiLink for ${
224
- Object.keys(options.multiLinks).length
225
- } countries`,
226
- );
227
- }
197
+ const metaData = application['meta-data'];
198
+ const horizonAppIdMeta = {
199
+ $: {
200
+ 'android:name': 'com.oculus.vr.APP_ID',
201
+ 'android:value': props.horizonAppId,
202
+ },
203
+ };
228
204
 
229
- // 4. SKExternalPurchaseCustomLinkRegions (iOS 18.1+)
230
- if (options.customLinkRegions && options.customLinkRegions.length > 0) {
231
- plist.SKExternalPurchaseCustomLinkRegions = options.customLinkRegions;
232
- logOnce(
233
- `✅ Added SKExternalPurchaseCustomLinkRegions: ${options.customLinkRegions.join(
234
- ', ',
235
- )}`,
205
+ const existingIndex = metaData.findIndex(
206
+ (m) => m.$['android:name'] === 'com.oculus.vr.APP_ID',
236
207
  );
237
- }
238
208
 
239
- // 5. SKExternalPurchaseLinkStreamingRegions (iOS 18.2+)
240
- if (
241
- options.streamingLinkRegions &&
242
- options.streamingLinkRegions.length > 0
243
- ) {
244
- plist.SKExternalPurchaseLinkStreamingRegions =
245
- options.streamingLinkRegions;
246
- logOnce(
247
- `✅ Added SKExternalPurchaseLinkStreamingRegions: ${options.streamingLinkRegions.join(
248
- ', ',
249
- )}`,
250
- );
209
+ if (existingIndex !== -1) {
210
+ metaData[existingIndex] = horizonAppIdMeta;
211
+ logOnce(
212
+ `✅ Updated com.oculus.vr.APP_ID to ${props.horizonAppId} in AndroidManifest.xml`,
213
+ );
214
+ } else {
215
+ metaData.push(horizonAppIdMeta);
216
+ logOnce(
217
+ `✅ Added com.oculus.vr.APP_ID: ${props.horizonAppId} to AndroidManifest.xml`,
218
+ );
219
+ }
251
220
  }
252
221
 
253
222
  return config;
@@ -310,12 +279,47 @@ export interface ExpoIapPluginOptions {
310
279
  /** Enable local development mode */
311
280
  enableLocalDev?: boolean;
312
281
  /**
313
- * iOS Alternative Billing configuration.
314
- * Configure external purchase countries, links, and entitlements.
315
- * Requires approval from Apple.
282
+ * Optional modules configuration
283
+ */
284
+ modules?: {
285
+ /**
286
+ * Onside module for iOS alternative billing (Korea market)
287
+ * @platform ios
288
+ */
289
+ onside?: boolean;
290
+ /**
291
+ * Horizon module for Meta Quest/VR devices
292
+ * @platform android
293
+ */
294
+ horizon?: boolean;
295
+ };
296
+ /**
297
+ * iOS-specific configuration
316
298
  * @platform ios
317
299
  */
300
+ ios?: {
301
+ /**
302
+ * iOS Alternative Billing configuration.
303
+ * Configure external purchase countries, links, and entitlements.
304
+ * Requires approval from Apple.
305
+ */
306
+ alternativeBilling?: IOSAlternativeBillingConfig;
307
+ };
308
+ /**
309
+ * Android-specific configuration
310
+ * @platform android
311
+ */
312
+ android?: {
313
+ /**
314
+ * Meta Horizon App ID for Quest/VR devices.
315
+ * Required when modules.horizon is true.
316
+ */
317
+ horizonAppId?: string;
318
+ };
319
+ /** @deprecated Use ios.alternativeBilling instead */
318
320
  iosAlternativeBilling?: IOSAlternativeBillingConfig;
321
+ /** @deprecated Use android.horizonAppId instead */
322
+ horizonAppId?: string;
319
323
  }
320
324
 
321
325
  const withIap: ConfigPlugin<ExpoIapPluginOptions | void> = (
@@ -323,10 +327,26 @@ const withIap: ConfigPlugin<ExpoIapPluginOptions | void> = (
323
327
  options,
324
328
  ) => {
325
329
  try {
330
+ // Read Horizon configuration from modules
331
+ const isHorizonEnabled = options?.modules?.horizon ?? false;
332
+
333
+ const horizonAppId =
334
+ options?.android?.horizonAppId ?? options?.horizonAppId;
335
+ const iosAlternativeBilling =
336
+ options?.ios?.alternativeBilling ?? options?.iosAlternativeBilling;
337
+
338
+ logOnce(
339
+ `🔍 [expo-iap] Config values: horizonAppId=${horizonAppId}, isHorizonEnabled=${isHorizonEnabled}`,
340
+ );
341
+
326
342
  // Respect explicit flag; fall back to presence of localPath only when flag is unset
327
343
  const isLocalDev = options?.enableLocalDev ?? !!options?.localPath;
328
344
  // Apply Android modifications (skip adding deps when linking local module)
329
- let result = withIapAndroid(config, {addDeps: !isLocalDev});
345
+ let result = withIapAndroid(config, {
346
+ addDeps: !isLocalDev,
347
+ horizonAppId,
348
+ isHorizonEnabled,
349
+ });
330
350
 
331
351
  // iOS: choose one path to avoid overlap
332
352
  if (isLocalDev) {
@@ -354,12 +374,14 @@ const withIap: ConfigPlugin<ExpoIapPluginOptions | void> = (
354
374
  logOnce(`🔧 [expo-iap] Enabling local OpenIAP: ${preview}`);
355
375
  result = withLocalOpenIAP(result, {
356
376
  localPath: resolved,
357
- iosAlternativeBilling: options?.iosAlternativeBilling,
377
+ iosAlternativeBilling,
378
+ horizonAppId,
379
+ isHorizonEnabled, // Resolved from modules.horizon (line 467)
358
380
  });
359
381
  }
360
382
  } else {
361
383
  // Ensure iOS Podfile is set up to resolve public CocoaPods specs
362
- result = withIapIOS(result, options?.iosAlternativeBilling);
384
+ result = withIapIOS(result, iosAlternativeBilling);
363
385
  logOnce('📦 [expo-iap] Using OpenIAP from CocoaPods');
364
386
  }
365
387
 
@@ -0,0 +1,133 @@
1
+ import {
2
+ ConfigPlugin,
3
+ withEntitlementsPlist,
4
+ withInfoPlist,
5
+ } from 'expo/config-plugins';
6
+
7
+ // Log a message only once per Node process
8
+ const logOnce = (() => {
9
+ const printed = new Set<string>();
10
+ return (msg: string) => {
11
+ if (!printed.has(msg)) {
12
+ console.log(msg);
13
+ printed.add(msg);
14
+ }
15
+ };
16
+ })();
17
+
18
+ export interface IOSAlternativeBillingConfig {
19
+ /** Country codes where external purchases are supported (ISO 3166-1 alpha-2) */
20
+ countries?: string[];
21
+ /** External purchase URLs per country (iOS 15.4+) */
22
+ links?: Record<string, string>;
23
+ /** Multiple external purchase URLs per country (iOS 17.5+, up to 5 per country) */
24
+ multiLinks?: Record<string, string[]>;
25
+ /** Custom link regions (iOS 18.1+) */
26
+ customLinkRegions?: string[];
27
+ /** Streaming link regions for music apps (iOS 18.2+) */
28
+ streamingLinkRegions?: string[];
29
+ /** Enable external purchase link entitlement */
30
+ enableExternalPurchaseLink?: boolean;
31
+ /** Enable external purchase link streaming entitlement (music apps only) */
32
+ enableExternalPurchaseLinkStreaming?: boolean;
33
+ }
34
+
35
+ /** Add external purchase entitlements and Info.plist configuration */
36
+ export const withIosAlternativeBilling: ConfigPlugin<
37
+ IOSAlternativeBillingConfig | undefined
38
+ > = (config, options) => {
39
+ if (!options || !options.countries || options.countries.length === 0) {
40
+ return config;
41
+ }
42
+
43
+ // Add entitlements
44
+ config = withEntitlementsPlist(config, (config) => {
45
+ // Always add basic external purchase entitlement when countries are specified
46
+ config.modResults['com.apple.developer.storekit.external-purchase'] = true;
47
+ logOnce(
48
+ '✅ Added com.apple.developer.storekit.external-purchase to entitlements',
49
+ );
50
+
51
+ // Add external purchase link entitlement if enabled
52
+ if (options.enableExternalPurchaseLink) {
53
+ config.modResults['com.apple.developer.storekit.external-purchase-link'] =
54
+ true;
55
+ logOnce(
56
+ '✅ Added com.apple.developer.storekit.external-purchase-link to entitlements',
57
+ );
58
+ }
59
+
60
+ // Add streaming entitlement if enabled
61
+ if (options.enableExternalPurchaseLinkStreaming) {
62
+ config.modResults[
63
+ 'com.apple.developer.storekit.external-purchase-link-streaming'
64
+ ] = true;
65
+ logOnce(
66
+ '✅ Added com.apple.developer.storekit.external-purchase-link-streaming to entitlements',
67
+ );
68
+ }
69
+
70
+ return config;
71
+ });
72
+
73
+ // Add Info.plist configuration
74
+ config = withInfoPlist(config, (config) => {
75
+ const plist = config.modResults;
76
+
77
+ // 1. SKExternalPurchase (Required)
78
+ plist.SKExternalPurchase = options.countries;
79
+ logOnce(
80
+ `✅ Added SKExternalPurchase with countries: ${options.countries?.join(
81
+ ', ',
82
+ )}`,
83
+ );
84
+
85
+ // 2. SKExternalPurchaseLink (Optional - iOS 15.4+)
86
+ if (options.links && Object.keys(options.links).length > 0) {
87
+ plist.SKExternalPurchaseLink = options.links;
88
+ logOnce(
89
+ `✅ Added SKExternalPurchaseLink for ${
90
+ Object.keys(options.links).length
91
+ } countries`,
92
+ );
93
+ }
94
+
95
+ // 3. SKExternalPurchaseMultiLink (iOS 17.5+)
96
+ if (options.multiLinks && Object.keys(options.multiLinks).length > 0) {
97
+ plist.SKExternalPurchaseMultiLink = options.multiLinks;
98
+ logOnce(
99
+ `✅ Added SKExternalPurchaseMultiLink for ${
100
+ Object.keys(options.multiLinks).length
101
+ } countries`,
102
+ );
103
+ }
104
+
105
+ // 4. SKExternalPurchaseCustomLinkRegions (iOS 18.1+)
106
+ if (options.customLinkRegions && options.customLinkRegions.length > 0) {
107
+ plist.SKExternalPurchaseCustomLinkRegions = options.customLinkRegions;
108
+ logOnce(
109
+ `✅ Added SKExternalPurchaseCustomLinkRegions: ${options.customLinkRegions.join(
110
+ ', ',
111
+ )}`,
112
+ );
113
+ }
114
+
115
+ // 5. SKExternalPurchaseLinkStreamingRegions (iOS 18.2+)
116
+ if (
117
+ options.streamingLinkRegions &&
118
+ options.streamingLinkRegions.length > 0
119
+ ) {
120
+ plist.SKExternalPurchaseLinkStreamingRegions =
121
+ options.streamingLinkRegions;
122
+ logOnce(
123
+ `✅ Added SKExternalPurchaseLinkStreamingRegions: ${options.streamingLinkRegions.join(
124
+ ', ',
125
+ )}`,
126
+ );
127
+ }
128
+
129
+ return config;
130
+ });
131
+
132
+ return config;
133
+ };