Skip to content

Commit 0b80597

Browse files
authored
feat(expo): Add Expo config plugin (#3391)
1 parent cbb32a7 commit 0b80597

11 files changed

+4796
-125
lines changed

.vscode/settings.json

+1-3
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@
22
"editor.formatOnType": true,
33
"editor.formatOnPaste": false,
44
"editor.formatOnSave": true,
5-
"editor.rulers": [
6-
120
7-
],
5+
"editor.rulers": [120],
86
"editor.tabSize": 2,
97
"files.autoSave": "onWindowChange",
108
"files.trimTrailingWhitespace": true,

app.plugin.js

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = require('./plugin/build');

package.json

+15-4
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@
1818
},
1919
"main": "dist/js/index.js",
2020
"scripts": {
21-
"build": "yarn build:sdk && yarn downlevel && yarn build:tools",
21+
"build": "yarn build:sdk && yarn downlevel && yarn build:tools && yarn build:plugin",
2222
"build:sdk": "tsc -p tsconfig.build.json",
2323
"build:sdk:watch": "tsc -p tsconfig.build.json -w --preserveWatchOutput",
2424
"build:tools": "tsc -p tsconfig.build.tools.json",
25+
"build:plugin": "EXPO_NONINTERACTIVE=true expo-module build plugin",
2526
"downlevel": "downlevel-dts dist ts3.8/dist --to=3.8",
26-
"clean": "rimraf dist coverage",
27+
"clean": "rimraf dist coverage && yarn clean:plugin",
28+
"clean:plugin": "expo-module clean plugin",
2729
"test": "yarn test:sdk && yarn test:tools",
2830
"test:sdk": "jest",
2931
"test:tools": "jest --config jest.config.tools.js",
@@ -36,7 +38,8 @@
3638
"test:watch": "jest --watch",
3739
"run-ios": "cd sample-new-architecture && yarn react-native run-ios",
3840
"run-android": "cd sample-new-architecture && yarn react-native run-android",
39-
"yalc:add:sentry-javascript": "yalc add @sentry/browser @sentry/core @sentry/hub @sentry/integrations @sentry/react @sentry/types @sentry/utils"
41+
"yalc:add:sentry-javascript": "yalc add @sentry/browser @sentry/core @sentry/hub @sentry/integrations @sentry/react @sentry/types @sentry/utils",
42+
"expo-module": "expo-module"
4043
},
4144
"keywords": [
4245
"react-native",
@@ -55,7 +58,8 @@
5558
],
5659
"peerDependencies": {
5760
"react": ">=17.0.0",
58-
"react-native": ">=0.65.0"
61+
"react-native": ">=0.65.0",
62+
"expo": ">=47.0.0"
5963
},
6064
"dependencies": {
6165
"@sentry/browser": "7.80.0",
@@ -81,6 +85,8 @@
8185
"eslint": "^7.6.0",
8286
"eslint-plugin-react": "^7.20.6",
8387
"eslint-plugin-react-native": "^3.8.1",
88+
"expo": "^47.0.0",
89+
"expo-module-scripts": "^3.1.0",
8490
"jest": "^29.6.2",
8591
"jest-environment-jsdom": "^29.6.2",
8692
"metro": "0.76",
@@ -109,5 +115,10 @@
109115
"android": {
110116
"javaPackageName": "io.sentry.react"
111117
}
118+
},
119+
"peerDependenciesMeta": {
120+
"expo": {
121+
"optional": true
122+
}
112123
}
113124
}

plugin/src/withSentry.ts

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { ConfigPlugin } from 'expo/config-plugins';
2+
import { createRunOncePlugin, WarningAggregator } from 'expo/config-plugins';
3+
4+
import { withSentryAndroid } from './withSentryAndroid';
5+
import { withSentryIOS } from './withSentryIOS';
6+
7+
// eslint-disable-next-line @typescript-eslint/no-var-requires
8+
const pkg = require('../../package.json');
9+
10+
interface PluginProps {
11+
organization?: string;
12+
project?: string;
13+
authToken?: string;
14+
url?: string;
15+
}
16+
17+
const withSentry: ConfigPlugin<PluginProps | void> = (config, props) => {
18+
const sentryProperties = getSentryProperties(props);
19+
let cfg = config;
20+
if (sentryProperties !== null) {
21+
try {
22+
cfg = withSentryAndroid(cfg, sentryProperties);
23+
} catch (e) {
24+
WarningAggregator.addWarningAndroid(
25+
'sentry-expo',
26+
`There was a problem configuring sentry-expo in your native Android project: ${e}`,
27+
);
28+
}
29+
try {
30+
cfg = withSentryIOS(cfg, sentryProperties);
31+
} catch (e) {
32+
WarningAggregator.addWarningIOS(
33+
'sentry-expo',
34+
`There was a problem configuring sentry-expo in your native iOS project: ${e}`,
35+
);
36+
}
37+
}
38+
return cfg;
39+
};
40+
41+
const missingAuthTokenMessage = '# auth.token is configured through SENTRY_AUTH_TOKEN environment variable';
42+
const missingProjectMessage = '# no project found, falling back to SENTRY_PROJECT environment variable';
43+
const missingOrgMessage = '# no org found, falling back to SENTRY_ORG environment variable';
44+
45+
export function getSentryProperties(props: PluginProps | void): string | null {
46+
const { organization, project, authToken, url = 'https://sentry.io/' } = props ?? {};
47+
// eslint-disable-next-line no-prototype-builtins
48+
const missingProperties = ['organization', 'project'].filter(each => !props?.hasOwnProperty(each));
49+
50+
if (missingProperties.length) {
51+
const warningMessage = `Missing Sentry configuration properties: ${missingProperties.join(
52+
', ',
53+
)} in config plugin. Builds will fall back to environment variables. Refer to @sentry/react-native docs for how to configure this.`;
54+
WarningAggregator.addWarningAndroid('sentry-expo', warningMessage);
55+
WarningAggregator.addWarningIOS('sentry-expo', warningMessage);
56+
}
57+
58+
return `defaults.url=${url}
59+
${organization ? `defaults.org=${organization}` : missingOrgMessage}
60+
${project ? `defaults.project=${project}` : missingProjectMessage}
61+
${
62+
authToken
63+
? `# Configure this value through \`SENTRY_AUTH_TOKEN\` environment variable instead. See:https://docs.expo.dev/guides/using-sentry/#app-configuration\nauth.token=${authToken}`
64+
: missingAuthTokenMessage
65+
}
66+
`;
67+
}
68+
69+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
70+
export default createRunOncePlugin(withSentry, pkg.name, pkg.version);

plugin/src/withSentryAndroid.ts

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { ConfigPlugin } from 'expo/config-plugins';
2+
import { WarningAggregator, withAppBuildGradle, withDangerousMod } from 'expo/config-plugins';
3+
import * as path from 'path';
4+
5+
import { writeSentryPropertiesTo } from './withSentryIOS';
6+
7+
export const withSentryAndroid: ConfigPlugin<string> = (config, sentryProperties: string) => {
8+
const cfg = withAppBuildGradle(config, config => {
9+
if (config.modResults.language === 'groovy') {
10+
config.modResults.contents = modifyAppBuildGradle(config.modResults.contents);
11+
} else {
12+
throw new Error('Cannot configure Sentry in the app gradle because the build.gradle is not groovy');
13+
}
14+
return config;
15+
});
16+
return withDangerousMod(cfg, [
17+
'android',
18+
config => {
19+
writeSentryPropertiesTo(path.resolve(config.modRequest.projectRoot, 'android'), sentryProperties);
20+
return config;
21+
},
22+
]);
23+
};
24+
25+
const resolveSentryReactNativePackageJsonPath =
26+
'["node", "--print", "require.resolve(\'@sentry/react-native/package.json\')"].execute().text.trim()';
27+
28+
/**
29+
* Writes to projectDirectory/android/app/build.gradle,
30+
* adding the relevant @sentry/react-native script.
31+
*/
32+
export function modifyAppBuildGradle(buildGradle: string): string {
33+
if (buildGradle.includes('/sentry.gradle"')) {
34+
return buildGradle;
35+
}
36+
37+
// Use the same location that sentry-wizard uses
38+
// See: https://github.com/getsentry/sentry-wizard/blob/e9b4522f27a852069c862bd458bdf9b07cab6e33/lib/Steps/Integrations/ReactNative.ts#L232
39+
const pattern = /^android {/m;
40+
41+
if (!buildGradle.match(pattern)) {
42+
WarningAggregator.addWarningAndroid(
43+
'sentry-expo',
44+
'Could not find react.gradle script in android/app/build.gradle. Please open a bug report at https://github.com/expo/sentry-expo.',
45+
);
46+
}
47+
48+
const sentryOptions = !buildGradle.includes('project.ext.sentryCli')
49+
? `project.ext.sentryCli=[collectModulesScript: new File(${resolveSentryReactNativePackageJsonPath}, "../dist/js/tools/collectModules.js")]`
50+
: '';
51+
const applyFrom = `apply from: new File(${resolveSentryReactNativePackageJsonPath}, "../sentry.gradle")`;
52+
53+
return buildGradle.replace(pattern, match => `${sentryOptions}\n\n${applyFrom}\n\n${match}`);
54+
}

plugin/src/withSentryIOS.ts

+77
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
2+
import type { ConfigPlugin, XcodeProject } from 'expo/config-plugins';
3+
import { WarningAggregator, withDangerousMod, withXcodeProject } from 'expo/config-plugins';
4+
import * as fs from 'fs';
5+
import * as path from 'path';
6+
7+
const SENTRY_CLI = "`node --print \"require.resolve('@sentry/cli/package.json').slice(0, -13) + '/bin/sentry-cli'\"`";
8+
9+
export const withSentryIOS: ConfigPlugin<string> = (config, sentryProperties: string) => {
10+
const cfg = withXcodeProject(config, config => {
11+
const xcodeProject: XcodeProject = config.modResults;
12+
13+
const sentryBuildPhase = xcodeProject.pbxItemByComment(
14+
'Upload Debug Symbols to Sentry',
15+
'PBXShellScriptBuildPhase',
16+
);
17+
if (!sentryBuildPhase) {
18+
xcodeProject.addBuildPhase([], 'PBXShellScriptBuildPhase', 'Upload Debug Symbols to Sentry', null, {
19+
shellPath: '/bin/sh',
20+
shellScript: `
21+
export SENTRY_PROPERTIES=sentry.properties
22+
[[ $SENTRY_INCLUDE_NATIVE_SOURCES == "true" ]] && INCLUDE_SOURCES_FLAG="--include-sources" || INCLUDE_SOURCES_FLAG=""
23+
${SENTRY_CLI} debug-files upload --force-foreground "$INCLUDE_SOURCES_FLAG" "$DWARF_DSYM_FOLDER_PATH"
24+
`,
25+
});
26+
}
27+
28+
const bundleReactNativePhase = xcodeProject.pbxItemByComment(
29+
'Bundle React Native code and images',
30+
'PBXShellScriptBuildPhase',
31+
);
32+
modifyExistingXcodeBuildScript(bundleReactNativePhase);
33+
34+
return config;
35+
});
36+
37+
return withDangerousMod(cfg, [
38+
'ios',
39+
config => {
40+
writeSentryPropertiesTo(path.resolve(config.modRequest.projectRoot, 'ios'), sentryProperties);
41+
return config;
42+
},
43+
]);
44+
};
45+
46+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
47+
export function modifyExistingXcodeBuildScript(script: any): void {
48+
if (
49+
!script.shellScript.match(/(packager|scripts)\/react-native-xcode\.sh\b/) ||
50+
script.shellScript.match(/bin\/sentry-cli.*react-native[\s-]xcode/)
51+
) {
52+
WarningAggregator.addWarningIOS(
53+
'sentry-expo',
54+
"Unable to modify build script 'Bundle React Native code and images'. Please open a bug report at https://github.com/expo/sentry-expo.",
55+
);
56+
return;
57+
}
58+
let code = JSON.parse(script.shellScript);
59+
code = `${
60+
'export SENTRY_PROPERTIES=sentry.properties\n' +
61+
'export EXTRA_PACKAGER_ARGS="--sourcemap-output $DERIVED_FILE_DIR/main.jsbundle.map"\n'
62+
}${code.replace(
63+
/^.*?(packager|scripts)\/react-native-xcode\.sh\s*(\\'\\\\")?/m,
64+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
65+
(match: any) => `${SENTRY_CLI} react-native xcode --force-foreground ${match}`,
66+
)}\n\n\`node --print "require.resolve('@sentry/react-native/package.json').slice(0, -13) + '/scripts/collect-modules.sh'"\``;
67+
68+
script.shellScript = JSON.stringify(code);
69+
}
70+
71+
export function writeSentryPropertiesTo(filepath: string, sentryProperties: string): void {
72+
if (!fs.existsSync(filepath)) {
73+
throw new Error(`Directory '${filepath}' does not exist.`);
74+
}
75+
76+
fs.writeFileSync(path.resolve(filepath, 'sentry.properties'), sentryProperties);
77+
}

plugin/tsconfig.json

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"extends": "expo-module-scripts/tsconfig.plugin",
3+
"compilerOptions": {
4+
"outDir": "build",
5+
"rootDir": "src"
6+
},
7+
"include": ["./src"],
8+
"exclude": ["**/__mocks__/*", "**/__tests__/*"]
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { WarningAggregator } from 'expo/config-plugins';
2+
3+
import { modifyAppBuildGradle } from '../../plugin/src/withSentryAndroid';
4+
5+
jest.mock('@expo/config-plugins', () => {
6+
const plugins = jest.requireActual('expo/config-plugins');
7+
return {
8+
...plugins,
9+
WarningAggregator: { addWarningAndroid: jest.fn() },
10+
};
11+
});
12+
13+
const buildGradleWithSentry = `
14+
apply from: new File(["node", "--print", "require.resolve('@sentry/react-native/package.json')"].execute().text.trim(), "../sentry.gradle")
15+
16+
android {
17+
}
18+
`;
19+
20+
const buildGradleWithOutSentry = `
21+
android {
22+
}
23+
`;
24+
25+
const monoRepoBuildGradleWithSentry = `
26+
apply from: new File(["node", "--print", "require.resolve('@sentry/react-native/package.json')"].execute().text.trim(), "../sentry.gradle")
27+
28+
android {
29+
}
30+
`;
31+
32+
const monoRepoBuildGradleWithOutSentry = `
33+
android {
34+
}
35+
`;
36+
37+
const buildGradleWithOutReactGradleScript = `
38+
`;
39+
40+
describe('Configures Android native project correctly', () => {
41+
it("Non monorepo: Doesn't modify app/build.gradle if Sentry's already configured", () => {
42+
expect(modifyAppBuildGradle(buildGradleWithSentry)).toMatch(buildGradleWithSentry);
43+
});
44+
45+
it('Non monorepo: Adds sentry.gradle script if not present already', () => {
46+
expect(modifyAppBuildGradle(buildGradleWithOutSentry)).toMatch(buildGradleWithSentry);
47+
});
48+
49+
it("Monorepo: Doesn't modify app/build.gradle if Sentry's already configured", () => {
50+
expect(modifyAppBuildGradle(monoRepoBuildGradleWithSentry)).toMatch(monoRepoBuildGradleWithSentry);
51+
});
52+
53+
it('Monorepo: Adds sentry.gradle script if not present already', () => {
54+
expect(modifyAppBuildGradle(monoRepoBuildGradleWithOutSentry)).toMatch(monoRepoBuildGradleWithSentry);
55+
});
56+
57+
it('Warns to file a bug report if no react.gradle is found', () => {
58+
modifyAppBuildGradle(buildGradleWithOutReactGradleScript);
59+
expect(WarningAggregator.addWarningAndroid).toHaveBeenCalled();
60+
});
61+
});

0 commit comments

Comments
 (0)