// 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 };