Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-expo-swift-appdelegate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-native-app-auth": patch
---

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.
10 changes: 2 additions & 8 deletions examples/expo-cng/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,6 @@
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.anonymous.expocng"
Expand All @@ -21,7 +15,6 @@
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"edgeToEdgeEnabled": true,
"package": "com.anonymous.expocng"
},
"web": {
Expand All @@ -35,7 +28,8 @@
"io.identityserver.demo:/oauthredirect"
]
}
]
],
"expo-status-bar"
]
}
}
15 changes: 8 additions & 7 deletions examples/expo-cng/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"name": "expo-cng",
"version": "1.0.0",
"packageManager": "yarn@1.22.22",
"main": "index.ts",
"scripts": {
"start": "expo start",
Expand All @@ -9,16 +10,16 @@
"web": "expo start --web"
},
"dependencies": {
"expo": "~53.0.20",
"expo-status-bar": "~2.2.3",
"react": "19.0.0",
"react-native": "0.79.5",
"expo": "~56.0.14",
"expo-status-bar": "~56.0.4",
"react": "19.2.3",
"react-native": "0.85.3",
"react-native-app-auth": "file:../../packages/react-native-app-auth"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@types/react": "~19.0.10",
"typescript": "~5.8.3"
"@babel/core": "^7.29.0",
"@types/react": "~19.2.14",
"typescript": "~6.0.3"
},
"private": true
}
3,046 changes: 1,131 additions & 1,915 deletions examples/expo-cng/yarn.lock

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { applyExpo53AppDelegatePatch } from '../ios/app-delegate';

const expo56AppDelegate = `internal import Expo
import React
import ReactAppDependencyProvider

@main
class AppDelegate: ExpoAppDelegate {
var window: UIWindow?

var reactNativeDelegate: ExpoReactNativeFactoryDelegate?
var reactNativeFactory: RCTReactNativeFactory?

public override func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options)
}
}`;

const publicAppDelegate = expo56AppDelegate.replace(
'class AppDelegate: ExpoAppDelegate',
'public class AppDelegate: ExpoAppDelegate'
);

describe('applyExpo53AppDelegatePatch', () => {
it('adds AppAuth conformance to Expo 56 Swift AppDelegate templates', () => {
const result = applyExpo53AppDelegatePatch(expo56AppDelegate);

expect(result).toContain('class AppDelegate: ExpoAppDelegate, RNAppAuthAuthorizationFlowManager {');
expect(result).toContain(
'public weak var authorizationFlowManagerDelegate: RNAppAuthAuthorizationFlowManagerDelegate?'
);
Comment on lines +32 to +35
expect(result).toContain('authorizationFlowManagerDelegate.resumeExternalUserAgentFlow(with: url)');
});

it('preserves older public Swift AppDelegate templates', () => {
const result = applyExpo53AppDelegatePatch(publicAppDelegate);

expect(result).toContain('public class AppDelegate: ExpoAppDelegate, RNAppAuthAuthorizationFlowManager {');
});

it('preserves existing protocol conformances', () => {
const result = applyExpo53AppDelegatePatch(
expo56AppDelegate.replace(
'class AppDelegate: ExpoAppDelegate',
'class AppDelegate: ExpoAppDelegate, UIApplicationDelegate'
)
);

expect(result).toContain(
'class AppDelegate: ExpoAppDelegate, UIApplicationDelegate, RNAppAuthAuthorizationFlowManager {'
);
});

it('preserves whitespace before the class opening brace', () => {
const result = applyExpo53AppDelegatePatch(
expo56AppDelegate.replace(
'class AppDelegate: ExpoAppDelegate {',
`class AppDelegate: ExpoAppDelegate,
UIApplicationDelegate
{`
)
);

expect(result).toContain(`class AppDelegate: ExpoAppDelegate,
UIApplicationDelegate, RNAppAuthAuthorizationFlowManager
{`);
});

it('does not duplicate an existing multiline AppAuth delegate property', () => {
const appDelegateWithMultilineProperty = expo56AppDelegate.replace(
' var reactNativeFactory: RCTReactNativeFactory?',
` var reactNativeFactory: RCTReactNativeFactory?

public weak var authorizationFlowManagerDelegate:
RNAppAuthAuthorizationFlowManagerDelegate?`
Comment on lines +76 to +79
);

const result = applyExpo53AppDelegatePatch(appDelegateWithMultilineProperty);

expect(result.match(/\bvar\s+authorizationFlowManagerDelegate\b/g)).toHaveLength(1);
});

it('is idempotent', () => {
const once = applyExpo53AppDelegatePatch(expo56AppDelegate);
const twice = applyExpo53AppDelegatePatch(once);

expect(twice).toBe(once);
});
});
11 changes: 11 additions & 0 deletions packages/react-native-app-auth/plugin/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { getRedirectUrlScheme } from '../index';

describe('getRedirectUrlScheme', () => {
it('extracts a scheme from single-slash AppAuth redirect URLs', () => {
expect(getRedirectUrlScheme('io.identityserver.demo:/oauthredirect')).toBe('io.identityserver.demo');
});

it('extracts a scheme from double-slash redirect URLs', () => {
expect(getRedirectUrlScheme('rnaa-demo://oauthredirect')).toBe('rnaa-demo');
});
});
64 changes: 58 additions & 6 deletions packages/react-native-app-auth/plugin/src/expo-version.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,63 @@
import * as fs from 'fs';
import * as path from 'path';

interface ExpoConfig {
sdkVersion?: string;
_internal?: {
[key: string]: any;
projectRoot?: string;
};
}

const EXPO_SDK_MAJOR_VERSION = 53;
export const MIN_EXPO_SDK_MAJOR_VERSION = 53;

const parseMajorVersion = (version?: string): number | null => {
if (!version) {
return null;
}

const match = version.match(/\d+/);
if (!match) {
return null;
}

return Number.parseInt(match[0], 10);
};

const readExpoPackageVersion = (projectRoot?: string): string | undefined => {
if (!projectRoot) {
return undefined;
}

const packageJsonPath = path.join(projectRoot, 'package.json');
if (!fs.existsSync(packageJsonPath)) {
return undefined;
}

const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
return packageJson.dependencies?.expo || packageJson.devDependencies?.expo;
};

export const getExpoSdkMajorVersion = (
config: ExpoConfig,
projectRoot = config._internal?.projectRoot
): number | null => {
return (
parseMajorVersion(config.sdkVersion) ??
parseMajorVersion(readExpoPackageVersion(projectRoot))
);
};

export const isExpo53OrLater = (config: ExpoConfig, projectRoot?: string): boolean => {
const major = getExpoSdkMajorVersion(config, projectRoot);
return major != null && major >= MIN_EXPO_SDK_MAJOR_VERSION;
};

export const isExpo53OrLater = (config: ExpoConfig): boolean => {
const expoSdkVersion = config.sdkVersion || '0.0.0';
const [major] = expoSdkVersion.split('.');
return Number.parseInt(major, 10) >= EXPO_SDK_MAJOR_VERSION;
};
export const assertExpo53OrLater = (config: ExpoConfig, projectRoot?: string): void => {
const major = getExpoSdkMajorVersion(config, projectRoot);
if (major != null && major < MIN_EXPO_SDK_MAJOR_VERSION) {
throw new Error(
`react-native-app-auth iOS Swift AppDelegate patch requires Expo SDK ${MIN_EXPO_SDK_MAJOR_VERSION} or later. Detected Expo SDK ${major}.`
);
}
};
Comment thread
Copilot marked this conversation as resolved.
12 changes: 9 additions & 3 deletions packages/react-native-app-auth/plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,20 @@ import { withAppAuthAppBuildGradle } from './android';

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

export const getRedirectUrlScheme = (redirectUrl?: string): string | undefined => {
return redirectUrl?.split(':')[0];
};

const withAppAuth: AppAuthConfigPlugin = (config, props) => {
const redirectUrlScheme = getRedirectUrlScheme(props?.redirectUrls?.[0]);

// Transform redirectUrls configuration to platform-specific format
const transformedProps: AppAuthProps = props?.redirectUrls ? {
ios: {
urlScheme: props.redirectUrls[0]?.split('://')[0], // Extract scheme from first URL
urlScheme: redirectUrlScheme,
},
android: {
appAuthRedirectScheme: props.redirectUrls[0]?.split('://')[0], // Extract scheme from first URL
appAuthRedirectScheme: redirectUrlScheme,
},
...props,
} : (props || {});
Expand All @@ -36,4 +42,4 @@ const withAppAuth: AppAuthConfigPlugin = (config, props) => {
]);
};

export default createRunOncePlugin(withAppAuth, packageJson.name, packageJson.version);
export default createRunOncePlugin(withAppAuth, packageJson.name, packageJson.version);
84 changes: 58 additions & 26 deletions packages/react-native-app-auth/plugin/src/ios/app-delegate.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,69 @@
import { withAppDelegate, ConfigPlugin } from '@expo/config-plugins';
import { isExpo53OrLater } from '../expo-version';
import { assertExpo53OrLater, isExpo53OrLater } from '../expo-version';

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

const withAppDelegateSwift: ConfigPlugin = rootConfig => {
return withAppDelegate(rootConfig, config => {
let { contents } = config.modResults;

if (!contents.includes('RNAppAuthAuthorizationFlowManager')) {
const replaceText = 'class AppDelegate: ExpoAppDelegate';
contents = contents.replace(replaceText, `${replaceText}, RNAppAuthAuthorizationFlowManager`);

const replaceText2 =
'return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options)';
contents = contents.replace(
replaceText2,
`if let authorizationFlowManagerDelegate = self.authorizationFlowManagerDelegate {
const APP_AUTH_PROTOCOL = 'RNAppAuthAuthorizationFlowManager';
const APP_AUTH_DELEGATE_PROPERTY =
'public weak var authorizationFlowManagerDelegate: RNAppAuthAuthorizationFlowManagerDelegate?';
const APP_AUTH_DELEGATE_PROPERTY_PATTERN =
/\bvar\s+authorizationFlowManagerDelegate\s*:\s*RNAppAuthAuthorizationFlowManagerDelegate\??/;
const APP_AUTH_RESUME_BLOCK = `if let authorizationFlowManagerDelegate = self.authorizationFlowManagerDelegate {
if authorizationFlowManagerDelegate.resumeExternalUserAgentFlow(with: url) {
return true
return true
}
}
${replaceText2}`
}`;

export const applyExpo53AppDelegatePatch = (contents: string): string => {
contents = contents.replace(
/^(\s*(?:public\s+)?class\s+AppDelegate\s*:\s*ExpoAppDelegate)([^{]*)(\{)/m,
(match, declaration, conformances, openingBrace) => {
if (conformances.includes(APP_AUTH_PROTOCOL)) {
return match;
}

const trailingWhitespace = conformances.match(/\s*$/)?.[0] ?? '';
const existingConformances = conformances.slice(
0,
conformances.length - trailingWhitespace.length
);

const replaceText3 = 'var reactNativeFactory: RCTReactNativeFactory?';
return `${declaration}${existingConformances}, ${APP_AUTH_PROTOCOL}${trailingWhitespace}${openingBrace}`;
}
Comment on lines +18 to 32
);

if (!APP_AUTH_DELEGATE_PROPERTY_PATTERN.test(contents)) {
const reactNativeFactoryPattern =
/^(\s*)(?:public\s+)?var\s+reactNativeFactory\s*:\s*RCTReactNativeFactory\?\s*$/m;
const factoryMatch = contents.match(reactNativeFactoryPattern);
if (factoryMatch) {
const indent = factoryMatch[1];
contents = contents.replace(
replaceText3,
`${replaceText3}\n\n public weak var authorizationFlowManagerDelegate: RNAppAuthAuthorizationFlowManagerDelegate?`
reactNativeFactoryPattern,
match => `${match}\n\n${indent}${APP_AUTH_DELEGATE_PROPERTY}`
);
Comment on lines +35 to 44
}
}

config.modResults.contents = contents;
if (!contents.includes('resumeExternalUserAgentFlow(with: url)')) {
contents = contents.replace(
/((?:public\s+)?override\s+func\s+application\s*\([\s\S]*?open\s+url\s*:\s*URL[\s\S]*?\)\s*->\s*Bool\s*\{)/m,
match => `${match}\n ${APP_AUTH_RESUME_BLOCK}\n`
);
}

return contents;
};

const withAppDelegateSwift: ConfigPlugin = rootConfig => {
return withAppDelegate(rootConfig, config => {
assertExpo53OrLater(config, config.modRequest.projectRoot);
config.modResults.contents = applyExpo53AppDelegatePatch(config.modResults.contents);
return config;
});
};

export const withAppAuthAppDelegate: ConfigPlugin = rootConfig => {
if (isExpo53OrLater(rootConfig)) {
return withAppDelegateSwift(rootConfig);
}

export const withLegacyAppAuthAppDelegate: ConfigPlugin = rootConfig => {
return withAppDelegate(rootConfig, config => {
let { contents } = config.modResults;

Expand All @@ -59,3 +83,11 @@ export const withAppAuthAppDelegate: ConfigPlugin = rootConfig => {
return config;
});
};

export const withAppAuthAppDelegate: ConfigPlugin = rootConfig => {
if (isExpo53OrLater(rootConfig)) {
return withAppDelegateSwift(rootConfig);
}

return withLegacyAppAuthAppDelegate(rootConfig);
};
Loading