// Adds the iOS Network Extension (Packet Tunnel Provider) target to the app's Xcode project at prebuild
// time, so it survives `expo prebuild` (a hand-added target would be wiped). Mirrors the proven pattern
// expo-share-intent uses to add its share-extension target (addPbxGroup → addTarget "app_extension" →
// addBuildPhase → build settings), plus the two things a VPN NE needs that a share extension doesn't:
// the packet-tunnel Info.plist + entitlements, and linking the vendored TailCore.xcframework (the
// gomobile Tailscale core) into the NE target — the core runs in the NE process.
//
// Only runs when props.enableNetworkExtension is true (needs Apple provisioning first; see README).
const { withXcodeProject, withDangerousMod } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
const EXT_NAME = 'OffTailTunnel'; // Xcode target + group name (bundle id is set separately)
// The NE's Info.plist: declares it a packet-tunnel provider whose principal class is our PacketTunnelProvider.
const infoPlist = () => `
CFBundleName$(PRODUCT_NAME)
CFBundleDisplayNameOffTail Tunnel
CFBundleExecutable$(EXECUTABLE_NAME)
CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER)
CFBundleInfoDictionaryVersion6.0
CFBundlePackageTypeXPC!
CFBundleShortVersionString$(MARKETING_VERSION)
CFBundleVersion$(CURRENT_PROJECT_VERSION)
NSExtension
NSExtensionPointIdentifiercom.apple.networkextension.packet-tunnel
NSExtensionPrincipalClass$(PRODUCT_MODULE_NAME).PacketTunnelProvider
`;
// The NE's entitlements: just the packet-tunnel capability. No App Group — the app↔NE channel is
// providerConfiguration (config in) + handleAppMessage (status out), so nothing to provision.
const entitlements = () => `
com.apple.developer.networking.networkextension
packet-tunnel-provider
`;
// Pull DEVELOPMENT_TEAM from the main-app target (same helper expo-share-intent uses).
function mainAppDevTeam(pbx) {
const configs = pbx.pbxXCBuildConfigurationSection();
for (const key in configs) {
const bs = configs[key].buildSettings;
if (!bs || !bs.PRODUCT_NAME) continue;
const name = bs.PRODUCT_NAME.replace(/"/g, '');
if (name.includes('Extension') || name.includes('Widget') || name.includes('Tunnel')) continue;
if (bs.DEVELOPMENT_TEAM) return bs.DEVELOPMENT_TEAM.replace(/"/g, '');
}
return null;
}
// Stage 1 (dangerous mod): write the NE's own files (provider source, Info.plist, entitlements) into
// ios// before the Xcode-project mod references them.
function withNeFiles(config, props) {
return withDangerousMod(config, [
'ios',
(cfg) => {
const iosRoot = cfg.modRequest.platformProjectRoot;
const extDir = path.join(iosRoot, EXT_NAME);
fs.mkdirSync(extDir, { recursive: true });
// Copy our authoritative provider source out of the package into the target folder.
const src = path.join(__dirname, '..', 'ios', 'PacketTunnelProvider.swift');
fs.copyFileSync(src, path.join(extDir, 'PacketTunnelProvider.swift'));
fs.writeFileSync(path.join(extDir, `${EXT_NAME}-Info.plist`), infoPlist());
fs.writeFileSync(path.join(extDir, `${EXT_NAME}.entitlements`), entitlements());
return cfg;
},
]);
}
// Stage 2 (Xcode project): create the target, its group + build phases, link TailCore, set build settings.
function withNeTarget(config, props) {
return withXcodeProject(config, (cfg) => {
const proj = cfg.modResults;
if (proj.pbxTargetByName(EXT_NAME)) {
console.log(`[officer-net] ${EXT_NAME} target already exists — skipping.`);
return cfg;
}
const sourceFiles = ['PacketTunnelProvider.swift'];
const configFiles = [`${EXT_NAME}-Info.plist`, `${EXT_NAME}.entitlements`];
// Group holding the NE files, hung off the project root group.
const group = proj.addPbxGroup([...sourceFiles, ...configFiles], EXT_NAME, EXT_NAME);
const groups = proj.hash.project.objects.PBXGroup;
Object.keys(groups).forEach((key) => {
if (typeof groups[key] === 'object' && groups[key].name === undefined && groups[key].path === undefined) {
proj.addToPbxGroup(group.uuid, key);
}
});
// addTarget bug workaround (single-target projects lack these sections).
const objects = proj.hash.project.objects;
objects.PBXTargetDependency = objects.PBXTargetDependency || {};
objects.PBXContainerItemProxy = objects.PBXContainerItemProxy || {};
const target = proj.addTarget(EXT_NAME, 'app_extension', EXT_NAME);
proj.addBuildPhase(sourceFiles, 'PBXSourcesBuildPhase', 'Sources', target.uuid);
proj.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', target.uuid);
proj.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', target.uuid);
/*
Link the vendored gomobile Tailscale core (TailCore.xcframework) into the NE.
The `xcode` lib has no .xcframework extension mapping, so lastKnownFileType MUST be forced to
'wrapper.xcframework' — left as 'unknown', Xcode never resolves the per-slice module and the build
fails with "no such module 'TailCore'".
The path is DERIVED, not written down. It used to read `../../../packages/officer-net/ios/libs`,
which encodes one repository's shape — correct from `apps//ios` in the monorepo, and wrong
everywhere else. Moving this module into a repo of its own produced exactly that: the Go core built
cleanly to the right place, then xcodebuild looked for it three levels up from a directory that no
longer had three levels, and reported a path belonging to neither repo.
Computing it from this file's own location cannot drift: wherever the module sits relative to the
generated ios/ directory, `path.relative` says so.
*/
const libsDir = path.join(__dirname, '..', 'ios', 'libs');
const iosDir = cfg.modRequest.platformProjectRoot;
const relLibs = path.relative(iosDir, libsDir);
proj.addFramework(path.join(relLibs, 'TailCore.xcframework'), {
target: target.uuid,
customFramework: true,
lastKnownFileType: 'wrapper.xcframework',
sourceTree: 'SOURCE_ROOT',
embed: false,
link: true,
sign: false,
});
const devTeam = mainAppDevTeam(proj);
// Same derivation for the search path — the two must agree, and hardcoding either is how they stop.
const fwSearch = `"$(PROJECT_DIR)/${relLibs}"`;
const configs = proj.pbxXCBuildConfigurationSection();
for (const key in configs) {
const bs = configs[key].buildSettings;
if (!bs || bs.PRODUCT_NAME !== `"${EXT_NAME}"`) continue;
bs.INFOPLIST_FILE = `"${EXT_NAME}/${EXT_NAME}-Info.plist"`;
bs.CODE_SIGN_ENTITLEMENTS = `"${EXT_NAME}/${EXT_NAME}.entitlements"`;
bs.CODE_SIGN_STYLE = 'Automatic';
bs.PRODUCT_BUNDLE_IDENTIFIER = `"${props.neBundleId}"`;
bs.GENERATE_INFOPLIST_FILE = 'NO';
bs.CLANG_ENABLE_MODULES = 'YES';
bs.SWIFT_VERSION = '5.0';
bs.SWIFT_EMIT_LOC_STRINGS = 'YES';
bs.TARGETED_DEVICE_FAMILY = '"1,2"';
bs.IPHONEOS_DEPLOYMENT_TARGET = '16.4';
bs.ENABLE_BITCODE = 'NO';
bs.MARKETING_VERSION = `"${config.version || '1.0.0'}"`;
bs.CURRENT_PROJECT_VERSION = `"${config.ios?.buildNumber || '1'}"`;
bs.FRAMEWORK_SEARCH_PATHS = ['"$(inherited)"', fwSearch];
if (devTeam) bs.DEVELOPMENT_TEAM = devTeam;
}
if (devTeam) {
proj.addTargetAttribute('DevelopmentTeam', devTeam);
proj.addTargetAttribute('DevelopmentTeam', devTeam, proj.pbxTargetByName(EXT_NAME));
}
console.log(`[officer-net] Added Network Extension target ${EXT_NAME} (${props.neBundleId}).`);
return cfg;
});
}
module.exports = { withNetworkExtensionTarget: (config, props) => withNeTarget(withNeFiles(config, props), props), EXT_NAME };