@dittolive/ditto
Version:
Ditto is a cross-platform SDK that allows apps to sync with and even without internet connectivity.
226 lines (201 loc) • 7.72 kB
text/typescript
// Dynamic imports to avoid bundling Expo dependencies when not needed
function loadExpoDependencies() {
try {
const {
withInfoPlist,
withAndroidManifest,
withDangerousMod,
createRunOncePlugin,
ConfigPlugin,
} = require('@expo/config-plugins') as any;
const { withBuildProperties } = require('expo-build-properties') as any;
return {
withInfoPlist,
withAndroidManifest,
withDangerousMod,
createRunOncePlugin,
ConfigPlugin,
withBuildProperties,
};
} catch {
throw new Error(`
Ditto Expo plugin requires additional dependencies:
npm install @expo/config-plugins expo-build-properties
These are only needed when using Expo with Ditto.
`);
}
}
const BLE_USAGE = 'Uses Bluetooth to connect and sync with nearby devices.';
const LAN_USAGE = 'Uses WiFi to connect and sync with nearby devices.';
type DittoConfig = {
/**
* The iOS Bluetooth prompt's description. Defaults to "Uses Bluetooth to
* connect and sync with nearby devices."
*/
bluetoothUsageDescription?: string;
/**
* The iOS LAN prompt's description. Defaults to "Uses WiFi to connect and
* sync with nearby devices."
*/
localNetworkUsageDescription?: string;
};
const withDittoIOS: any = (expoConfig: any, props: DittoConfig) => {
const { withInfoPlist } = loadExpoDependencies();
return withInfoPlist(expoConfig, (config: any) => {
const infoPlist = config.modResults;
infoPlist.NSBluetoothAlwaysUsageDescription =
props.bluetoothUsageDescription ??
infoPlist.NSBluetoothAlwaysUsageDescription ??
BLE_USAGE;
infoPlist.NSLocalNetworkUsageDescription =
props.localNetworkUsageDescription ??
infoPlist.NSLocalNetworkUsageDescription ??
LAN_USAGE;
if (!Array.isArray(infoPlist.NSBonjourServices)) {
infoPlist.NSBonjourServices = [];
}
if (!infoPlist.NSBonjourServices.includes('_http-alt._tcp.')) {
infoPlist.NSBonjourServices.push('_http-alt._tcp.');
}
if (!Array.isArray(infoPlist.UIBackgroundModes)) {
infoPlist.UIBackgroundModes = [];
}
if (!infoPlist.UIBackgroundModes.includes('bluetooth-central')) {
infoPlist.UIBackgroundModes.push('bluetooth-central');
}
if (!infoPlist.UIBackgroundModes.includes('bluetooth-peripheral')) {
infoPlist.UIBackgroundModes.push('bluetooth-peripheral');
}
return config;
});
};
const withDittoAndroid: any = (expoConfig: any) => {
const { withBuildProperties, withAndroidManifest } = loadExpoDependencies();
const architectures = ['x86', 'x86_64', 'armeabi-v7a', 'arm64-v8a'];
const libraries = [
'libjsi.so',
'libdittoffi.so',
'libreact_nativemodule_core.so',
'libturbomodulejsijni.so',
'libreactnative.so',
];
expoConfig = withBuildProperties(expoConfig, {
android: {
packagingOptions: {
pickFirst: architectures.flatMap((arch: string) =>
libraries.map((lib) => `lib/${arch}/${lib}`)
),
},
},
});
expoConfig = withAndroidManifest(expoConfig, async (config: any) => {
const androidManifest = config.modResults;
const permissions = androidManifest.manifest['uses-permission'] || [];
const permissionsToAdd = [
{ name: 'android.permission.BLUETOOTH' },
{ name: 'android.permission.BLUETOOTH_ADMIN' },
{ name: 'android.permission.BLUETOOTH_ADVERTISE' },
{ name: 'android.permission.BLUETOOTH_CONNECT' },
{
name: 'android.permission.BLUETOOTH_SCAN',
attributes: { 'android:usesPermissionFlags': 'neverForLocation' },
},
{ name: 'android.permission.ACCESS_FINE_LOCATION' },
{ name: 'android.permission.ACCESS_COARSE_LOCATION' },
{ name: 'android.permission.INTERNET' },
{ name: 'android.permission.ACCESS_WIFI_STATE' },
{ name: 'android.permission.ACCESS_NETWORK_STATE' },
{ name: 'android.permission.CHANGE_NETWORK_STATE' },
{ name: 'android.permission.CHANGE_WIFI_MULTICAST_STATE' },
{ name: 'android.permission.CHANGE_WIFI_STATE' },
{
name: 'android.permission.NEARBY_WIFI_DEVICES',
attributes: { 'android:usesPermissionFlags': 'neverForLocation' },
},
];
permissionsToAdd.forEach((permission) => {
function isPermissionAlreadyRequested(
permissionName: string,
manifestPermissions: any[]
): boolean {
return manifestPermissions.some(
(e) => e.$['android:name'] === permissionName
);
}
if (!isPermissionAlreadyRequested(permission.name, permissions)) {
const permissionObject = {
$: {
'android:name': permission.name,
...permission.attributes,
},
};
permissions.push(permissionObject);
}
});
androidManifest.manifest['uses-permission'] = permissions;
return config;
});
return expoConfig;
};
// Workaround for fmt 11.x consteval incompatibility with Xcode 26.4 clang.
// Compile the fmt pod as C++17 so __cpp_consteval is not defined, which forces
// fmt to skip the consteval FMT_STRING code path entirely and fall back to
// runtime format-string validation. Scoped to the fmt target only — RCT-Folly,
// React-Core, and the rest of RN keep their C++20 settings.
// See: https://github.com/fmtlib/fmt/issues/4740 and SDKS-3242.
// Remove when React Native upgrades to a build that bundles fmt >= 11.1.
const FMT_CONSTEVAL_SNIPPET = `
# Ditto: fmt consteval workaround for Xcode 26.4 (SDKS-3242).
installer.pods_project.targets.each do |target|
if target.name == 'fmt'
target.build_configurations.each do |cfg|
cfg.build_settings['CLANG_CXX_LANGUAGE_STANDARD'] = 'c++17'
end
end
end
`;
// Idempotency marker. The comment text is stable — it comes from
// FMT_CONSTEVAL_SNIPPET and we own it — so a formatter reflowing whitespace
// around it won't cause a duplicate injection.
const FMT_CONSTEVAL_MARKER =
'# Ditto: fmt consteval workaround for Xcode 26.4 (SDKS-3242).';
// Match `react_native_post_install(...)` for both the single-line form
// (`react_native_post_install(installer)`) and the multi-line form with
// trailing arguments. Handles one level of balanced parens in the arg list,
// which is more than any real Podfile template requires.
const REACT_NATIVE_POST_INSTALL_RE =
/react_native_post_install\((?:[^()]|\([^()]*\))*\)\s*/;
const withDittoFmtConstevalFix: any = (expoConfig: any) => {
const { withDangerousMod } = loadExpoDependencies();
return withDangerousMod(expoConfig, [
'ios',
async (config: any) => {
const fs = require('node:fs');
const path = require('node:path');
const podfilePath = path.join(
config.modRequest.platformProjectRoot,
'Podfile'
);
if (!fs.existsSync(podfilePath)) return config;
let podfile: string = fs.readFileSync(podfilePath, 'utf8');
if (podfile.includes(FMT_CONSTEVAL_MARKER)) return config;
if (!REACT_NATIVE_POST_INSTALL_RE.test(podfile)) return config;
podfile = podfile.replace(
REACT_NATIVE_POST_INSTALL_RE,
(match: string) => match + FMT_CONSTEVAL_SNIPPET
);
fs.writeFileSync(podfilePath, podfile);
return config;
},
]);
};
const withDitto: any = (config: any, props: DittoConfig = {}) => {
config = withDittoIOS(config, props);
config = withDittoAndroid(config, props);
config = withDittoFmtConstevalFix(config);
return config;
};
export default function dittoExpoPlugin(...args: any[]) {
const { createRunOncePlugin } = loadExpoDependencies();
return createRunOncePlugin(withDitto, 'ditto_expo')(...args);
}