Files
offscale-mobile/modules/officer-net/plugin/withNetworkExtensionTarget.js
pastilhasandClaude Opus 5 79fbfee5fb fix: build standalone — five things the monorepo was quietly providing
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>
2026-08-14 23:04:09 +01:00

178 lines
8.4 KiB
JavaScript

// 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 = () => `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key><string>$(PRODUCT_NAME)</string>
<key>CFBundleDisplayName</key><string>OffTail Tunnel</string>
<key>CFBundleExecutable</key><string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key><string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundlePackageType</key><string>XPC!</string>
<key>CFBundleShortVersionString</key><string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key><string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key><string>com.apple.networkextension.packet-tunnel</string>
<key>NSExtensionPrincipalClass</key><string>$(PRODUCT_MODULE_NAME).PacketTunnelProvider</string>
</dict>
</dict>
</plist>
`;
// 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 = () => `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.networking.networkextension</key>
<array><string>packet-tunnel-provider</string></array>
</dict>
</plist>
`;
// 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/<EXT_NAME>/ 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/<name>/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 };