The extraction typechecked from day one and had never been built. It took five attempts, each failing further along, and every cause was the same shape: something the shared node_modules or the monorepo's layout supplied without anyone declaring it. 1. The config plugin had the monorepo's geometry written into it — ../../../packages/officer-net/ios/libs, correct from apps/<name>/ios and meaningless from a repo root. It reported a path belonging to neither repo. Now derived from the plugin's own location with path.relative, so it is right wherever the module sits. 2. That fix appeared to do nothing, because officer-net was a dependency and bun COPIES those. Prebuild ran a week-old copy while the source was edited — a clean edit and a stale build, the same shadow-copy trap that cost a night in the monorepo. Making it a workspace turns node_modules/officer-net into a symlink. 3. @babel/plugin-transform-react-jsx was never declared by anything. It was hoisted into the monorepo by some other package, so babel-preset-expo with jsxImportSource found it by luck. 4. Same for react-native-reanimated and react-native-worklets: nativewind depends on react-native-css-interop, which lists reanimated as a PEER, whose babel plugin requires react-native-worklets/plugin. Three levels of nobody-declared-it. 5. And the self-inflicted one. Adding for (2) silently flipped bun to its isolated linker — only direct dependencies at top level, everything else symlinked into node_modules/.bun, which Metro cannot resolve. It surfaced as 'Unable to resolve module react-native-css-interop/jsx-runtime', naming a package that was installed and in the lockfile and simply not where the resolver looks. bunfig.toml now pins both properties that are needed at once: workspaces for the symlink, hoisted for the layout. Result: OffScale.app, 82 MB, with OffTailTunnel.appex at 20 MB carrying 129 Tailcore symbols — the Go core is in the extension, which is the half that matters. Not yet run on a device. It builds; that is a different claim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
officer-net Expo config plugin
Wires the OS-level capabilities the VPN needs into the app at prebuild. Enabled from app.json:
plugins: [
["officer-net", {
appGroup: "group.pastilhas.officer.offscale",
neBundleId: "dev.officer.offscale.net",
enableNetworkExtension: true,
}],
]
Props (all optional; defaults shown):
| prop | default | what it controls |
|---|---|---|
appGroup |
group.pastilhas.officer.mobile |
iOS App Group shared by the app and the NE |
neBundleId |
dev.officer.mobile.net |
the Network Extension target's bundle id |
specialUseSubtype |
VPN mesh tunnel justification | Android FOREGROUND_SERVICE_SPECIAL_USE purpose |
enableNetworkExtension |
false |
add the iOS entitlement and synthesise the NE target |
enableNetworkExtension is off by default because the entitlement fails device code-signing until
the Apple provisioning below exists. With it off the app signs, installs and renders; the engine is
inert. The repo's own app.json turns it on.
What the plugin does
- iOS — adds
com.apple.developer.networking.networkextension(packet-tunnel-provider) to the main app, and writesOfficerNetExtensionBundleIdintoInfo.plist. That key is load-bearing, not a breadcrumb: the Swift module reads it to fillproviderBundleIdentifier, which is how iOS knows which extension to launch. A hardcoded value there means the start command is accepted, nothing matches, and the tunnel silently never starts. - iOS, under
enableNetworkExtension— synthesises the Network Extension Xcode target (withNetworkExtensionTarget.js): a new target compiling this package'sios/PacketTunnelProvider.swift, itsInfo.plist(NSExtensionPointIdentifier = com.apple.networkextension.packet-tunnel), its own entitlements,NetworkExtension.framework, the vendoredTailCore.xcframework, and the app's Embed App Extensions phase. Doing this by hand after everyexpo prebuild --cleanis what this replaces. - Android — adds
FOREGROUND_SERVICEandFOREGROUND_SERVICE_SPECIAL_USE, and registersOfficerVpnServicewithBIND_VPN_SERVICE,foregroundServiceType="specialUse"and theandroid.net.VpnServiceintent-filter.
Apple provisioning prerequisites (one-time, in the Apple Developer portal)
- A separate App ID for the extension (
neBundleId) with the Network Extensions capability. - The Network Extensions capability on the main app ID.
- Provisioning profiles for both, with those capabilities.
xcodebuild -allowProvisioningUpdates cannot create these while Xcode has no account signed in: it
falls back to the wildcard team profile and fails with "Provisioning profile 'iOS Team Provisioning
Profile: *' doesn't include the Network Extensions capability". Sign in under Xcode → Settings →
Accounts first. Android needs none of this.