// Expo config plugin for officer-net. Wires the OS-level capabilities the VPN engine needs into the // consuming app at prebuild time. Native code lives in the package; this plugin touches manifests, // entitlements and Info.plist, plus (under enableNetworkExtension) the iOS Network Extension target. // // Usage in app.json: // plugins: [ // ["officer-net", { appGroup: "group.pastilhas.officer.offscale", // neBundleId: "dev.officer.offscale.net", // enableNetworkExtension: true }] // ] // // iOS is the hard part: a packet tunnel requires a SECOND native target (the Network Extension) that // embeds PacketTunnelProvider.swift. withNetworkExtensionTarget.js synthesises it — see // plugin/README.md for what that needs from the Apple Developer portal first. const { withEntitlementsPlist, withInfoPlist, withAndroidManifest, AndroidConfig, createRunOncePlugin, } = require('@expo/config-plugins'); const { withNetworkExtensionTarget } = require('./withNetworkExtensionTarget'); const DEFAULTS = { // App Group IDs are globally unique across ALL Apple accounts, so this uses your own domain // (group.pastilhas.*) to guarantee it's free + yours. appGroup: 'group.pastilhas.officer.mobile', neBundleId: 'dev.officer.mobile.net', // Android FOREGROUND_SERVICE_SPECIAL_USE (API 34+) needs a stated purpose in the manifest. specialUseSubtype: 'VPN mesh tunnel (Tailscale/Headscale) for OffTail', // The iOS Network Extension entitlement + App Group require Apple provisioning (a registered App // Group, an NE App ID, the Network Extensions capability) that must exist BEFORE the app can sign. // Until that's set up, adding the entitlement fails device code-signing — so it's OFF by default. // Flip to true once provisioning is in place (see ../README.md). With it off the app still signs and // installs, the VPN screen renders, and the engine is inert. enableNetworkExtension: false, }; // ── iOS: main-app entitlements ────────────────────────────────────────────── // The main app needs the Network Extensions capability (packet-tunnel-provider) to create/drive the // NETunnelProviderManager. NO App Group: config reaches the NE via the manager's providerConfiguration // and status comes back via handleAppMessage — so there's no App-Group entitlement to provision (which // -allowProvisioningUpdates won't auto-assign anyway, especially on Xcode-managed App IDs). function withIosEntitlements(config) { return withEntitlementsPlist(config, (cfg) => { cfg.modResults['com.apple.developer.networking.networkextension'] = ['packet-tunnel-provider']; return cfg; }); } // Record the NE bundle id in the main app's Info.plist. This is LOAD-BEARING, not a breadcrumb: the // Swift module reads it to fill providerBundleIdentifier, which is how iOS knows which extension to // launch. It used to be described as "purely informational" while the module used a hardcoded // dev.officer.mobile.net — so every host but Officer pointed at an extension it does not ship, and // the tunnel silently never started. Keep this in step with each app's `neBundleId` prop. function withIosInfo(config, props) { return withInfoPlist(config, (cfg) => { cfg.modResults.OfficerNetExtensionBundleId = props.neBundleId; return cfg; }); // TODO: create the Network Extension target here (or as a separate withXcodeProject mod): // - add a new PBXNativeTarget of productType com.apple.product-type.app-extension // - embed PacketTunnelProvider.swift + NetworkExtension.framework // - set its bundle id = props.neBundleId, its own entitlements (networkextension + // the same App Group), and an Info.plist with NSExtension → // NSExtensionPointIdentifier = com.apple.networkextension.packet-tunnel // NSExtensionPrincipalClass = $(PRODUCT_MODULE_NAME).PacketTunnelProvider // - add it to the app's "Embed App Extensions" build phase // This is intentionally NOT automated yet — see plugin/README.md for the manual Xcode steps. } // ── Android: manifest ─────────────────────────────────────────────────────── // Adds the foreground-service permissions and registers OfficerVpnService with the BIND_VPN_SERVICE // permission + the android.net.VpnService intent-filter (what makes it a system VPN provider). function withAndroidVpn(config, props) { config = AndroidConfig.Permissions.withPermissions(config, [ 'android.permission.FOREGROUND_SERVICE', 'android.permission.FOREGROUND_SERVICE_SPECIAL_USE', // Reconnect after a reboot if the user had the tunnel on. See BootReceiver. 'android.permission.RECEIVE_BOOT_COMPLETED', ]); return withAndroidManifest(config, (cfg) => { const app = AndroidConfig.Manifest.getMainApplicationOrThrow(cfg.modResults); app.service = app.service || []; const name = 'expo.modules.officernet.OfficerVpnService'; if (!app.service.some((s) => s.$?.['android:name'] === name)) { app.service.push({ $: { 'android:name': name, 'android:permission': 'android.permission.BIND_VPN_SERVICE', 'android:foregroundServiceType': 'specialUse', 'android:exported': 'false', }, 'intent-filter': [{ action: [{ $: { 'android:name': 'android.net.VpnService' } }] }], // API 34+ requires declaring the special-use subtype/justification. property: [ { $: { 'android:name': 'android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE', 'android:value': props.specialUseSubtype, }, }, ], }); } // Boot receiver: brings the tunnel back after a restart, but only when the user left it on. // exported=true is required — the sender is the system, not us. app.receiver = app.receiver || []; const receiver = 'expo.modules.officernet.BootReceiver'; if (!app.receiver.some((r) => r.$?.['android:name'] === receiver)) { app.receiver.push({ $: { 'android:name': receiver, 'android:exported': 'true', 'android:enabled': 'true' }, 'intent-filter': [ { action: [{ $: { 'android:name': 'android.intent.action.BOOT_COMPLETED' } }], }, ], }); } return cfg; }); } const withOfficerNet = (config, userProps = {}) => { const props = { ...DEFAULTS, ...userProps }; // The NE entitlement + App Group + the NE target only go on once Apple provisioning exists — otherwise // device signing fails. Gated so the inert-shell builds stay signable. if (props.enableNetworkExtension) { config = withIosEntitlements(config, props); config = withNetworkExtensionTarget(config, props); } config = withIosInfo(config, props); config = withAndroidVpn(config, props); return config; }; const pkg = require('../package.json'); module.exports = createRunOncePlugin(withOfficerNet, pkg.name, pkg.version);