react-native-whip-whep
Version:
An implementation of WHIP and WHEP protocols functionalities for React Native.
394 lines (346 loc) • 12.8 kB
text/typescript
import {
ConfigPlugin,
withEntitlementsPlist,
withXcodeProject,
withInfoPlist,
withPodfileProperties,
} from '@expo/config-plugins';
import * as fs from 'promise-fs';
import * as path from 'path';
import { WhipWhepPluginOptions } from './types';
/**
* Gets the name of the Broadcast Upload Extension target.
* This can be customized via plugin options or defaults to 'WhipWhepScreenBroadcastExtension'.
*/
function getSbeTargetName(props: WhipWhepPluginOptions) {
return (
props?.ios?.broadcastExtensionTargetName ||
'WhipWhepScreenBroadcastExtension'
);
}
/**
* Generates the Podfile snippet that adds the broadcast extension target.
* This snippet adds the necessary WebRTC dependency to the extension.
*/
export function getSbePodfileSnippet(props: WhipWhepPluginOptions) {
const targetName = getSbeTargetName(props);
// The extension needs access to WebRTC for handling video frames via the MobileWhipWhepBroadcastClient
return `\ntarget '${targetName}' do\n pod 'MobileWhipWhepBroadcastClient'\nend`;
}
const TARGETED_DEVICE_FAMILY = `"1,2"`; // 1=iPhone, 2=iPad
const IPHONEOS_DEPLOYMENT_TARGET = '15.1'; // Minimum iOS version
const GROUP_IDENTIFIER_TEMPLATE_REGEX = /{{GROUP_IDENTIFIER}}/gm; // Template placeholder for app group
const BUNDLE_IDENTIFIER_TEMPLATE_REGEX = /{{BUNDLE_IDENTIFIER}}/gm; // Template placeholder for bundle ID
/**
* Helper function for updating template placeholders in extension files.
* Replaces template variables like {{GROUP_IDENTIFIER}} with actual values.
*
* @param iosPath - Path to the iOS project directory
* @param fileName - Name of the file to update
* @param regex - Regular expression to find the placeholder
* @param value - The actual value to replace the placeholder with
* @param props - Plugin configuration options
*/
async function updateFileWithRegex(
iosPath: string,
fileName: string,
regex: RegExp,
value: string,
props: WhipWhepPluginOptions,
) {
const targetName = getSbeTargetName(props);
const filePath = `${iosPath}/${targetName}/${fileName}`;
let file = await fs.readFile(filePath, { encoding: 'utf-8' });
file = file.replace(regex, value);
await fs.writeFile(filePath, file);
}
/**
* Updates the Podfile to include the broadcast extension target.
* This ensures the extension has access to necessary dependencies (WebRTC).
* The extension runs in a separate process and needs its own dependencies.
*
* @param iosPath - Path to the iOS project directory
* @param props - Plugin configuration options
*/
async function updatePodfile(iosPath: string, props: WhipWhepPluginOptions) {
const podfileSnippet = getSbePodfileSnippet(props);
let matches;
try {
const podfile = await fs.readFile(`${iosPath}/Podfile`, {
encoding: 'utf-8',
});
matches = podfile.match(podfileSnippet);
} catch (e) {
console.error('Error reading from Podfile: ', e);
}
if (matches) {
console.log(
`${getSbeTargetName(props)} target already added to Podfile. Skipping...`,
);
return;
}
try {
await fs.appendFile(`${iosPath}/Podfile`, podfileSnippet);
} catch (e) {
console.error('Error writing to Podfile: ', e);
}
}
/**
* Adds "App Group" permission to the main app.
*
* The app group identifier is typically in the format: group.{bundle.identifier}
*/
const withAppGroupPermissions: ConfigPlugin<WhipWhepPluginOptions> = (
config,
props,
) => {
const APP_GROUP_KEY = 'com.apple.security.application-groups';
const bundleIdentifier = config.ios?.bundleIdentifier || '';
const groupIdentifier =
props?.ios?.appGroupContainerId || `group.${bundleIdentifier}`;
const mainTarget = props?.ios?.mainTargetName || '';
// Add to the config object for Expo
config.ios ??= {};
config.ios.entitlements ??= {};
config.ios.entitlements[APP_GROUP_KEY] ??= [];
const entitlementsArray = config.ios.entitlements[APP_GROUP_KEY] as string[];
if (!entitlementsArray.includes(groupIdentifier)) {
entitlementsArray.push(groupIdentifier);
}
config = withEntitlementsPlist(config, (newConfig) => {
const modResultsArray =
(newConfig.modResults[APP_GROUP_KEY] as string[]) || [];
if (!modResultsArray.includes(groupIdentifier)) {
modResultsArray.push(groupIdentifier);
}
newConfig.modResults[APP_GROUP_KEY] = modResultsArray;
return newConfig;
});
config = withXcodeProject(config, (props) => {
const xcodeProject = props.modResults;
const targets = xcodeProject.getFirstTarget();
const project = xcodeProject.getFirstProject();
if (!targets || !project) {
return props;
}
const targetUuid = targets.uuid;
const projectUuid = project.uuid;
const projectObj =
xcodeProject.hash.project.objects.PBXProject[projectUuid];
projectObj.attributes ??= {};
projectObj.attributes.TargetAttributes ??= {};
projectObj.attributes.TargetAttributes[targetUuid] ??= {};
projectObj.attributes.TargetAttributes[targetUuid].SystemCapabilities ??=
{};
projectObj.attributes.TargetAttributes[targetUuid].SystemCapabilities[
'com.apple.ApplicationGroups.iOS'
] = {
enabled: 1,
};
const mainTargetName = mainTarget || props.modRequest.projectName;
const entitlementsFilePath = `${mainTargetName}/${mainTargetName}.entitlements`;
const configurations = xcodeProject.pbxXCBuildConfigurationSection();
Object.keys(configurations).forEach((key) => {
const config = configurations[key];
if (config.buildSettings?.PRODUCT_NAME?.includes(mainTargetName)) {
if (!config.buildSettings.CODE_SIGN_ENTITLEMENTS) {
config.buildSettings.CODE_SIGN_ENTITLEMENTS = entitlementsFilePath;
}
}
});
return props;
});
return config;
};
const withInfoPlistConstants: ConfigPlugin<WhipWhepPluginOptions> = (
config,
props,
) =>
withInfoPlist(config, (configuration) => {
const bundleIdentifier = configuration.ios?.bundleIdentifier || '';
const groupIdentifier =
props?.ios?.appGroupContainerId || `group.${bundleIdentifier}`;
configuration.modResults['AppGroupName'] = groupIdentifier;
configuration.modResults['ScreenShareExtensionBundleId'] =
`${bundleIdentifier}.${getSbeTargetName(props)}`;
return configuration;
});
const withWhipWhepSBE: ConfigPlugin<WhipWhepPluginOptions> = (config, options) =>
withXcodeProject(config, async (props) => {
const appName = props.modRequest.projectName || '';
const iosPath = props.modRequest.platformProjectRoot;
const bundleIdentifier = props.ios?.bundleIdentifier;
const groupIdentifier =
options?.ios?.appGroupContainerId || `group.${bundleIdentifier}`;
const xcodeProject = props.modResults;
const targetName = getSbeTargetName(options);
const pluginDir = require.resolve(
'react-native-whip-whep/package.json',
);
const extensionSourceDir = path.join(
pluginDir,
'../plugin/broadcastExtensionFiles/',
);
await updatePodfile(iosPath, options);
const projPath = `${iosPath}/${appName}.xcodeproj/project.pbxproj`;
const templateTargetName = 'WhipWhepScreenBroadcastExtension';
const extFiles = [
'WhipWhepBroadcastSampleHandler.swift',
`${templateTargetName}.entitlements`,
`Info.plist`,
];
const destFiles = [
'WhipWhepBroadcastSampleHandler.swift',
`${targetName}.entitlements`,
`Info.plist`,
];
await xcodeProject.parse(async function (err: Error) {
if (err) {
console.error(`Error parsing iOS project: ${JSON.stringify(err)}`);
return;
}
if (xcodeProject.pbxTargetByName(targetName)) {
console.log(`${targetName} already exists in project. Skipping...`);
return;
}
try {
await fs.mkdir(`${iosPath}/${targetName}`, { recursive: true });
for (let i = 0; i < extFiles.length; i++) {
const srcFile = `${extensionSourceDir}${extFiles[i]}`;
const destFile = `${iosPath}/${targetName}/${destFiles[i]}`;
await fs.copyFile(srcFile, destFile);
}
} catch (e) {
console.error('Error copying extension files: ', e);
}
await updateFileWithRegex(
iosPath,
`${targetName}.entitlements`,
GROUP_IDENTIFIER_TEMPLATE_REGEX,
groupIdentifier,
options,
);
await updateFileWithRegex(
iosPath,
'WhipWhepBroadcastSampleHandler.swift',
GROUP_IDENTIFIER_TEMPLATE_REGEX,
groupIdentifier,
options,
);
await updateFileWithRegex(
iosPath,
'WhipWhepBroadcastSampleHandler.swift',
BUNDLE_IDENTIFIER_TEMPLATE_REGEX,
bundleIdentifier || '',
options,
);
const extGroup = xcodeProject.addPbxGroup(
extFiles,
targetName,
targetName,
);
const groups = xcodeProject.hash.project.objects['PBXGroup'];
Object.keys(groups).forEach(function (key) {
if (groups[key].name === undefined) {
xcodeProject.addToPbxGroup(extGroup.uuid, key);
}
});
// WORKAROUND for xcodeProject.addTarget bug
// Xcode projects don't contain these if there is only one target
const projObjects = xcodeProject.hash.project.objects;
projObjects['PBXTargetDependency'] =
projObjects['PBXTargetDependency'] || {};
projObjects['PBXContainerItemProxy'] =
projObjects['PBXContainerItemProxy'] || {};
const sbeTarget = xcodeProject.addTarget(
targetName,
'app_extension',
targetName,
`${bundleIdentifier}.${targetName}`,
);
xcodeProject.addBuildPhase(
['WhipWhepBroadcastSampleHandler.swift'],
'PBXSourcesBuildPhase',
'Sources',
sbeTarget.uuid,
);
xcodeProject.addBuildPhase(
[],
'PBXResourcesBuildPhase',
'Resources',
sbeTarget.uuid,
);
xcodeProject.addBuildPhase(
[],
'PBXFrameworksBuildPhase',
'Frameworks',
sbeTarget.uuid,
);
xcodeProject.addFramework('ReplayKit.framework', {
target: sbeTarget.uuid,
});
const configurations = xcodeProject.pbxXCBuildConfigurationSection();
for (const key in configurations) {
if (
typeof configurations[key].buildSettings !== 'undefined' &&
configurations[key].buildSettings.PRODUCT_NAME === `"${targetName}"`
) {
const buildSettingsObj = configurations[key].buildSettings;
buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET =
options?.ios?.iphoneDeploymentTarget ?? IPHONEOS_DEPLOYMENT_TARGET;
buildSettingsObj.TARGETED_DEVICE_FAMILY = TARGETED_DEVICE_FAMILY;
buildSettingsObj.CODE_SIGN_ENTITLEMENTS = `${targetName}/${targetName}.entitlements`;
buildSettingsObj.CODE_SIGN_STYLE = 'Automatic';
buildSettingsObj.INFOPLIST_FILE = `${targetName}/Info.plist`;
buildSettingsObj.SWIFT_VERSION = '5.0';
buildSettingsObj.MARKETING_VERSION = '1.0.0';
buildSettingsObj.CURRENT_PROJECT_VERSION = '1';
buildSettingsObj.ENABLE_BITCODE = 'NO';
}
}
await fs.writeFile(projPath, xcodeProject.writeSync());
});
return props;
});
/**
* Adds Picture-in-Picture support by enabling audio background mode.
* This allows the app to continue playing audio when in PiP mode.
*/
const withWhipWhepPictureInPicture: ConfigPlugin<WhipWhepPluginOptions> = (
config,
props,
) =>
withInfoPlist(config, (configuration) => {
if (props?.ios?.supportsPictureInPicture) {
const backgroundModes = new Set(
configuration.modResults.UIBackgroundModes ?? [],
);
backgroundModes.add('audio');
configuration.modResults.UIBackgroundModes = Array.from(backgroundModes);
}
return configuration;
});
/**
* Main iOS plugin function.
* Applies all iOS-specific configurations conditionally based on options.
*
* This orchestrates:
* - Screen sharing setup (if enabled)
* - Deployment target configuration
* - Picture-in-Picture support
*/
const withWhipWhepIos: ConfigPlugin<WhipWhepPluginOptions> = (config, props) => {
if (props?.ios?.enableScreensharing) {
config = withAppGroupPermissions(config, props);
config = withInfoPlistConstants(config, props);
config = withWhipWhepSBE(config, props);
}
config = withPodfileProperties(config, (configuration) => {
configuration.modResults['ios.deploymentTarget'] =
props?.ios?.iphoneDeploymentTarget ?? IPHONEOS_DEPLOYMENT_TARGET;
return configuration;
});
config = withWhipWhepPictureInPicture(config, props);
return config;
};
export default withWhipWhepIos;