customerio-expo-plugin 3.3.0 → 3.5.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.
Files changed (107) hide show
  1. package/package.json +8 -1
  2. package/plugin/lib/commonjs/android/withAndroidManifestUpdates.js +64 -59
  3. package/plugin/lib/commonjs/android/withAndroidManifestUpdates.js.map +1 -1
  4. package/plugin/lib/commonjs/android/withAppGoogleServices.js +10 -7
  5. package/plugin/lib/commonjs/android/withAppGoogleServices.js.map +1 -1
  6. package/plugin/lib/commonjs/android/withGoogleServicesJSON.js +18 -21
  7. package/plugin/lib/commonjs/android/withGoogleServicesJSON.js.map +1 -1
  8. package/plugin/lib/commonjs/android/withLocationGradleProperties.js +16 -12
  9. package/plugin/lib/commonjs/android/withLocationGradleProperties.js.map +1 -1
  10. package/plugin/lib/commonjs/android/withMainApplicationModifications.js +19 -12
  11. package/plugin/lib/commonjs/android/withMainApplicationModifications.js.map +1 -1
  12. package/plugin/lib/commonjs/android/withNotificationChannelMetadata.js +2 -1
  13. package/plugin/lib/commonjs/android/withNotificationChannelMetadata.js.map +1 -1
  14. package/plugin/lib/commonjs/android/withProjectBuildGradle.js +29 -25
  15. package/plugin/lib/commonjs/android/withProjectBuildGradle.js.map +1 -1
  16. package/plugin/lib/commonjs/android/withProjectGoogleServices.js +9 -5
  17. package/plugin/lib/commonjs/android/withProjectGoogleServices.js.map +1 -1
  18. package/plugin/lib/commonjs/helpers/constants/ios.js +76 -8
  19. package/plugin/lib/commonjs/helpers/constants/ios.js.map +1 -1
  20. package/plugin/lib/commonjs/helpers/utils/injectCIOPodfileCode.js +76 -31
  21. package/plugin/lib/commonjs/helpers/utils/injectCIOPodfileCode.js.map +1 -1
  22. package/plugin/lib/commonjs/index.js +7 -0
  23. package/plugin/lib/commonjs/index.js.map +1 -1
  24. package/plugin/lib/commonjs/ios/withAppDelegateModifications.js +47 -33
  25. package/plugin/lib/commonjs/ios/withAppDelegateModifications.js.map +1 -1
  26. package/plugin/lib/commonjs/ios/withCIOIosSwift.js +44 -54
  27. package/plugin/lib/commonjs/ios/withCIOIosSwift.js.map +1 -1
  28. package/plugin/lib/commonjs/ios/withGoogleServicesJsonFile.js +46 -30
  29. package/plugin/lib/commonjs/ios/withGoogleServicesJsonFile.js.map +1 -1
  30. package/plugin/lib/commonjs/ios/withNotificationsXcodeProject.js +192 -122
  31. package/plugin/lib/commonjs/ios/withNotificationsXcodeProject.js.map +1 -1
  32. package/plugin/lib/commonjs/postInstallHelper.js +58 -11
  33. package/plugin/lib/commonjs/postInstallHelper.js.map +1 -1
  34. package/plugin/lib/commonjs/utils/resolveRNSDK.js +97 -0
  35. package/plugin/lib/commonjs/utils/resolveRNSDK.js.map +1 -0
  36. package/plugin/lib/commonjs/utils/writeExpoVersion.js +56 -0
  37. package/plugin/lib/commonjs/utils/writeExpoVersion.js.map +1 -0
  38. package/plugin/lib/module/android/withAndroidManifestUpdates.js +61 -58
  39. package/plugin/lib/module/android/withAndroidManifestUpdates.js.map +1 -1
  40. package/plugin/lib/module/android/withAppGoogleServices.js +9 -7
  41. package/plugin/lib/module/android/withAppGoogleServices.js.map +1 -1
  42. package/plugin/lib/module/android/withGoogleServicesJSON.js +17 -21
  43. package/plugin/lib/module/android/withGoogleServicesJSON.js.map +1 -1
  44. package/plugin/lib/module/android/withLocationGradleProperties.js +15 -12
  45. package/plugin/lib/module/android/withLocationGradleProperties.js.map +1 -1
  46. package/plugin/lib/module/android/withMainApplicationModifications.js +18 -12
  47. package/plugin/lib/module/android/withMainApplicationModifications.js.map +1 -1
  48. package/plugin/lib/module/android/withNotificationChannelMetadata.js +1 -1
  49. package/plugin/lib/module/android/withNotificationChannelMetadata.js.map +1 -1
  50. package/plugin/lib/module/android/withProjectBuildGradle.js +28 -25
  51. package/plugin/lib/module/android/withProjectBuildGradle.js.map +1 -1
  52. package/plugin/lib/module/android/withProjectGoogleServices.js +8 -5
  53. package/plugin/lib/module/android/withProjectGoogleServices.js.map +1 -1
  54. package/plugin/lib/module/helpers/constants/ios.js +75 -8
  55. package/plugin/lib/module/helpers/constants/ios.js.map +1 -1
  56. package/plugin/lib/module/helpers/utils/injectCIOPodfileCode.js +74 -31
  57. package/plugin/lib/module/helpers/utils/injectCIOPodfileCode.js.map +1 -1
  58. package/plugin/lib/module/index.js +7 -0
  59. package/plugin/lib/module/index.js.map +1 -1
  60. package/plugin/lib/module/ios/withAppDelegateModifications.js +45 -33
  61. package/plugin/lib/module/ios/withAppDelegateModifications.js.map +1 -1
  62. package/plugin/lib/module/ios/withCIOIosSwift.js +42 -54
  63. package/plugin/lib/module/ios/withCIOIosSwift.js.map +1 -1
  64. package/plugin/lib/module/ios/withGoogleServicesJsonFile.js +45 -30
  65. package/plugin/lib/module/ios/withGoogleServicesJsonFile.js.map +1 -1
  66. package/plugin/lib/module/ios/withNotificationsXcodeProject.js +187 -122
  67. package/plugin/lib/module/ios/withNotificationsXcodeProject.js.map +1 -1
  68. package/plugin/lib/module/postInstallHelper.js +58 -11
  69. package/plugin/lib/module/postInstallHelper.js.map +1 -1
  70. package/plugin/lib/module/utils/resolveRNSDK.js +88 -0
  71. package/plugin/lib/module/utils/resolveRNSDK.js.map +1 -0
  72. package/plugin/lib/module/utils/writeExpoVersion.js +48 -0
  73. package/plugin/lib/module/utils/writeExpoVersion.js.map +1 -0
  74. package/plugin/lib/typescript/android/withAndroidManifestUpdates.d.ts +2 -0
  75. package/plugin/lib/typescript/android/withAppGoogleServices.d.ts +1 -0
  76. package/plugin/lib/typescript/android/withGoogleServicesJSON.d.ts +1 -0
  77. package/plugin/lib/typescript/android/withLocationGradleProperties.d.ts +2 -0
  78. package/plugin/lib/typescript/android/withMainApplicationModifications.d.ts +6 -0
  79. package/plugin/lib/typescript/android/withNotificationChannelMetadata.d.ts +5 -0
  80. package/plugin/lib/typescript/android/withProjectBuildGradle.d.ts +9 -0
  81. package/plugin/lib/typescript/android/withProjectGoogleServices.d.ts +1 -0
  82. package/plugin/lib/typescript/helpers/constants/ios.d.ts +18 -0
  83. package/plugin/lib/typescript/helpers/utils/injectCIOPodfileCode.d.ts +25 -1
  84. package/plugin/lib/typescript/ios/withAppDelegateModifications.d.ts +13 -0
  85. package/plugin/lib/typescript/ios/withCIOIosSwift.d.ts +11 -0
  86. package/plugin/lib/typescript/ios/withGoogleServicesJsonFile.d.ts +14 -1
  87. package/plugin/lib/typescript/ios/withNotificationsXcodeProject.d.ts +53 -2
  88. package/plugin/lib/typescript/utils/resolveRNSDK.d.ts +7 -0
  89. package/plugin/lib/typescript/utils/writeExpoVersion.d.ts +3 -0
  90. package/plugin/src/android/withAndroidManifestUpdates.ts +83 -73
  91. package/plugin/src/android/withAppGoogleServices.ts +13 -11
  92. package/plugin/src/android/withGoogleServicesJSON.ts +30 -28
  93. package/plugin/src/android/withLocationGradleProperties.ts +23 -17
  94. package/plugin/src/android/withMainApplicationModifications.ts +25 -15
  95. package/plugin/src/android/withNotificationChannelMetadata.ts +1 -1
  96. package/plugin/src/android/withProjectBuildGradle.ts +37 -27
  97. package/plugin/src/android/withProjectGoogleServices.ts +14 -9
  98. package/plugin/src/helpers/constants/ios.ts +87 -8
  99. package/plugin/src/helpers/utils/injectCIOPodfileCode.ts +97 -50
  100. package/plugin/src/index.ts +7 -0
  101. package/plugin/src/ios/withAppDelegateModifications.ts +61 -48
  102. package/plugin/src/ios/withCIOIosSwift.ts +58 -62
  103. package/plugin/src/ios/withGoogleServicesJsonFile.ts +66 -48
  104. package/plugin/src/ios/withNotificationsXcodeProject.ts +257 -207
  105. package/plugin/src/postInstallHelper.js +75 -17
  106. package/plugin/src/utils/resolveRNSDK.ts +118 -0
  107. package/plugin/src/utils/writeExpoVersion.ts +62 -0
@@ -1,18 +1,97 @@
1
+ import fs from 'fs';
2
+ import * as semver from 'semver';
3
+
1
4
  const path = require('path');
2
- const resolveFrom = require('resolve-from');
5
+ import { resolveRNSDK, tryReadRNVersion } from '../../utils/resolveRNSDK';
6
+
7
+ // Threshold at which React Native pod autolinking moves from
8
+ // @react-native-community/cli (lexical, symlink-preserving) to
9
+ // expo-modules-autolinking (realpath). The two flavors emit different
10
+ // :path strings on pnpm/yarn-symlink layouts, so to keep CocoaPods happy
11
+ // we must match whichever flavor will resolve the same package later.
12
+ const RN_REALPATH_AUTOLINKING_MIN_VERSION = '0.80.0';
13
+
14
+ const PLUGIN_LOG_PREFIX = '[CustomerIO Plugin]';
15
+
16
+ // Always-on so the trail shows up in customer-shared `expo prebuild`
17
+ // output without needing a separate verbose-mode opt-in.
18
+ function pluginLog(message: string): void {
19
+ // eslint-disable-next-line no-console
20
+ console.log(`${PLUGIN_LOG_PREFIX} ${message}`);
21
+ }
3
22
 
23
+ /**
24
+ * Returns the relative path from the iOS project dir to the installed
25
+ * customerio-reactnative directory, in the exact form React Native pod
26
+ * autolinking will emit for the same package. The two autolinking
27
+ * flavors disagree on path shape under pnpm/yarn symlinks:
28
+ *
29
+ * - RN <0.80 (`@react-native-community/cli`): walks node_modules
30
+ * lexically, preserves symlinks. We keep the symlink path too —
31
+ * `tryResolveRNSDK` already does this without calling realpath.
32
+ *
33
+ * - RN >=0.80 (`expo-modules-autolinking`): realpaths the package
34
+ * via Node, emitting the underlying `.pnpm/...` (or yarn-classic)
35
+ * path. We match by realpath'ing the resolved directory.
36
+ *
37
+ * Decision points are logged so a customer's prebuild output is enough
38
+ * to triage path-resolution issues without a follow-up "set
39
+ * CUSTOMERIO_DEBUG_MODE and rerun" round-trip.
40
+ */
4
41
  export function getRelativePathToRNSDK(iosPath: string) {
5
- // Root path of the Expo project
6
42
  const rootAppPath = path.dirname(iosPath);
43
+ pluginLog(
44
+ `Resolving customerio-reactnative for Podfile (iosPath=${iosPath}, projectRoot=${rootAppPath})`
45
+ );
46
+
47
+ const { packageDir } = resolveRNSDK(rootAppPath);
48
+ pluginLog(`customerio-reactnative resolved to: ${packageDir}`);
7
49
 
8
- // Path of the cio RN package.json file. Example: test-app/node_modules/customerio-reactnative/package.json
9
- const pluginPackageJsonPath = resolveFrom.silent(
10
- rootAppPath,
11
- `customerio-reactnative/package.json`
50
+ const rnVersion = tryReadRNVersion(rootAppPath);
51
+ pluginLog(`Detected react-native version: ${rnVersion ?? 'unknown'}`);
52
+
53
+ const useLexical = shouldUseLexicalPath(rnVersion);
54
+ pluginLog(
55
+ useLexical
56
+ ? `RN <${RN_REALPATH_AUTOLINKING_MIN_VERSION} — using lexical/symlink path to match @react-native-community/cli autolinking`
57
+ : `RN >=${RN_REALPATH_AUTOLINKING_MIN_VERSION} or unknown — using realpath to match expo-modules-autolinking`
12
58
  );
13
59
 
14
- // Example: ../node_modules/customerio-reactnative
15
- return path.relative(iosPath, path.dirname(pluginPackageJsonPath));
60
+ let absolutePath: string;
61
+ if (useLexical) {
62
+ absolutePath = packageDir;
63
+ } else {
64
+ try {
65
+ absolutePath = fs.realpathSync(packageDir);
66
+ if (absolutePath !== packageDir) {
67
+ pluginLog(`Realpath differs from resolved dir: ${absolutePath}`);
68
+ }
69
+ } catch (err) {
70
+ pluginLog(
71
+ `realpathSync failed (${
72
+ err instanceof Error ? err.message : String(err)
73
+ }); falling back to symlink path`
74
+ );
75
+ absolutePath = packageDir;
76
+ }
77
+ }
78
+
79
+ const relativePath = path.relative(iosPath, absolutePath);
80
+ pluginLog(`Final Podfile :path => '${relativePath}'`);
81
+ return relativePath;
82
+ }
83
+
84
+ function shouldUseLexicalPath(rnVersion: string | null): boolean {
85
+ if (!rnVersion) {
86
+ // Modern Expo (realpath) has been the working path for the last few
87
+ // SDKs, so it's the safer default when RN can't be detected.
88
+ return false;
89
+ }
90
+ const coerced = semver.valid(rnVersion) || semver.coerce(rnVersion);
91
+ if (!coerced) {
92
+ return false;
93
+ }
94
+ return semver.lt(coerced, RN_REALPATH_AUTOLINKING_MIN_VERSION);
16
95
  }
17
96
 
18
97
  export const IOS_DEPLOYMENT_TARGET = '13.0';
@@ -11,92 +11,139 @@ export type InjectCIOPodfileOptions = {
11
11
  hasPush?: boolean;
12
12
  };
13
13
 
14
- /** Builds the host app pod line for the Podfile (single subspec or :subspecs with location). Exported for tests. */
14
+ /** Builds the host-app pod snippet for the Podfile.
15
+ *
16
+ * The :path is resolved at prebuild time by `getRelativePathToRNSDK`,
17
+ * which dispatches on the installed React Native version so the path
18
+ * matches what RN pod autolinking will emit (lexical for RN <0.80,
19
+ * realpath for RN >=0.80). Baking the resolved string directly avoids
20
+ * any Ruby/install-time logic in the Podfile and keeps the snippet
21
+ * trivially diff-able.
22
+ *
23
+ * Exported for tests.
24
+ */
15
25
  export function buildHostAppPodSnippet(
16
26
  iosPath: string,
17
27
  isFcmPushProvider: boolean,
18
28
  options?: InjectCIOPodfileOptions
19
29
  ): string {
20
- const path = getRelativePathToRNSDK(iosPath);
30
+ const resolvedPath = getRelativePathToRNSDK(iosPath);
21
31
  const locationEnabled = options?.locationEnabled === true;
22
32
  const hasPush = options?.hasPush !== false;
23
33
 
24
34
  if (!locationEnabled) {
25
35
  const subspec = isFcmPushProvider ? 'fcm' : 'apn';
26
- return `pod 'customerio-reactnative/${subspec}', :path => '${path}'`;
36
+ return `pod 'customerio-reactnative/${subspec}', :path => '${resolvedPath}'`;
27
37
  }
28
-
29
38
  if (!hasPush) {
30
- return `pod 'customerio-reactnative', :subspecs => ['location'], :path => '${path}'`;
39
+ return `pod 'customerio-reactnative', :subspecs => ['location'], :path => '${resolvedPath}'`;
31
40
  }
32
-
33
41
  const pushSubspec = isFcmPushProvider ? 'fcm' : 'apn';
34
- return `pod 'customerio-reactnative', :subspecs => ['${pushSubspec}', 'location'], :path => '${path}'`;
42
+ return `pod 'customerio-reactnative', :subspecs => ['${pushSubspec}', 'location'], :path => '${resolvedPath}'`;
35
43
  }
36
44
 
37
- export async function injectCIOPodfileCode(
45
+ const HOST_APP_BLOCK_START = '# --- CustomerIO Host App START ---';
46
+ const HOST_APP_BLOCK_END = '# --- CustomerIO Host App END ---';
47
+ const NOTIFICATION_BLOCK_START = '# --- CustomerIO Notification START ---';
48
+ const NOTIFICATION_BLOCK_END = '# --- CustomerIO Notification END ---';
49
+
50
+ /**
51
+ * Pure string transform: given the existing Podfile contents, returns the
52
+ * Podfile with the CustomerIO host-app block injected before the Expo
53
+ * `post_install do |installer|` anchor. Idempotent — returns input unchanged
54
+ * if the block is already present.
55
+ */
56
+ export function injectHostAppPodfileCode(
57
+ podfileContent: string,
38
58
  iosPath: string,
39
59
  isFcmPushProvider: boolean,
40
60
  options?: InjectCIOPodfileOptions
41
- ) {
42
- const blockStart = '# --- CustomerIO Host App START ---';
43
- const blockEnd = '# --- CustomerIO Host App END ---';
44
-
45
- const filename = `${iosPath}/Podfile`;
46
- const podfile = await FileManagement.read(filename);
47
- const matches = podfile.match(new RegExp(blockStart));
48
-
49
- if (!matches) {
50
- // We need to decide what line of code in the Podfile to insert our native code.
51
- // The "post_install" line is always present in an Expo project Podfile so it's reliable.
52
- // Find that line in the Podfile and then we will insert our code above that line.
53
- const lineInPodfileToInjectSnippetBefore = /post_install do \|installer\|/;
61
+ ): string {
62
+ if (podfileContent.match(new RegExp(HOST_APP_BLOCK_START))) {
63
+ return podfileContent;
64
+ }
54
65
 
55
- const podLine = buildHostAppPodSnippet(iosPath, isFcmPushProvider, options);
66
+ // We need to decide what line of code in the Podfile to insert our native code.
67
+ // The "post_install" line is always present in an Expo project Podfile so it's reliable.
68
+ // Find that line in the Podfile and then we will insert our code above that line.
69
+ const lineInPodfileToInjectSnippetBefore = /post_install do \|installer\|/;
70
+ const podLine = buildHostAppPodSnippet(iosPath, isFcmPushProvider, options);
56
71
 
57
- const snippetToInjectInPodfile = `
58
- ${blockStart}
72
+ const snippetToInjectInPodfile = `
73
+ ${HOST_APP_BLOCK_START}
59
74
  ${podLine}
60
- ${blockEnd}
75
+ ${HOST_APP_BLOCK_END}
61
76
  `.trim();
62
77
 
63
- FileManagement.write(
64
- filename,
65
- injectCodeByRegex(
66
- podfile,
67
- lineInPodfileToInjectSnippetBefore,
68
- snippetToInjectInPodfile
69
- ).join('\n')
70
- );
71
- } else {
72
- logger.info('CustomerIO Podfile snippets already exists. Skipping...');
73
- }
78
+ return injectCodeByRegex(
79
+ podfileContent,
80
+ lineInPodfileToInjectSnippetBefore,
81
+ snippetToInjectInPodfile,
82
+ ).join('\n');
74
83
  }
75
84
 
76
- export async function injectCIONotificationPodfileCode(
85
+ export async function injectCIOPodfileCode(
77
86
  iosPath: string,
78
- useFrameworks: CustomerIOPluginOptionsIOS['useFrameworks'],
79
- isFcmPushProvider: boolean
87
+ isFcmPushProvider: boolean,
88
+ options?: InjectCIOPodfileOptions
80
89
  ) {
81
90
  const filename = `${iosPath}/Podfile`;
82
91
  const podfile = await FileManagement.read(filename);
92
+ const next = injectHostAppPodfileCode(podfile, iosPath, isFcmPushProvider, options);
93
+ if (next !== podfile) {
94
+ FileManagement.write(filename, next);
95
+ } else {
96
+ logger.info('CustomerIO Podfile snippets already exists. Skipping...');
97
+ }
98
+ }
83
99
 
84
- const blockStart = '# --- CustomerIO Notification START ---';
85
- const blockEnd = '# --- CustomerIO Notification END ---';
86
-
87
- const matches = podfile.match(new RegExp(blockStart));
100
+ /**
101
+ * Pure string transform: given the existing Podfile contents, returns the
102
+ * Podfile with the rich-push NotificationService target block appended at
103
+ * the end. Idempotent — returns input unchanged if the block is already
104
+ * present.
105
+ */
106
+ export function appendNotificationTargetToPodfile(
107
+ podfileContent: string,
108
+ iosPath: string,
109
+ isFcmPushProvider: boolean,
110
+ useFrameworks: CustomerIOPluginOptionsIOS['useFrameworks'],
111
+ ): string {
112
+ if (podfileContent.match(new RegExp(NOTIFICATION_BLOCK_START))) {
113
+ return podfileContent;
114
+ }
88
115
 
89
- if (!matches) {
90
- const snippetToInjectInPodfile = `
91
- ${blockStart}
116
+ const snippetToAppend = `
117
+ ${NOTIFICATION_BLOCK_START}
92
118
  target 'NotificationService' do
93
119
  ${useFrameworks === 'static' ? 'use_frameworks! :linkage => :static' : ''}
94
- pod 'customerio-reactnative-richpush/${isFcmPushProvider ? 'fcm' : 'apn'
95
- }', :path => '${getRelativePathToRNSDK(iosPath)}'
120
+ pod 'customerio-reactnative-richpush/${isFcmPushProvider ? 'fcm' : 'apn'}', :path => '${getRelativePathToRNSDK(iosPath)}'
96
121
  end
97
- ${blockEnd}
122
+ ${NOTIFICATION_BLOCK_END}
98
123
  `.trim();
99
124
 
100
- FileManagement.append(filename, snippetToInjectInPodfile);
125
+ // Mirror FileManagement.append: append directly with no separator (real
126
+ // Podfiles end with a trailing newline, so the appended block starts on a
127
+ // fresh line in practice).
128
+ return `${podfileContent}${snippetToAppend}`;
129
+ }
130
+
131
+ export async function injectCIONotificationPodfileCode(
132
+ iosPath: string,
133
+ useFrameworks: CustomerIOPluginOptionsIOS['useFrameworks'],
134
+ isFcmPushProvider: boolean
135
+ ) {
136
+ const filename = `${iosPath}/Podfile`;
137
+ const podfile = await FileManagement.read(filename);
138
+ const next = appendNotificationTargetToPodfile(
139
+ podfile,
140
+ iosPath,
141
+ isFcmPushProvider,
142
+ useFrameworks,
143
+ );
144
+ if (next !== podfile) {
145
+ // FileManagement.append matches what the previous direct-append did.
146
+ // Slice off the leading content (already on disk) and append only the new tail.
147
+ FileManagement.append(filename, next.slice(podfile.length));
101
148
  }
102
149
  }
@@ -8,6 +8,7 @@ import type {
8
8
  LocationTrackingMode,
9
9
  NativeSDKConfig,
10
10
  } from './types/cio-types';
11
+ import { withExpoVersion } from './utils/writeExpoVersion';
11
12
 
12
13
  export type { LocationTrackingMode, NativeSDKConfig };
13
14
 
@@ -25,6 +26,12 @@ function withCustomerIOPlugin(
25
26
  );
26
27
  }
27
28
 
29
+ // Belt-and-suspenders write of the plugin version into the RN SDK's
30
+ // package.json. The postinstall hook does the same write at install time;
31
+ // this covers installs where postinstall didn't run cleanly (pnpm with
32
+ // strict store layouts, --ignore-scripts, cached CI installs, etc).
33
+ config = withExpoVersion(config);
34
+
28
35
  // Apply platform specific modifications
29
36
  config = withCIOIos(config, props.config, props.ios, props.location);
30
37
  config = withCIOAndroid(config, props.config, props.android, props.location);
@@ -153,9 +153,12 @@ const addFirebaseDelegateForwardDeclarationIfNeeded = (
153
153
  return stringContents;
154
154
  };
155
155
 
156
- const addAppdelegateHeaderModification = (stringContents: string) => {
157
- // Add UNUserNotificationCenterDelegate if needed
158
- stringContents = stringContents.replace(
156
+ /**
157
+ * Pure string transform: ensures the AppDelegate header (Objective-C path) declares
158
+ * `UNUserNotificationCenterDelegate` and imports `UserNotifications`. Idempotent.
159
+ */
160
+ export function modifyAppDelegateHeader(headerContent: string): string {
161
+ return headerContent.replace(
159
162
  CIO_APPDELEGATEHEADER_REGEX,
160
163
  (match, interfaceDeclaration, _groupedDelegates, existingDelegates) => {
161
164
  if (
@@ -179,9 +182,7 @@ ${interfaceDeclaration.trim()} <${CIO_APPDELEGATEHEADER_USER_NOTIFICATION_CENTER
179
182
  }
180
183
  }
181
184
  );
182
-
183
- return stringContents;
184
- };
185
+ }
185
186
 
186
187
  const addHandleDeeplinkInKilledState = (stringContents: string) => {
187
188
  // Find if the deep link code snippet is already present
@@ -219,59 +220,71 @@ const addHandleDeeplinkInKilledState = (stringContents: string) => {
219
220
  return stringContents;
220
221
  };
221
222
 
223
+ /**
224
+ * Pure string transform: produces the modified Objective-C AppDelegate.m / AppDelegate.mm
225
+ * contents wired with the Customer.io push pipeline (imports, declarations, notification
226
+ * configuration, registration callbacks, optional killed-state deep-link, FCM forward decl,
227
+ * Expo notifications header). The caller is responsible for the AppDelegate header file
228
+ * (.h) — see `modifyAppDelegateHeader`.
229
+ */
230
+ export function modifyAppDelegateContents(
231
+ contents: string,
232
+ projectName: string,
233
+ props: CustomerIOPluginOptionsIOS
234
+ ): string {
235
+ let next = addImport(contents, projectName);
236
+ next = addNotificationHandlerDeclaration(next);
237
+
238
+ // unless this property is explicity set to true, push notification
239
+ // registration will be added to the AppDelegate
240
+ if (props.pushNotification?.disableNotificationRegistration !== true) {
241
+ next = addNotificationConfiguration(next);
242
+ }
243
+
244
+ next = addInitializeNativeCioSdk(next);
245
+
246
+ if (props.pushNotification?.handleDeeplinkInKilledState === true) {
247
+ next = addHandleDeeplinkInKilledState(next);
248
+ }
249
+
250
+ next = addDidFailToRegisterForRemoteNotificationsWithError(next);
251
+ next = addDidRegisterForRemoteNotificationsWithDeviceToken(next);
252
+
253
+ if (isFcmPushProvider(props)) {
254
+ next = addFirebaseDelegateForwardDeclarationIfNeeded(next);
255
+ }
256
+
257
+ next = addExpoNotificationsHeaderModification(next);
258
+
259
+ return next;
260
+ }
261
+
222
262
  export const withAppDelegateModifications: ConfigPlugin<
223
263
  CustomerIOPluginOptionsIOS
224
264
  > = (configOuter, props) => {
225
265
  return withAppDelegate(configOuter, async (config) => {
226
- let stringContents = config.modResults.contents;
266
+ const stringContents = config.modResults.contents;
227
267
  const regex = new RegExp(
228
268
  `#import <${config.modRequest.projectName}-Swift.h>`
229
269
  );
230
- const match = stringContents.match(regex);
231
-
232
- if (!match) {
233
- const headerPath = getAppDelegateHeaderFilePath(
234
- config.modRequest.projectRoot
235
- );
236
- let headerContent = await FileManagement.read(headerPath);
237
- headerContent = addAppdelegateHeaderModification(headerContent);
238
- FileManagement.write(headerPath, headerContent);
239
-
240
- stringContents = addImport(
241
- stringContents,
242
- config.modRequest.projectName as string
243
- );
244
- stringContents = addNotificationHandlerDeclaration(stringContents);
245
-
246
- // unless this property is explicity set to true, push notification
247
- // registration will be added to the AppDelegate
248
- if (props.pushNotification?.disableNotificationRegistration !== true) {
249
- stringContents = addNotificationConfiguration(stringContents);
250
- }
251
-
252
- stringContents = addInitializeNativeCioSdk(stringContents);
253
-
254
- if (props.pushNotification?.handleDeeplinkInKilledState === true) {
255
- stringContents = addHandleDeeplinkInKilledState(stringContents);
256
- }
257
-
258
- stringContents =
259
- addDidFailToRegisterForRemoteNotificationsWithError(stringContents);
260
- stringContents =
261
- addDidRegisterForRemoteNotificationsWithDeviceToken(stringContents);
262
-
263
- if (isFcmPushProvider(props)) {
264
- stringContents =
265
- addFirebaseDelegateForwardDeclarationIfNeeded(stringContents);
266
- }
267
-
268
- stringContents = addExpoNotificationsHeaderModification(stringContents);
269
270
 
270
- config.modResults.contents = stringContents;
271
- } else {
271
+ if (stringContents.match(regex)) {
272
272
  logger.info('Customerio AppDelegate changes already exist. Skipping...');
273
+ return config;
273
274
  }
274
275
 
276
+ const headerPath = getAppDelegateHeaderFilePath(
277
+ config.modRequest.projectRoot
278
+ );
279
+ const headerContent = await FileManagement.read(headerPath);
280
+ FileManagement.write(headerPath, modifyAppDelegateHeader(headerContent));
281
+
282
+ config.modResults.contents = modifyAppDelegateContents(
283
+ stringContents,
284
+ config.modRequest.projectName as string,
285
+ props
286
+ );
287
+
275
288
  return config;
276
289
  });
277
290
  };
@@ -232,12 +232,19 @@ export const withCIOIosSwift = (
232
232
  if (props?.pushNotification) {
233
233
  // With push notifications: delegate to CioSdkAppDelegateHandler for both push and auto-init
234
234
  return withAppDelegate(configOuter, async (config) => {
235
- return modifyAppDelegateWithPushAppDelegateHandler(config, props);
235
+ config.modResults.contents = modifyAppDelegateForPushHandler(
236
+ config.modResults.contents,
237
+ props
238
+ );
239
+ return config;
236
240
  });
237
241
  } else if (sdkConfig) {
238
242
  // Without push notifications: directly inject auto initialization into AppDelegate
239
243
  return withAppDelegate(configOuter, async (config) => {
240
- return modifyAppDelegateWithNativeSDKInitializer(config);
244
+ config.modResults.contents = modifyAppDelegateForNativeSDKInitializer(
245
+ config.modResults.contents
246
+ );
247
+ return config;
241
248
  });
242
249
  } else {
243
250
  return configOuter;
@@ -245,77 +252,53 @@ export const withCIOIosSwift = (
245
252
  };
246
253
 
247
254
  /**
248
- * Modify the AppDelegate to integrate with Customer.io SDK
255
+ * Pure string transform: produces the Swift AppDelegate contents wired to delegate to
256
+ * `CioSdkAppDelegateHandler` for both push notifications and (when configured) auto-init.
257
+ * Idempotent — returns `contents` unchanged when the handler is already present.
249
258
  */
250
- const modifyAppDelegateWithPushAppDelegateHandler = (
251
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
252
- config: any,
259
+ export function modifyAppDelegateForPushHandler(
260
+ contents: string,
253
261
  props: CustomerIOPluginOptionsIOS
254
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
255
- ): any => {
256
- const appDelegateContent = config.modResults.contents;
257
-
258
- // Check if modifications have already been applied
259
- if (appDelegateContent.includes(CIO_SDK_APP_DELEGATE_HANDLER_CLASS)) {
262
+ ): string {
263
+ if (contents.includes(CIO_SDK_APP_DELEGATE_HANDLER_CLASS)) {
260
264
  logger.info(
261
265
  'CustomerIO Swift AppDelegate changes already exist. Skipping...'
262
266
  );
263
- return config;
267
+ return contents;
264
268
  }
265
269
 
266
- // Add the handler property declaration
267
- let modifiedContent = addHandlerPropertyDeclaration(appDelegateContent);
268
-
269
- // Modify didFinishLaunchingWithOptions to initialize and call the handler
270
- modifiedContent = modifyDidFinishLaunchingWithOptions(
271
- modifiedContent,
270
+ let next = addHandlerPropertyDeclaration(contents);
271
+ next = modifyDidFinishLaunchingWithOptions(
272
+ next,
272
273
  ` cioSdkHandler.application(application, didFinishLaunchingWithOptions: launchOptions)\n\n `
273
274
  );
275
+ next = addDidRegisterForRemoteNotificationsWithDeviceToken(next);
276
+ next = addDidFailToRegisterForRemoteNotificationsWithError(next);
274
277
 
275
- // Add didRegisterForRemoteNotificationsWithDeviceToken implementation
276
- modifiedContent =
277
- addDidRegisterForRemoteNotificationsWithDeviceToken(modifiedContent);
278
-
279
- // Add didFailToRegisterForRemoteNotificationsWithError implementation
280
- modifiedContent =
281
- addDidFailToRegisterForRemoteNotificationsWithError(modifiedContent);
282
-
283
- // Add deep link handling for killed state if enabled
284
278
  if (props.pushNotification?.handleDeeplinkInKilledState === true) {
285
- modifiedContent = addHandleDeeplinkInKilledState(modifiedContent);
279
+ next = addHandleDeeplinkInKilledState(next);
286
280
  }
287
281
 
288
- config.modResults.contents = modifiedContent;
289
- return config;
290
- };
282
+ return next;
283
+ }
291
284
 
292
285
  /**
293
- * Modify the AppDelegate to integrate with Customer.io SDK
286
+ * Pure string transform: injects the auto-init snippet into the Swift AppDelegate's
287
+ * didFinishLaunchingWithOptions for the no-push path. Idempotent.
294
288
  */
295
- const modifyAppDelegateWithNativeSDKInitializer = (
296
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
297
- config: any,
298
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
299
- ): any => {
300
- const appDelegateContent = config.modResults.contents;
301
-
302
- // Check if modifications have already been applied
303
- if (appDelegateContent.includes(CIO_NATIVE_SDK_INITIALIZE_CALL)) {
289
+ export function modifyAppDelegateForNativeSDKInitializer(contents: string): string {
290
+ if (contents.includes(CIO_NATIVE_SDK_INITIALIZE_CALL)) {
304
291
  logger.info(
305
292
  'CustomerIO Swift AppDelegate changes already exist. Skipping...'
306
293
  );
307
- return config;
294
+ return contents;
308
295
  }
309
296
 
310
- // Modify didFinishLaunchingWithOptions to initialize and call the handler
311
- const modifiedContent = modifyDidFinishLaunchingWithOptions(
312
- appDelegateContent,
297
+ return modifyDidFinishLaunchingWithOptions(
298
+ contents,
313
299
  CIO_NATIVE_SDK_INITIALIZE_SNIPPET,
314
300
  );
315
-
316
- config.modResults.contents = modifiedContent;
317
- return config;
318
- };
301
+ }
319
302
 
320
303
  /**
321
304
  * Check if a method exists in the AppDelegate content
@@ -500,34 +483,47 @@ const addDidFailToRegisterForRemoteNotificationsWithError = (
500
483
 
501
484
  /**
502
485
  * Add deep link handling for killed state
503
- * This replaces the return statement with deep link handling code
504
- * and a modified return statement that uses modifiedLaunchOptions
486
+ *
487
+ * On modern Expo Swift templates, RN is bootstrapped by `factory.startReactNative(...)`
488
+ * inside an `#if os(iOS) || os(tvOS)` guard, *before* the trailing `return super.application(...)`.
489
+ * The deep-link block must run before that call so `modifiedLaunchOptions` flows into RN's
490
+ * initial launchOptions; otherwise the workaround is a no-op.
491
+ *
492
+ * For older templates (no `factory.startReactNative` — `super.application(...)` is what
493
+ * starts RN), the snippet is injected before the return statement as before.
505
494
  */
506
495
  const addHandleDeeplinkInKilledState = (content: string): string => {
507
- // Check if deep link code snippet is already present
508
496
  const deepLinkMarker = 'Deep link workaround for app killed state start';
509
497
  if (content.includes(deepLinkMarker)) {
510
498
  return content;
511
499
  }
512
500
 
513
- // Find the return statement with launchOptions
514
501
  const returnStatementRegex =
515
502
  /return\s+super\.application\s*\(\s*application\s*,\s*didFinishLaunchingWithOptions\s*:\s*launchOptions\s*\)/;
516
- const returnStatementMatch = content.match(returnStatementRegex);
503
+ const modifiedReturnStatement =
504
+ 'return super.application(application, didFinishLaunchingWithOptions: modifiedLaunchOptions)';
517
505
 
518
- if (!returnStatementMatch) {
506
+ const factoryStartRegex =
507
+ /(\s*)#if\s+os\(iOS\)\s*\|\|\s*os\(tvOS\)([\s\S]*?factory\.startReactNative\s*\([\s\S]*?launchOptions:\s*)launchOptions(\s*\)[\s\S]*?#endif)/;
508
+
509
+ if (factoryStartRegex.test(content)) {
510
+ let result = content.replace(
511
+ factoryStartRegex,
512
+ `\n${CIO_CONFIGUREDEEPLINK_KILLEDSTATE_SWIFT_SNIPPET}\n#if os(iOS) || os(tvOS)$2modifiedLaunchOptions$3`
513
+ );
514
+ if (returnStatementRegex.test(result)) {
515
+ result = result.replace(returnStatementRegex, modifiedReturnStatement);
516
+ }
517
+ return result;
518
+ }
519
+
520
+ if (!returnStatementRegex.test(content)) {
519
521
  logger.warn('Could not find return statement with launchOptions');
520
522
  return content;
521
523
  }
522
-
523
- // Create the replacement code with deep link handling and modified return statement
524
- const modifiedReturnStatement =
525
- 'return super.application(application, didFinishLaunchingWithOptions: modifiedLaunchOptions)';
526
524
  const replacementCode =
527
525
  CIO_CONFIGUREDEEPLINK_KILLEDSTATE_SWIFT_SNIPPET +
528
526
  '\n\n ' +
529
527
  modifiedReturnStatement;
530
-
531
- // Replace the return statement with deep link handling code and modified return statement
532
528
  return content.replace(returnStatementRegex, replacementCode);
533
529
  };