Skip to content

Commit c0b9d46

Browse files
zibsCopilot
andauthored
fix(expo): support Swift AppDelegate without public (#1123)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 10f70f3 commit c0b9d46

9 files changed

Lines changed: 1375 additions & 1965 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-native-app-auth": patch
3+
---
4+
5+
Fix Expo config plugin support for Swift AppDelegate templates that omit `public` before the AppDelegate class declaration, and correctly extract URL schemes from AppAuth redirect URLs that use a single slash.

examples/expo-cng/app.json

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,6 @@
66
"orientation": "portrait",
77
"icon": "./assets/icon.png",
88
"userInterfaceStyle": "light",
9-
"newArchEnabled": true,
10-
"splash": {
11-
"image": "./assets/splash-icon.png",
12-
"resizeMode": "contain",
13-
"backgroundColor": "#ffffff"
14-
},
159
"ios": {
1610
"supportsTablet": true,
1711
"bundleIdentifier": "com.anonymous.expocng"
@@ -21,7 +15,6 @@
2115
"foregroundImage": "./assets/adaptive-icon.png",
2216
"backgroundColor": "#ffffff"
2317
},
24-
"edgeToEdgeEnabled": true,
2518
"package": "com.anonymous.expocng"
2619
},
2720
"web": {
@@ -35,7 +28,8 @@
3528
"io.identityserver.demo:/oauthredirect"
3629
]
3730
}
38-
]
31+
],
32+
"expo-status-bar"
3933
]
4034
}
4135
}

examples/expo-cng/package.json

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"name": "expo-cng",
33
"version": "1.0.0",
4+
"packageManager": "yarn@1.22.22",
45
"main": "index.ts",
56
"scripts": {
67
"start": "expo start",
@@ -9,16 +10,16 @@
910
"web": "expo start --web"
1011
},
1112
"dependencies": {
12-
"expo": "~53.0.20",
13-
"expo-status-bar": "~2.2.3",
14-
"react": "19.0.0",
15-
"react-native": "0.79.5",
13+
"expo": "~56.0.14",
14+
"expo-status-bar": "~56.0.4",
15+
"react": "19.2.3",
16+
"react-native": "0.85.3",
1617
"react-native-app-auth": "file:../../packages/react-native-app-auth"
1718
},
1819
"devDependencies": {
19-
"@babel/core": "^7.25.2",
20-
"@types/react": "~19.0.10",
21-
"typescript": "~5.8.3"
20+
"@babel/core": "^7.29.0",
21+
"@types/react": "~19.2.14",
22+
"typescript": "~6.0.3"
2223
},
2324
"private": true
2425
}

examples/expo-cng/yarn.lock

Lines changed: 1131 additions & 1915 deletions
Large diffs are not rendered by default.
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { applyExpo53AppDelegatePatch } from '../ios/app-delegate';
2+
3+
const expo56AppDelegate = `internal import Expo
4+
import React
5+
import ReactAppDependencyProvider
6+
7+
@main
8+
class AppDelegate: ExpoAppDelegate {
9+
var window: UIWindow?
10+
11+
var reactNativeDelegate: ExpoReactNativeFactoryDelegate?
12+
var reactNativeFactory: RCTReactNativeFactory?
13+
14+
public override func application(
15+
_ app: UIApplication,
16+
open url: URL,
17+
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
18+
) -> Bool {
19+
return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options)
20+
}
21+
}`;
22+
23+
const publicAppDelegate = expo56AppDelegate.replace(
24+
'class AppDelegate: ExpoAppDelegate',
25+
'public class AppDelegate: ExpoAppDelegate'
26+
);
27+
28+
describe('applyExpo53AppDelegatePatch', () => {
29+
it('adds AppAuth conformance to Expo 56 Swift AppDelegate templates', () => {
30+
const result = applyExpo53AppDelegatePatch(expo56AppDelegate);
31+
32+
expect(result).toContain('class AppDelegate: ExpoAppDelegate, RNAppAuthAuthorizationFlowManager {');
33+
expect(result).toContain(
34+
'public weak var authorizationFlowManagerDelegate: RNAppAuthAuthorizationFlowManagerDelegate?'
35+
);
36+
expect(result).toContain('authorizationFlowManagerDelegate.resumeExternalUserAgentFlow(with: url)');
37+
});
38+
39+
it('preserves older public Swift AppDelegate templates', () => {
40+
const result = applyExpo53AppDelegatePatch(publicAppDelegate);
41+
42+
expect(result).toContain('public class AppDelegate: ExpoAppDelegate, RNAppAuthAuthorizationFlowManager {');
43+
});
44+
45+
it('preserves existing protocol conformances', () => {
46+
const result = applyExpo53AppDelegatePatch(
47+
expo56AppDelegate.replace(
48+
'class AppDelegate: ExpoAppDelegate',
49+
'class AppDelegate: ExpoAppDelegate, UIApplicationDelegate'
50+
)
51+
);
52+
53+
expect(result).toContain(
54+
'class AppDelegate: ExpoAppDelegate, UIApplicationDelegate, RNAppAuthAuthorizationFlowManager {'
55+
);
56+
});
57+
58+
it('preserves whitespace before the class opening brace', () => {
59+
const result = applyExpo53AppDelegatePatch(
60+
expo56AppDelegate.replace(
61+
'class AppDelegate: ExpoAppDelegate {',
62+
`class AppDelegate: ExpoAppDelegate,
63+
UIApplicationDelegate
64+
{`
65+
)
66+
);
67+
68+
expect(result).toContain(`class AppDelegate: ExpoAppDelegate,
69+
UIApplicationDelegate, RNAppAuthAuthorizationFlowManager
70+
{`);
71+
});
72+
73+
it('does not duplicate an existing multiline AppAuth delegate property', () => {
74+
const appDelegateWithMultilineProperty = expo56AppDelegate.replace(
75+
' var reactNativeFactory: RCTReactNativeFactory?',
76+
` var reactNativeFactory: RCTReactNativeFactory?
77+
78+
public weak var authorizationFlowManagerDelegate:
79+
RNAppAuthAuthorizationFlowManagerDelegate?`
80+
);
81+
82+
const result = applyExpo53AppDelegatePatch(appDelegateWithMultilineProperty);
83+
84+
expect(result.match(/\bvar\s+authorizationFlowManagerDelegate\b/g)).toHaveLength(1);
85+
});
86+
87+
it('is idempotent', () => {
88+
const once = applyExpo53AppDelegatePatch(expo56AppDelegate);
89+
const twice = applyExpo53AppDelegatePatch(once);
90+
91+
expect(twice).toBe(once);
92+
});
93+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { getRedirectUrlScheme } from '../index';
2+
3+
describe('getRedirectUrlScheme', () => {
4+
it('extracts a scheme from single-slash AppAuth redirect URLs', () => {
5+
expect(getRedirectUrlScheme('io.identityserver.demo:/oauthredirect')).toBe('io.identityserver.demo');
6+
});
7+
8+
it('extracts a scheme from double-slash redirect URLs', () => {
9+
expect(getRedirectUrlScheme('rnaa-demo://oauthredirect')).toBe('rnaa-demo');
10+
});
11+
});
Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,63 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
14
interface ExpoConfig {
25
sdkVersion?: string;
6+
_internal?: {
7+
[key: string]: any;
8+
projectRoot?: string;
9+
};
310
}
411

5-
const EXPO_SDK_MAJOR_VERSION = 53;
12+
export const MIN_EXPO_SDK_MAJOR_VERSION = 53;
13+
14+
const parseMajorVersion = (version?: string): number | null => {
15+
if (!version) {
16+
return null;
17+
}
18+
19+
const match = version.match(/\d+/);
20+
if (!match) {
21+
return null;
22+
}
23+
24+
return Number.parseInt(match[0], 10);
25+
};
26+
27+
const readExpoPackageVersion = (projectRoot?: string): string | undefined => {
28+
if (!projectRoot) {
29+
return undefined;
30+
}
31+
32+
const packageJsonPath = path.join(projectRoot, 'package.json');
33+
if (!fs.existsSync(packageJsonPath)) {
34+
return undefined;
35+
}
36+
37+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
38+
return packageJson.dependencies?.expo || packageJson.devDependencies?.expo;
39+
};
40+
41+
export const getExpoSdkMajorVersion = (
42+
config: ExpoConfig,
43+
projectRoot = config._internal?.projectRoot
44+
): number | null => {
45+
return (
46+
parseMajorVersion(config.sdkVersion) ??
47+
parseMajorVersion(readExpoPackageVersion(projectRoot))
48+
);
49+
};
50+
51+
export const isExpo53OrLater = (config: ExpoConfig, projectRoot?: string): boolean => {
52+
const major = getExpoSdkMajorVersion(config, projectRoot);
53+
return major != null && major >= MIN_EXPO_SDK_MAJOR_VERSION;
54+
};
655

7-
export const isExpo53OrLater = (config: ExpoConfig): boolean => {
8-
const expoSdkVersion = config.sdkVersion || '0.0.0';
9-
const [major] = expoSdkVersion.split('.');
10-
return Number.parseInt(major, 10) >= EXPO_SDK_MAJOR_VERSION;
11-
};
56+
export const assertExpo53OrLater = (config: ExpoConfig, projectRoot?: string): void => {
57+
const major = getExpoSdkMajorVersion(config, projectRoot);
58+
if (major != null && major < MIN_EXPO_SDK_MAJOR_VERSION) {
59+
throw new Error(
60+
`react-native-app-auth iOS Swift AppDelegate patch requires Expo SDK ${MIN_EXPO_SDK_MAJOR_VERSION} or later. Detected Expo SDK ${major}.`
61+
);
62+
}
63+
};

packages/react-native-app-auth/plugin/src/index.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,20 @@ import { withAppAuthAppBuildGradle } from './android';
1111

1212
const packageJson = require('../../package.json');
1313

14+
export const getRedirectUrlScheme = (redirectUrl?: string): string | undefined => {
15+
return redirectUrl?.split(':')[0];
16+
};
17+
1418
const withAppAuth: AppAuthConfigPlugin = (config, props) => {
19+
const redirectUrlScheme = getRedirectUrlScheme(props?.redirectUrls?.[0]);
20+
1521
// Transform redirectUrls configuration to platform-specific format
1622
const transformedProps: AppAuthProps = props?.redirectUrls ? {
1723
ios: {
18-
urlScheme: props.redirectUrls[0]?.split('://')[0], // Extract scheme from first URL
24+
urlScheme: redirectUrlScheme,
1925
},
2026
android: {
21-
appAuthRedirectScheme: props.redirectUrls[0]?.split('://')[0], // Extract scheme from first URL
27+
appAuthRedirectScheme: redirectUrlScheme,
2228
},
2329
...props,
2430
} : (props || {});
@@ -36,4 +42,4 @@ const withAppAuth: AppAuthConfigPlugin = (config, props) => {
3642
]);
3743
};
3844

39-
export default createRunOncePlugin(withAppAuth, packageJson.name, packageJson.version);
45+
export default createRunOncePlugin(withAppAuth, packageJson.name, packageJson.version);
Lines changed: 58 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,69 @@
11
import { withAppDelegate, ConfigPlugin } from '@expo/config-plugins';
2-
import { isExpo53OrLater } from '../expo-version';
2+
import { assertExpo53OrLater, isExpo53OrLater } from '../expo-version';
33

44
const codeModIOs = require('@expo/config-plugins/build/ios/codeMod');
55

6-
const withAppDelegateSwift: ConfigPlugin = rootConfig => {
7-
return withAppDelegate(rootConfig, config => {
8-
let { contents } = config.modResults;
9-
10-
if (!contents.includes('RNAppAuthAuthorizationFlowManager')) {
11-
const replaceText = 'class AppDelegate: ExpoAppDelegate';
12-
contents = contents.replace(replaceText, `${replaceText}, RNAppAuthAuthorizationFlowManager`);
13-
14-
const replaceText2 =
15-
'return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options)';
16-
contents = contents.replace(
17-
replaceText2,
18-
`if let authorizationFlowManagerDelegate = self.authorizationFlowManagerDelegate {
6+
const APP_AUTH_PROTOCOL = 'RNAppAuthAuthorizationFlowManager';
7+
const APP_AUTH_DELEGATE_PROPERTY =
8+
'public weak var authorizationFlowManagerDelegate: RNAppAuthAuthorizationFlowManagerDelegate?';
9+
const APP_AUTH_DELEGATE_PROPERTY_PATTERN =
10+
/\bvar\s+authorizationFlowManagerDelegate\s*:\s*RNAppAuthAuthorizationFlowManagerDelegate\??/;
11+
const APP_AUTH_RESUME_BLOCK = `if let authorizationFlowManagerDelegate = self.authorizationFlowManagerDelegate {
1912
if authorizationFlowManagerDelegate.resumeExternalUserAgentFlow(with: url) {
20-
return true
13+
return true
2114
}
22-
}
23-
${replaceText2}`
15+
}`;
16+
17+
export const applyExpo53AppDelegatePatch = (contents: string): string => {
18+
contents = contents.replace(
19+
/^(\s*(?:public\s+)?class\s+AppDelegate\s*:\s*ExpoAppDelegate)([^{]*)(\{)/m,
20+
(match, declaration, conformances, openingBrace) => {
21+
if (conformances.includes(APP_AUTH_PROTOCOL)) {
22+
return match;
23+
}
24+
25+
const trailingWhitespace = conformances.match(/\s*$/)?.[0] ?? '';
26+
const existingConformances = conformances.slice(
27+
0,
28+
conformances.length - trailingWhitespace.length
2429
);
2530

26-
const replaceText3 = 'var reactNativeFactory: RCTReactNativeFactory?';
31+
return `${declaration}${existingConformances}, ${APP_AUTH_PROTOCOL}${trailingWhitespace}${openingBrace}`;
32+
}
33+
);
34+
35+
if (!APP_AUTH_DELEGATE_PROPERTY_PATTERN.test(contents)) {
36+
const reactNativeFactoryPattern =
37+
/^(\s*)(?:public\s+)?var\s+reactNativeFactory\s*:\s*RCTReactNativeFactory\?\s*$/m;
38+
const factoryMatch = contents.match(reactNativeFactoryPattern);
39+
if (factoryMatch) {
40+
const indent = factoryMatch[1];
2741
contents = contents.replace(
28-
replaceText3,
29-
`${replaceText3}\n\n public weak var authorizationFlowManagerDelegate: RNAppAuthAuthorizationFlowManagerDelegate?`
42+
reactNativeFactoryPattern,
43+
match => `${match}\n\n${indent}${APP_AUTH_DELEGATE_PROPERTY}`
3044
);
3145
}
46+
}
3247

33-
config.modResults.contents = contents;
48+
if (!contents.includes('resumeExternalUserAgentFlow(with: url)')) {
49+
contents = contents.replace(
50+
/((?:public\s+)?override\s+func\s+application\s*\([\s\S]*?open\s+url\s*:\s*URL[\s\S]*?\)\s*->\s*Bool\s*\{)/m,
51+
match => `${match}\n ${APP_AUTH_RESUME_BLOCK}\n`
52+
);
53+
}
54+
55+
return contents;
56+
};
57+
58+
const withAppDelegateSwift: ConfigPlugin = rootConfig => {
59+
return withAppDelegate(rootConfig, config => {
60+
assertExpo53OrLater(config, config.modRequest.projectRoot);
61+
config.modResults.contents = applyExpo53AppDelegatePatch(config.modResults.contents);
3462
return config;
3563
});
3664
};
3765

38-
export const withAppAuthAppDelegate: ConfigPlugin = rootConfig => {
39-
if (isExpo53OrLater(rootConfig)) {
40-
return withAppDelegateSwift(rootConfig);
41-
}
42-
66+
export const withLegacyAppAuthAppDelegate: ConfigPlugin = rootConfig => {
4367
return withAppDelegate(rootConfig, config => {
4468
let { contents } = config.modResults;
4569

@@ -59,3 +83,11 @@ export const withAppAuthAppDelegate: ConfigPlugin = rootConfig => {
5983
return config;
6084
});
6185
};
86+
87+
export const withAppAuthAppDelegate: ConfigPlugin = rootConfig => {
88+
if (isExpo53OrLater(rootConfig)) {
89+
return withAppDelegateSwift(rootConfig);
90+
}
91+
92+
return withLegacyAppAuthAppDelegate(rootConfig);
93+
};

0 commit comments

Comments
 (0)