rebreak-monorepo/apps/rebreak-native/plugins/with-rebreak-protection-ios.js
chahinebrini 448d64dbd5 fix(ios): re-enable family-controls entitlement for development builds
It was commented out wholesale in 398b7b9 so the App-Store/TestFlight provisioning
profile would build (Apple hasn't granted the *Distribution* Family Controls
entitlement yet). But that also killed it for the dev-client, so denyAppRemoval /
ManagedSettings throws "NSCocoaErrorDomain:4099 — can't talk to the helper app"
when you flip the Blocker-page App-Lock.

Gate it on REBREAK_ENABLE_FAMILY_CONTROLS, set to "1" in eas.json's development
profile (internal distribution → Development entitlement, which we do have). The
preview/production profiles stay without it until Apple approves the Distribution
entitlement — then add the flag there too + bump buildNumber.

NOTE: the next `eas build -p ios --profile development` will re-provision the main
app profile to include the entitlement; if Apple turns out NOT to have granted the
*Development* one either, that build will fail the same way the TestFlight one did.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:30:45 +02:00

206 lines
8.0 KiB
JavaScript

/* eslint-disable @typescript-eslint/no-var-requires */
/**
* Expo Config-Plugin — wires the NEFilter Extension target into the iOS
* project at prebuild time.
*
* Was es macht:
* 1) Setzt die Entitlements der Haupt-App (family-controls, network-
* extension, app-groups).
* 2) Kopiert `modules/rebreak-protection/ios/RebreakURLFilter/` nach
* `ios/RebreakURLFilter/` (idempotent).
* 3) Fügt einen neuen Xcode-Target `RebreakURLFilter` (Bundle-ID
* `org.rebreak.app.RebreakURLFilter`) zum Projekt hinzu, mit:
* - Source-File: FilterControlProvider.swift
* - NetworkExtension.framework
* - Embed-App-Extensions Build-Phase im Haupt-Target
* - Entitlements via `RebreakURLFilter.entitlements`
*
* Wird aus app.config.ts via `plugins: ['./plugins/with-rebreak-protection-ios']`
* registriert. Idempotent — kann beliebig oft via `expo prebuild` laufen.
*/
const fs = require('fs');
const path = require('path');
const {
withEntitlementsPlist,
withDangerousMod,
withXcodeProject,
} = require('@expo/config-plugins');
const APP_GROUP = 'group.org.rebreak.app';
const TARGET_NAME = 'RebreakURLFilter';
const EXT_BUNDLE_SUFFIX = 'RebreakURLFilter';
const MODULE_DIR = path.join(
__dirname,
'..',
'modules',
'rebreak-protection',
'ios',
TARGET_NAME,
);
// ─── 1) Haupt-App Entitlements ──────────────────────────────────────────────
function withMainAppEntitlements(config) {
return withEntitlementsPlist(config, (cfg) => {
cfg.modResults['com.apple.developer.networking.networkextension'] = [
'content-filter-provider',
];
// Family Controls: das DISTRIBUTION-Entitlement liegt noch bei Apple zur Freigabe
// → AppStore/TestFlight-Builds (Profile preview/production) dürfen es NICHT claimen,
// sonst kippt EAS' Provisioning ("doesn't support the Family Controls capability").
// Für development-Builds (Dev-Entitlement, internal distribution) schalten wir's per
// Env-Flag wieder ein (eas.json → development.env.REBREAK_ENABLE_FAMILY_CONTROLS="1"),
// damit denyAppRemoval / ManagedSettings im Dev-Client testbar ist. Sobald Apple das
// Distribution-Entitlement freigibt: Flag auch in preview/production env setzen + buildNumber bump.
if (process.env.REBREAK_ENABLE_FAMILY_CONTROLS === '1') {
cfg.modResults['com.apple.developer.family-controls'] = true;
}
const groups = cfg.modResults['com.apple.security.application-groups'] || [];
if (!groups.includes(APP_GROUP)) {
cfg.modResults['com.apple.security.application-groups'] = [...groups, APP_GROUP];
}
return cfg;
});
}
// ─── 2) Extension-Sources ins ios/-Verzeichnis kopieren ─────────────────────
function withCopyExtensionSources(config) {
return withDangerousMod(config, [
'ios',
async (cfg) => {
const platformProjectRoot = cfg.modRequest.platformProjectRoot;
const dest = path.join(platformProjectRoot, TARGET_NAME);
if (!fs.existsSync(MODULE_DIR)) {
throw new Error(
`[with-rebreak-protection-ios] Extension source dir missing: ${MODULE_DIR}`,
);
}
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
for (const file of fs.readdirSync(MODULE_DIR)) {
const srcFile = path.join(MODULE_DIR, file);
const destFile = path.join(dest, file);
fs.copyFileSync(srcFile, destFile);
}
return cfg;
},
]);
}
// ─── 3) Xcode-Target hinzufügen ──────────────────────────────────────────────
function withExtensionTarget(config) {
return withXcodeProject(config, async (cfg) => {
const proj = cfg.modResults;
// Idempotenz: skip wenn Target schon angelegt
if (proj.pbxTargetByName(TARGET_NAME)) {
return cfg;
}
const mainBundleId = cfg.ios?.bundleIdentifier;
if (!mainBundleId) {
throw new Error('[with-rebreak-protection-ios] ios.bundleIdentifier fehlt in app.config');
}
const extBundleId = `${mainBundleId}.${EXT_BUNDLE_SUFFIX}`;
// ── Target anlegen (Type: app_extension) ──
const target = proj.addTarget(TARGET_NAME, 'app_extension', TARGET_NAME, extBundleId);
// ── Build-Phasen: Sources + Frameworks + Resources ──
proj.addBuildPhase(
['FilterControlProvider.swift'],
'PBXSourcesBuildPhase',
'Sources',
target.uuid,
);
proj.addBuildPhase(
['NetworkExtension.framework'],
'PBXFrameworksBuildPhase',
'Frameworks',
target.uuid,
);
// Info.plist gehört NICHT als Resource — wird via INFOPLIST_FILE referenziert.
// ── PBXGroup für die Sources ──
const pbxGroup = proj.addPbxGroup(
['FilterControlProvider.swift', 'Info.plist', 'RebreakURLFilter.entitlements'],
TARGET_NAME,
TARGET_NAME,
);
// Group ans CustomTemplate-Group hängen damit sie im Project Navigator erscheint
const groups = proj.hash.project.objects.PBXGroup;
Object.keys(groups).forEach((key) => {
if (
groups[key].name === 'CustomTemplate' ||
(groups[key].name === undefined && groups[key].path === undefined)
) {
proj.addToPbxGroup(pbxGroup.uuid, key);
}
});
// ── Build-Settings auf der Target-Configuration anpassen ──
const configurations = proj.pbxXCBuildConfigurationSection();
Object.keys(configurations)
.filter((k) => typeof configurations[k] === 'object')
.forEach((k) => {
const buildSettingsObj = configurations[k].buildSettings;
if (
buildSettingsObj &&
buildSettingsObj.PRODUCT_NAME &&
buildSettingsObj.PRODUCT_NAME.replace(/"/g, '') === TARGET_NAME
) {
buildSettingsObj.INFOPLIST_FILE = `"${TARGET_NAME}/Info.plist"`;
buildSettingsObj.CODE_SIGN_ENTITLEMENTS = `"${TARGET_NAME}/${TARGET_NAME}.entitlements"`;
buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '15.1';
buildSettingsObj.SWIFT_VERSION = '5.9';
buildSettingsObj.TARGETED_DEVICE_FAMILY = '"1,2"';
buildSettingsObj.CODE_SIGN_STYLE = 'Automatic';
// EAS managed credentials setzen DEVELOPMENT_TEAM nur auf der Main-App.
// Die Extension ist ein eigenes Target → muss expliziten Team-Wert haben,
// sonst: "Signing for 'RebreakURLFilter' requires a development team".
buildSettingsObj.DEVELOPMENT_TEAM = '84BQ7MTFYK';
}
});
// ── Embed App Extensions Build-Phase im Haupt-Target ──
// Suche nach existierender CopyFilesBuildPhase mit Comment "Embed App Extensions"
const mainTargetUuid = proj.getFirstTarget().uuid;
const buildPhases = proj.hash.project.objects.PBXNativeTarget[mainTargetUuid].buildPhases;
const copyFilesPhases = proj.hash.project.objects.PBXCopyFilesBuildPhase || {};
const hasEmbedPhase = Object.keys(copyFilesPhases).some((key) => {
const phase = copyFilesPhases[key];
return (
typeof phase === 'object' &&
phase.dstSubfolderSpec === 13 && // 13 = PluginsAndFrameworks (App Extensions)
buildPhases.some((bp) => bp.value === key)
);
});
if (!hasEmbedPhase) {
proj.addBuildPhase(
[`${TARGET_NAME}.appex`],
'PBXCopyFilesBuildPhase',
'Embed App Extensions',
mainTargetUuid,
'app_extension', // dstSubfolderSpec=13
);
}
// ── Target-Dependency: Haupt-App muss Extension vor sich bauen ──
proj.addTargetDependency(mainTargetUuid, [target.uuid]);
return cfg;
});
}
// ─── Composition ────────────────────────────────────────────────────────────
module.exports = function withRebreakProtectionIos(config) {
config = withMainAppEntitlements(config);
config = withCopyExtensionSources(config);
config = withExtensionTarget(config);
return config;
};