// Config plugin: push notifications on iOS. // // - The app's entitlements: aps-environment, and a keychain group shared with // the notification service extension (the app's own group stays first, so // items saved before keep their group). // - A Notification Service Extension target ("NotificationService", // org.bulwarkmail.mobile.NotificationService). The relay's push carries // only ids; the extension fetches sender and subject over JMAP on the device // before iOS shows the notification. Its sources live next to this file and // are copied into ios/ at prebuild, like everything else in ios/. // - Push renewal (PushRenewal.swift): Stalwart drops a subscription seven days // after its last refresh. The extension extends the pushed account's, and // the app (PushRenewalTask.swift, compiled into the app target) every // account's on foreground and from a background app refresh task, which // AppDelegate registers at launch and Info.plist permits. // // Options (app.config.js): // apsEnvironment: 'production' | 'development' (APS_ENVIRONMENT env wins) // teamId: Apple team id, for the shared keychain group // extensionProfile / appProfile: provisioning profile specifiers for the // Release configuration of the extension and the app (IOS_NSE_PROFILE and // IOS_APP_PROFILE env win). Set per target because xcodebuild's command // line applies a specifier to every target. Empty = leave unset. const fs = require('node:fs'); const path = require('node:path'); const { IOSConfig, withAppDelegate, withEntitlementsPlist, withInfoPlist, withXcodeProject, withDangerousMod, createRunOncePlugin, } = require('@expo/config-plugins'); const TARGET = 'NotificationService'; const DEPLOYMENT_TARGET = '15.1'; const SOURCE_FILES = ['NotificationService.swift', 'SharedKeychain.swift', 'Jmap.swift', 'PushRenewal.swift']; // Compiled into the app as well: the renewal on foreground and in the // background refresh task. const APP_SOURCE_FILES = ['SharedKeychain.swift', 'Jmap.swift', 'PushRenewal.swift', 'PushRenewalTask.swift']; // PushRenewalTask.identifier const RENEWAL_TASK_ID = 'org.bulwarkmail.mobile.push-renewal'; const RENEWAL_REGISTER_CALL = 'PushRenewalTask.register()'; // Where Expo's AppDelegate.swift hands launch to its superclass; the // registration has to run before that returns (Expo's Maps plugin uses the // same anchor). const APP_DELEGATE_LAUNCH = /^([ \t]*)return super\.application\(\w+, didFinishLaunchingWithOptions: \w+\)/m; function sharedGroupSuffix() { return 'org.bulwarkmail.shared'; } function withAppEntitlements(config, { apsEnvironment }) { return withEntitlementsPlist(config, (cfg) => { const bundleId = cfg.ios?.bundleIdentifier; cfg.modResults['aps-environment'] = apsEnvironment; const groups = new Set(cfg.modResults['keychain-access-groups'] ?? []); groups.add(`$(AppIdentifierPrefix)${bundleId}`); groups.add(`$(AppIdentifierPrefix)${sharedGroupSuffix()}`); cfg.modResults['keychain-access-groups'] = [...groups]; return cfg; }); } function infoPlist({ version, buildNumber }) { return ` CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName Bulwark Mail notifications CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName $(PRODUCT_NAME) CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString ${version} CFBundleVersion ${buildNumber} BulwarkKeychainGroup $(DEVELOPMENT_TEAM).${sharedGroupSuffix()} NSExtension NSExtensionPointIdentifier com.apple.usernotifications.service NSExtensionPrincipalClass $(PRODUCT_MODULE_NAME).NotificationService `; } function entitlementsPlist() { return ` keychain-access-groups $(AppIdentifierPrefix)${sharedGroupSuffix()} `; } function withExtensionFiles(config) { return withDangerousMod(config, [ 'ios', async (cfg) => { const dir = path.join(cfg.modRequest.platformProjectRoot, TARGET); fs.mkdirSync(dir, { recursive: true }); for (const file of SOURCE_FILES) { fs.copyFileSync(path.join(__dirname, file), path.join(dir, file)); } fs.writeFileSync( path.join(dir, `${TARGET}-Info.plist`), infoPlist({ version: cfg.version, buildNumber: cfg.ios?.buildNumber ?? '1' }), ); fs.writeFileSync(path.join(dir, `${TARGET}.entitlements`), entitlementsPlist()); const appDir = path.join(cfg.modRequest.platformProjectRoot, cfg.modRequest.projectName); for (const file of APP_SOURCE_FILES) { fs.copyFileSync(path.join(__dirname, file), path.join(appDir, file)); } return cfg; }, ]); } /** Compile the renewal sources into the app target. */ function addAppSources(cfg) { const projectName = cfg.modRequest.projectName; for (const file of APP_SOURCE_FILES) { IOSConfig.XcodeUtils.addBuildSourceFileToGroup({ filepath: `${projectName}/${file}`, groupName: projectName, project: cfg.modResults, }); } } /** * The app's Info.plist entries for the renewal: the refresh task id, the * background mode it needs, and the keychain group SharedKeychain reads * (the extension's Info.plist names the same group). */ function setRenewalInfoPlist(plist, { teamId }) { const ids = new Set(plist.BGTaskSchedulerPermittedIdentifiers ?? []); ids.add(RENEWAL_TASK_ID); plist.BGTaskSchedulerPermittedIdentifiers = [...ids]; const modes = new Set(plist.UIBackgroundModes ?? []); modes.add('fetch'); plist.UIBackgroundModes = [...modes]; plist.BulwarkKeychainGroup = `${teamId || '$(DEVELOPMENT_TEAM)'}.${sharedGroupSuffix()}`; return plist; } /** AppDelegate.swift with the renewal task registered during launch. */ function addRenewalToAppDelegate(contents) { if (contents.includes(RENEWAL_REGISTER_CALL)) return contents; if (!APP_DELEGATE_LAUNCH.test(contents)) { throw new Error('plugins/ios-push: AppDelegate.swift has no didFinishLaunching return to register push renewal before'); } return contents.replace( APP_DELEGATE_LAUNCH, (line, indent) => `${indent}// plugins/ios-push: BGTaskScheduler handlers must exist before launch ends.\n${indent}${RENEWAL_REGISTER_CALL}\n${line}`, ); } function withRenewal(config, { teamId }) { config = withInfoPlist(config, (cfg) => { setRenewalInfoPlist(cfg.modResults, { teamId }); return cfg; }); config = withAppDelegate(config, (cfg) => { if (cfg.modResults.language !== 'swift') { throw new Error('plugins/ios-push: push renewal expects a Swift AppDelegate'); } cfg.modResults.contents = addRenewalToAppDelegate(cfg.modResults.contents); return cfg; }); return config; } function setReleaseProfiles(cfg, { appProfile, extensionProfile }) { if (!appProfile && !extensionProfile) return; const project = cfg.modResults; const appName = `"${cfg.modRequest.projectName}"`; const configs = project.pbxXCBuildConfigurationSection(); for (const key of Object.keys(configs)) { const entry = configs[key]; if (!entry.buildSettings || entry.name !== 'Release') continue; const name = entry.buildSettings.PRODUCT_NAME; const profile = name === `"${TARGET}"` ? extensionProfile : (name === appName || name === '"$(TARGET_NAME)"') && entry.buildSettings.PRODUCT_BUNDLE_IDENTIFIER ? appProfile : ''; if (profile) entry.buildSettings.PROVISIONING_PROFILE_SPECIFIER = `"${profile}"`; } } // One mod for creating the target and naming the profiles: separate // withXcodeProject mods don't run in the order they are registered. function withExtensionTarget(config, { teamId, appProfile, extensionProfile }) { return withXcodeProject(config, (cfg) => { addExtensionTarget(cfg, { teamId }); addAppSources(cfg); setReleaseProfiles(cfg, { appProfile, extensionProfile }); return cfg; }); } function addExtensionTarget(cfg, { teamId }) { const project = cfg.modResults; if (project.pbxTargetByName(TARGET)) return; // addTarget expects these sections; a fresh Expo project may not have them. const objects = project.hash.project.objects; objects.PBXTargetDependency = objects.PBXTargetDependency || {}; objects.PBXContainerItemProxy = objects.PBXContainerItemProxy || {}; const group = project.addPbxGroup( [...SOURCE_FILES, `${TARGET}-Info.plist`, `${TARGET}.entitlements`], TARGET, TARGET, ); // Hang the group off the project's main group. const mainGroupId = project.getFirstProject().firstProject.mainGroup; project.addToPbxGroup(group.uuid, mainGroupId); const bundleId = `${cfg.ios.bundleIdentifier}.${TARGET}`; const target = project.addTarget(TARGET, 'app_extension', TARGET, bundleId); project.addBuildPhase(SOURCE_FILES, 'PBXSourcesBuildPhase', 'Sources', target.uuid); project.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', target.uuid); project.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', target.uuid); const configs = project.pbxXCBuildConfigurationSection(); for (const key of Object.keys(configs)) { const entry = configs[key]; if (!entry.buildSettings || entry.buildSettings.PRODUCT_NAME !== `"${TARGET}"`) continue; const s = entry.buildSettings; s.CODE_SIGN_ENTITLEMENTS = `${TARGET}/${TARGET}.entitlements`; s.IPHONEOS_DEPLOYMENT_TARGET = DEPLOYMENT_TARGET; s.TARGETED_DEVICE_FAMILY = '"1,2"'; s.SWIFT_VERSION = '5.0'; s.CLANG_ENABLE_MODULES = 'YES'; s.GENERATE_INFOPLIST_FILE = 'NO'; if (teamId) s.DEVELOPMENT_TEAM = teamId; } } function withIosPush(config, options = {}) { const apsEnvironment = process.env.APS_ENVIRONMENT || options.apsEnvironment || 'production'; const teamId = process.env.APPLE_TEAM_ID || options.teamId || ''; const extensionProfile = process.env.IOS_NSE_PROFILE || options.extensionProfile || ''; const appProfile = process.env.IOS_APP_PROFILE || options.appProfile || ''; config = withAppEntitlements(config, { apsEnvironment }); config = withExtensionFiles(config); config = withExtensionTarget(config, { teamId, appProfile, extensionProfile }); config = withRenewal(config, { teamId }); return config; } module.exports = createRunOncePlugin(withIosPush, 'bulwark-ios-push', '1.0.0'); // For the tests (src/lib/__tests__/ios-push-renewal.test.ts). module.exports.internals = { addRenewalToAppDelegate, setRenewalInfoPlist, SOURCE_FILES, APP_SOURCE_FILES, RENEWAL_TASK_ID };