rebreak-monorepo/apps/rebreak-native/plugins/with-rebreak-protection-ios.js
chahinebrini 29bbf23405 feat(protection): iOS NEURLFilter-Spike + PIR-Server-Ops
NEURLFilter-Stack (iOS 26): Extension RebreakURLFilter -> URLFilterExtension
umbenannt, url-filter-provider-Entitlement, Bloom-Prefilter-Extension,
PIR-Client-Config (pirServerURL/pirAuthToken via Build-Env).
PIR-Server-Ops unter ops/pir-server/ (Dockerfile, build-and-deploy, Patches,
DTS-Report). backend/scripts/generate-pir-input.ts erzeugt die PIR-Datenbank.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 18:09:42 +02:00

215 lines
8.6 KiB
JavaScript

/* eslint-disable @typescript-eslint/no-var-requires */
/**
* Expo Config-Plugin — bindet das NEURLFilter-ExtensionKit-Target
* (`RebreakURLFilterExtension`) beim Prebuild ins iOS-Projekt ein.
*
* Ersetzt das frühere NEFilter-Plugin. Erkenntnis aus Apples `SimpleURLFilter`-
* Sample: das Target ist KEIN exotischer Produkttyp — es bleibt das klassische
* `app_extension`. Die ExtensionKit-Natur kommt aus:
* 1. der Info.plist (`EXAppExtensionAttributes` / `EXExtensionPointIdentifier`)
* 2. der Embed-Phase „Embed Foundation Extensions" mit `dstSubfolderSpec = 16`
* (das klassische „Embed App Extensions" wäre 13).
*
* Was es macht:
* 1) Haupt-App-Entitlements (url-filter-provider, family-controls, app-groups)
* 2) kopiert `modules/rebreak-protection/ios/RebreakURLFilterExtension/` → `ios/`
* 3) fügt das Xcode-Target `RebreakURLFilterExtension` hinzu
*
* 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 = 'RebreakURLFilterExtension';
const EXT_BUNDLE_SUFFIX = 'URLFilterExtension'; // → org.rebreak.app.URLFilterExtension
const DEVELOPMENT_TEAM = '84BQ7MTFYK';
const MODULE_DIR = path.join(
__dirname,
'..',
'modules',
'rebreak-protection',
'ios',
TARGET_NAME,
);
// Swift-Quellen (Apple-Bloom vendored + unser Control-Provider).
const SWIFT_SOURCES = [
'BloomFilter.swift',
'Murmur3Hash.swift',
'FNV1aHash.swift',
'RebreakURLFilterControlProvider.swift',
];
// Bundle-Resources.
const RESOURCES = ['bloom_filter.plist'];
// ─── 1) Haupt-App-Entitlements ──────────────────────────────────────────────
function withMainAppEntitlements(config) {
return withEntitlementsPlist(config, (cfg) => {
// NEURLFilter-Entitlement (ersetzt das alte content-filter-provider).
// Dev-Builds laufen ohne Apple-Freigabe; TestFlight/Store brauchen die
// genehmigte Capability (Developer-Portal → Capability Requests).
cfg.modResults['com.apple.developer.networking.networkextension'] = [
'url-filter-provider',
];
// Family Controls: Distribution-Entitlement — nur wenn das Env-Flag gesetzt
// ist (development-Builds bzw. nach Apple-Freigabe auch preview/production).
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 dest = path.join(cfg.modRequest.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)) {
fs.copyFileSync(path.join(MODULE_DIR, file), path.join(dest, file));
}
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 (Produkttyp app_extension — wie Apples SimpleURLFilter) ──
const target = proj.addTarget(TARGET_NAME, 'app_extension', TARGET_NAME, extBundleId);
// ── Build-Phasen ──
proj.addBuildPhase(SWIFT_SOURCES, 'PBXSourcesBuildPhase', 'Sources', target.uuid);
proj.addBuildPhase(RESOURCES, 'PBXResourcesBuildPhase', 'Resources', target.uuid);
proj.addBuildPhase(
['NetworkExtension.framework'],
'PBXFrameworksBuildPhase',
'Frameworks',
target.uuid,
);
// ── PBXGroup für die Extension-Dateien ──
const pbxGroup = proj.addPbxGroup(
[...SWIFT_SOURCES, ...RESOURCES, 'Info.plist', `${TARGET_NAME}.entitlements`],
TARGET_NAME,
TARGET_NAME,
);
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 ──
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"`;
// NEURLFilter ist iOS 26+. Die Extension lädt nur dort — höheres
// Deployment-Target als die Haupt-App (iOS 16+) ist korrekt.
buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '26.0';
buildSettingsObj.SWIFT_VERSION = '5.0';
buildSettingsObj.TARGETED_DEVICE_FAMILY = '"1,2"';
buildSettingsObj.CODE_SIGN_STYLE = 'Automatic';
// EAS managed credentials setzen DEVELOPMENT_TEAM nur auf der Main-App
// → Extension braucht expliziten Team-Wert.
buildSettingsObj.DEVELOPMENT_TEAM = DEVELOPMENT_TEAM;
}
});
// ── Embed-Phase auf ExtensionKit umstellen ──
// proj.addTarget() hat bereits eine „Copy Files"-PBXCopyFilesBuildPhase
// (dstSubfolderSpec 13 = PlugIns/) im Haupt-Target angelegt, die unsere
// .appex einbettet. iOS' Installer (MIPluginKitBundle) behandelt aber
// ALLES in PlugIns/ als klassische PluginKit-Extension und verlangt ein
// NSExtension-Dict im Info.plist — das ExtensionKit-Extensions NICHT haben
// ("AppexBundleMissingNSExtensionDict", MIInstallerError 39). ExtensionKit-
// Extensions MÜSSEN nach Extensions/. Also die bestehende Phase umbiegen:
// dstSubfolderSpec 16 + dstPath $(EXTENSIONS_FOLDER_PATH) — exakt wie
// Apples SimpleURLFilter.xcodeproj. KEINE zweite Phase anlegen (sonst wird
// die .appex doppelt eingebettet).
const mainTargetUuid = proj.getFirstTarget().uuid;
const copyFilesPhases = proj.hash.project.objects.PBXCopyFilesBuildPhase || {};
let embedFixed = false;
Object.keys(copyFilesPhases).forEach((key) => {
const phase = copyFilesPhases[key];
if (typeof phase !== 'object') return;
const embedsOurAppex = (phase.files || []).some(
(f) => typeof f?.comment === 'string' && f.comment.includes(`${TARGET_NAME}.appex`),
);
if (!embedsOurAppex) return;
phase.name = '"Embed Foundation Extensions"';
phase.dstSubfolderSpec = 16;
phase.dstPath = '"$(EXTENSIONS_FOLDER_PATH)"';
embedFixed = true;
});
if (!embedFixed) {
throw new Error(
'[with-rebreak-protection-ios] Embed-CopyFiles-Phase für die .appex nicht gefunden',
);
}
// ── Target-Dependency: Haupt-App baut die Extension vorher ──
proj.addTargetDependency(mainTargetUuid, [target.uuid]);
return cfg;
});
}
// ─── Composition ────────────────────────────────────────────────────────────
module.exports = function withRebreakProtectionIos(config) {
config = withMainAppEntitlements(config);
config = withCopyExtensionSources(config);
config = withExtensionTarget(config);
return config;
};