react-native-acoustic-connect-beta
Version:
BETA: React native plugin for Acoustic Connect
202 lines (179 loc) • 7.62 kB
JavaScript
// `acoustic-connect setup-ios-push [dir]` — create/repair the iOS push extensions.
//
// The manual-setup counterpart to what the Expo Config Plugin does on
// `prebuild`: it provisions the two app-extension targets a bare React Native
// app needs for Acoustic Connect rich push —
//
// * ConnectNSE — Notification Service Extension
// * ConnectNCE — Notification Content Extension
//
// Division of labour:
// - THIS script (Node) owns the files: it writes the per-extension Swift,
// Info.plist and entitlements from the SDK templates (substituting the App
// Group from ConnectConfig.json), and ensures the host app has an
// entitlements file with aps-environment + the App Group.
// - add_push_extensions.rb (Ruby + xcodeproj) owns the pbxproj surgery:
// it creates/links the targets, the embed phase, and the system frameworks.
//
// Everything is idempotent — existing files are left untouched, existing
// targets are skipped. Re-running repairs missing pieces without duplicating.
import fs from 'node:fs'
import path from 'node:path'
import {fileURLToPath} from 'node:url'
import {color, commandExists, fileExists, readJson, readText, run, section} from '../lib.mjs'
const cliDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))) // .../cli
const sdkRoot = path.dirname(cliDir) // package root
const templatesDir = path.join(cliDir, 'ios', 'templates')
// Swift principal-class templates are shared with the Expo Config Plugin.
const swiftDir = path.join(sdkRoot, 'plugin', 'swift')
const APP_GROUP_TOKEN = 'CONNECT_APP_GROUP_IDENTIFIER_PLACEHOLDER'
const EXTENSIONS = [
{name: 'ConnectNSE', swift: 'NotificationService.swift'},
{name: 'ConnectNCE', swift: 'NotificationViewController.swift'},
]
// Find the single .xcodeproj under <dir>/ios. The app target name follows the
// RN convention (the project basename), which is what `setup-ios-push` assumes.
function findXcodeproj(iosDir) {
let entries = []
try {
entries = fs.readdirSync(iosDir)
} catch {
return null
}
const proj = entries.find((e) => e.endsWith('.xcodeproj'))
return proj ? path.join(iosDir, proj) : null
}
// Write `content` to `dest` only if missing; report which happened.
function writeIfMissing(reporter, label, dest, content) {
if (fileExists(dest)) {
reporter.pass(label, 'present')
return
}
fs.mkdirSync(path.dirname(dest), {recursive: true})
fs.writeFileSync(dest, content)
reporter.pass(label, 'created')
}
// Render a template file, substituting the App Group placeholder. Only the
// real occurrences are replaced — the Swift string literal and the plist
// <string> entry — so any explanatory comment that mentions the token by name
// (e.g. "The <token> is replaced by …") stays readable.
function renderTemplate(srcPath, appGroup) {
const raw = readText(srcPath)
if (raw == null) return null
return raw
.split(`"${APP_GROUP_TOKEN}"`)
.join(`"${appGroup}"`)
.split(`<string>${APP_GROUP_TOKEN}</string>`)
.join(`<string>${appGroup}</string>`)
}
// Minimal host entitlements when the app has none yet: aps-environment +
// the App Group, matching what the extensions share.
function hostEntitlements(appGroup) {
return `<?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>
\t<key>aps-environment</key>
\t<string>development</string>
\t<key>com.apple.security.application-groups</key>
\t<array>
\t\t<string>${appGroup}</string>
\t</array>
</dict>
</plist>
`
}
export async function setupIosPush(dir, {reporter}) {
console.log(
color.bold('\nAcoustic Connect — iOS push extensions') +
color.dim(` (${dir})`),
)
if (process.platform !== 'darwin') {
reporter.warn('iOS', 'skipped — iOS project surgery requires macOS')
return
}
const iosDir = path.join(dir, 'ios')
if (!fileExists(iosDir)) {
reporter.fail('ios/', `no ios/ directory under ${dir}`)
return
}
const projectPath = findXcodeproj(iosDir)
if (!projectPath) {
reporter.fail('.xcodeproj', `none found under ${iosDir}`)
return
}
const appTarget = path.basename(projectPath, '.xcodeproj')
reporter.pass('Xcode project', `${path.basename(projectPath)} (target ${appTarget})`)
// App Group is the linchpin — host + both extensions must share it.
const cfg = readJson(path.join(dir, 'ConnectConfig.json'))
const appGroup = cfg?.Connect?.iOSAppGroupIdentifier
if (!appGroup) {
reporter.fail(
'iOSAppGroupIdentifier',
'not set in ConnectConfig.json — required for the NSE/NCE shared store. Run `acoustic-connect doctor` first.',
)
return
}
reporter.pass('App Group', appGroup)
// ── Files (idempotent) ───────────────────────────────────────────────────
section('Source files')
// Host entitlements — only created if absent (never clobber a real one).
const hostEntPath = path.join(iosDir, appTarget, `${appTarget}.entitlements`)
writeIfMissing(
reporter,
`${appTarget}/${appTarget}.entitlements`,
hostEntPath,
hostEntitlements(appGroup),
)
for (const ext of EXTENSIONS) {
const extDir = path.join(iosDir, ext.name)
const swift = renderTemplate(path.join(swiftDir, ext.swift), appGroup)
if (swift == null) {
reporter.fail(`${ext.name}/${ext.swift}`, `template missing in ${swiftDir}`)
return
}
writeIfMissing(reporter, `${ext.name}/${ext.swift}`, path.join(extDir, ext.swift), swift)
const tplKey = ext.name === 'ConnectNSE' ? 'nse' : 'nce'
const plist = readText(path.join(templatesDir, tplKey, 'Info.plist'))
writeIfMissing(reporter, `${ext.name}/Info.plist`, path.join(extDir, 'Info.plist'), plist)
const ent = renderTemplate(
path.join(templatesDir, tplKey, `${ext.name}.entitlements`),
appGroup,
)
writeIfMissing(
reporter,
`${ext.name}/${ext.name}.entitlements`,
path.join(extDir, `${ext.name}.entitlements`),
ent,
)
}
// ── pbxproj surgery (Ruby) ─────────────────────────────────────────────────
section('Xcode targets')
if (!commandExists('ruby')) {
reporter.warn(
'Push extensions',
'ruby not found — files written, but run add_push_extensions.rb on a machine with ruby + the xcodeproj gem to wire the targets',
)
return
}
const rubyScript = path.join(cliDir, 'ios', 'add_push_extensions.rb')
// Pass the project path / target name as real environment variables (the
// Ruby script reads them via ENV[…]) rather than interpolating them into the
// command line — a path with shell metacharacters must not be re-parsed.
// The signing team (when configured) is forwarded so the scaffolder stamps
// DEVELOPMENT_TEAM on the host + both extensions; without it a CLI build
// drops aps-environment to ad-hoc and yields no APNs token.
const team = cfg?.Connect?.iOSDevelopmentTeam
const rubyEnv = {
ACOUSTIC_PROJECT_PATH: projectPath,
ACOUSTIC_APP_TARGET: appTarget,
}
if (team) rubyEnv.ACOUSTIC_DEVELOPMENT_TEAM = team
if (run(`ruby "${rubyScript}"`, {cwd: iosDir, env: rubyEnv}))
reporter.pass('Push extensions wired (NSE + NCE)')
else
reporter.fail(
'Push extensions',
'add_push_extensions.rb failed — needs the xcodeproj gem (ships with CocoaPods: `gem install xcodeproj`)',
)
}