UNPKG

react-native-acoustic-connect-beta

Version:

BETA: React native plugin for Acoustic Connect

460 lines (424 loc) 20 kB
// `acoustic-connect doctor [dir]` — pre-build doctor + config scaffolder. // // One command that checks the prerequisites and validates the bits of Acoustic // Connect integration that otherwise fail late and confusingly at build time: // the App Group format, a Java-safe Android package, and the // google-services.json package match. It also scaffolds the gitignored // ConnectConfig.json — from a project-local ConnectConfig.example.json if // present, else from the copy bundled at the package root — so a fresh // project has something to edit. // // Auto-detects the project shape: // - Expo : a root app.json with an `expo` block (identifiers live there; // google-services.json sits at the project root). // - bare : an android/app/build.gradle (identifiers come from // applicationId; google-services.json sits under android/app/). // // It is read-mostly: the only thing it writes is a missing ConnectConfig.json // (copied from the example). It never touches native projects, installs // dependencies, or creates Apple/Firebase resources — those are owned by the // platform tooling and the developer's accounts. import fs from 'node:fs' import path from 'node:path' import {fileURLToPath} from 'node:url' import { Reporter, capture, color, copyIfMissing, fileExists, readJson, readText, section, } from './lib.mjs' // Package root (one level up from cli/) — where the bundled // ConnectConfig.example.json ships for real (published) consumers. const sdkRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))) // App Group format per Apple + the Config Plugin's own validator // (plugin/src/withConnectNSE.ts: APP_GROUP_PATTERN). const APP_GROUP_PATTERN = /^group\.[A-Za-z0-9.-]+$/ // The committed sample ships placeholder identifiers (com.example.*) that the // consumer is expected to replace with their own — setting app.json identity is // the normal Expo workflow, not something the SDK rewrites for you. Flag a value // still on the placeholder so it isn't shipped by accident. const PLACEHOLDER_ID_PREFIX = 'com.example.' // Sentinels shipped in ConnectConfig.example.json — a value still on one of // these means the consumer hasn't filled it in. const PLACEHOLDER_APP_KEY = 'YOUR_CONNECT_APP_KEY_HERE' const PLACEHOLDER_APP_GROUP = 'YOUR_APP_GROUP_ID_HERE' const PLACEHOLDER_TEAM = 'YOUR_TEAM_ID' // Host of the placeholder collector URLs in the bare sample's example config. const PLACEHOLDER_COLLECTOR_HOST = 'collector.example.com' // Apple Team IDs are 10 uppercase-alphanumeric characters. const TEAM_PATTERN = /^[A-Z0-9]{10}$/ // A usable collector endpoint: a parseable https URL whose host isn't the // example placeholder. Used for PostMessageUrl / KillSwitchUrl. function isValidCollectorUrl(value) { if (!value || typeof value !== 'string') return false let u try { u = new URL(value) } catch { return false } return u.protocol === 'https:' && u.hostname !== PLACEHOLDER_COLLECTOR_HOST } // Java reserved words (+ literals) that cannot appear as a package segment. // Expo uses android.package as the Java/Kotlin namespace, so any segment that // is a keyword breaks the Gradle build ("not a valid Java package name"). const JAVA_KEYWORDS = new Set([ 'abstract', 'assert', 'boolean', 'break', 'byte', 'case', 'catch', 'char', 'class', 'const', 'continue', 'default', 'do', 'double', 'else', 'enum', 'extends', 'final', 'finally', 'float', 'for', 'goto', 'if', 'implements', 'import', 'instanceof', 'int', 'interface', 'long', 'native', 'new', 'package', 'private', 'protected', 'public', 'return', 'short', 'static', 'strictfp', 'super', 'switch', 'synchronized', 'this', 'throw', 'throws', 'transient', 'try', 'void', 'volatile', 'while', 'true', 'false', 'null', ]) // Decide whether `dir` is an Expo or a bare React Native project. export function detectProjectType(dir) { const appJson = readJson(path.join(dir, 'app.json')) if (appJson && appJson.expo) return 'expo' // A bare RN app always declares `react-native` in its manifest. Use that as // the discriminator rather than a bare `android/` directory — any Flutter or // native-Android project also has one, and would be misclassified as bare. const pkg = readJson(path.join(dir, 'package.json')) const deps = {...pkg?.dependencies, ...pkg?.devDependencies} if (deps['react-native']) return 'bare' return 'unknown' } function checkNode(reporter) { const major = Number(process.versions.node.split('.')[0]) if (major >= 20) reporter.pass(`Node ${process.versions.node}`) else reporter.fail( `Node ${process.versions.node}`, 'Node >= 20 is required — upgrade Node and re-run', ) } // Scaffold the per-developer ConnectConfig.json from an example. Prefers a // project-local ConnectConfig.example.json (e.g. this repo's own // Examples/bare-workflow and Examples/expo, which commit a customized one) and // falls back to the copy bundled with the package — the project-local file // generally isn't there for a real (published) consumer. // // `bundledExample` is overridable purely so cli/__tests__/doctor.test.mjs can // exercise the "both missing" branch without touching the real package file. export function ensureConnectConfig( reporter, dir, {bundledExample = path.join(sdkRoot, 'ConnectConfig.example.json')} = {}, ) { const dest = path.join(dir, 'ConnectConfig.json') const localExample = path.join(dir, 'ConnectConfig.example.json') const src = fileExists(localExample) ? localExample : bundledExample const result = copyIfMissing(src, dest) if (result === 'created') reporter.warn( 'ConnectConfig.json', 'created from example — EDIT it: set AppKey + collector URLs (PostMessageUrl/KillSwitchUrl), and iOSAppGroupIdentifier for push', ) else if (result === 'exists') reporter.pass('ConnectConfig.json present') else reporter.fail('ConnectConfig.json', 'ConnectConfig.example.json missing') } // Validate the ConnectConfig.json values. The push-required inputs (collector // URLs, App Group, iOS signing team) are only HARD failures when push is on — // or when `--require-push` forces it. A client that doesn't use push needs // nothing beyond AppKey, so off-push these stay advisory and never block. // // A bad value otherwise fails the Config Plugin during `expo prebuild` / the // iOS extensions at build time, not here, so catch it early. Returns // { appGroup, pushEnabled, strict } for downstream checks. function checkConnectConfigValues(reporter, dir, {requirePush} = {}) { const cfg = readJson(path.join(dir, 'ConnectConfig.json')) if (cfg === undefined) { // Missing/failed was already reported by ensureConnectConfig; only flag a // present-but-broken file here. if (fileExists(path.join(dir, 'ConnectConfig.json'))) reporter.fail('ConnectConfig.json', 'present but not valid JSON') return {strict: requirePush === true} } const connect = (cfg && cfg.Connect) || {} const appGroup = connect.iOSAppGroupIdentifier const pushEnabled = connect.PushEnabled === true // Push-required inputs are strict iff push is on (or forced via flag). const strict = pushEnabled || requirePush === true const gate = (label, detail) => strict ? reporter.fail(label, detail) : reporter.warn(label, detail) // AppKey — the one value the SDK always needs to reach the collector. Under // push it's a hard gate; off push it stays advisory (a non-push client may // simply not have wired it yet — but it still needs it to send anything). const appKey = connect.AppKey if (appKey && appKey !== PLACEHOLDER_APP_KEY) reporter.pass('AppKey', 'set') else gate( 'AppKey', `not set (empty or still "${PLACEHOLDER_APP_KEY}") — required to send to the collector`, ) // Collector URLs — the push registration (and every event) posts here. The // example ships unfilled placeholders, so reject those under push. for (const key of ['PostMessageUrl', 'KillSwitchUrl']) { if (isValidCollectorUrl(connect[key])) reporter.pass(key, 'set') else gate( key, `"${connect[key] ?? ''}" is not a valid https collector URL (host must not be ${PLACEHOLDER_COLLECTOR_HOST})`, ) } // App Group — the shared store the iOS NSE/NCE rich-push extensions read/write. if (appGroup && appGroup !== PLACEHOLDER_APP_GROUP) { if (APP_GROUP_PATTERN.test(appGroup)) reporter.pass('iOSAppGroupIdentifier', appGroup) else reporter.fail( 'iOSAppGroupIdentifier', `"${appGroup}" is invalid — must match group.<reverse-dns> (letters/digits/dots/hyphens)`, ) } else { gate( 'iOSAppGroupIdentifier', appGroup ? `still the placeholder "${appGroup}" — set your own App Group` : 'not set — required for the iOS NSE/NCE rich-push extensions', ) } // iOS signing team — without it a CLI build drops aps-environment to ad-hoc // signing and the OS issues no APNs token (silent on the Simulator). const team = connect.iOSDevelopmentTeam if (team && team !== PLACEHOLDER_TEAM && TEAM_PATTERN.test(team)) reporter.pass('iOSDevelopmentTeam', team) else gate( 'iOSDevelopmentTeam', team && team !== PLACEHOLDER_TEAM ? `"${team}" is not a 10-char Apple Team ID` : 'not set — required so the iOS build embeds aps-environment (no APNs token without it)', ) return {appGroup, pushEnabled, strict} } function validateAndroidPackage(reporter, label, pkg) { if (!pkg) { reporter.warn(label, 'not set') return } const badSegment = pkg.split('.').find((seg) => JAVA_KEYWORDS.has(seg)) if (badSegment) reporter.fail( label, `"${pkg}" — segment "${badSegment}" is a Java keyword; the Android build will fail. Use a Java-safe package (the iOS bundle id may keep it).`, ) else reporter.pass(label, pkg) } // Expo: identifiers live in app.json's expo block. Under push (`strict`) the // ids must be set and off the sample placeholder, because android.package must // match a client in google-services.json (Gradle fails otherwise) and // ios.bundleIdentifier drives APNs. Off push these stay advisory. Returns the // resolved android.package and the configured googleServicesFile (if any). function checkAppJson(reporter, dir, {strict} = {}) { const appJson = readJson(path.join(dir, 'app.json')) if (!appJson || !appJson.expo) { reporter.fail('app.json', 'missing or has no "expo" block') return {} } const expo = appJson.expo const androidPackage = expo.android && expo.android.package const iosBundleId = expo.ios && expo.ios.bundleIdentifier const gsFile = expo.android && expo.android.googleServicesFile const gate = (label, detail) => strict ? reporter.fail(label, detail) : reporter.warn(label, detail) // android.package: must be set + non-placeholder under push; always // keyword-checked when present (a Java keyword segment breaks the build). if (!androidPackage) gate('android.package', 'not set in app.json — required for the Android build / FCM') else if (androidPackage.startsWith(PLACEHOLDER_ID_PREFIX)) gate( 'android.package', `still the sample placeholder "${androidPackage}" — set your own (must match a client in google-services.json)`, ) else validateAndroidPackage(reporter, 'android.package', androidPackage) // ios.bundleIdentifier: must be set + non-placeholder under push (drives APNs). if (!iosBundleId) gate('ios.bundleIdentifier', 'not set in app.json') else if (iosBundleId.startsWith(PLACEHOLDER_ID_PREFIX)) gate( 'ios.bundleIdentifier', `still the sample placeholder "${iosBundleId}" — set your own in app.json before building`, ) else reporter.pass('ios.bundleIdentifier', iosBundleId) return {androidPackage, iosBundleId, gsFile} } // Expo only: an incremental `expo prebuild` does NOT propagate an app.json // android.package change into an already-generated android/app/build.gradle // (prebuild is non-destructive). Catch the stale applicationId so the developer // doesn't hit a confusing google-services mismatch on a config they "already // fixed". function checkExpoCleanPrebuild(reporter, dir, {androidPackage, strict} = {}) { if (!androidPackage) return const gradle = readText(path.join(dir, 'android', 'app', 'build.gradle')) if (!gradle) return // not prebuilt yet — nothing stale to catch const appId = gradle.match(/applicationId\s+["']([^"']+)["']/)?.[1] if (appId && appId !== androidPackage) { const detail = `generated applicationId "${appId}" ≠ app.json android.package "${androidPackage}" — run \`npx expo prebuild --platform android --clean\` to regenerate` if (strict) reporter.fail('android/ (stale prebuild)', detail) else reporter.warn('android/ (stale prebuild)', detail) } } // iOS only, macOS only, best-effort hint — NEVER a hard failure. A *local dev* // CLI build with no Development certificate falls back to ad-hoc signing, which // drops aps-environment → no APNs token. But a CI/release machine that signs // with an Apple Distribution certificate (and has no Development cert) is a // perfectly valid configuration, so blocking it would be wrong — this stays a // warning either way. Skips silently when `security` is unavailable (non-mac / // locked keychain). function checkIosSigningIdentity(reporter) { if (process.platform !== 'darwin') return const {ok, stdout} = capture('security find-identity -p codesigning -v') if (!ok) return if (/Apple Development|iPhone Developer/.test(stdout)) reporter.pass('iOS signing identity', 'Apple Development certificate present') else reporter.warn( 'iOS signing identity', 'no Apple Development certificate in the keychain — fine for CI/release (Distribution signing), but a local dev push build needs one, else ad-hoc signing drops aps-environment (no APNs token). Add your Apple ID in Xcode → Accounts and build once with -allowProvisioningUpdates.', ) } // Bare: read identifiers from android/app/build.gradle. Two distinct fields: // - `namespace` → the R-class / Java package; MUST be a valid Java // package (keyword check applies here). // - `applicationId` → the published app id; may contain segments that are // not valid Java identifiers (e.g. `new`), so it is NOT // keyword-checked. It is what FCM matches in // google-services.json, so it's returned for that check. // (Expo collapses both into android.package, which is why checkAppJson // keyword-checks that single field.) function checkBareAndroidId(reporter, dir) { const gradle = readText(path.join(dir, 'android', 'app', 'build.gradle')) const namespace = gradle?.match(/namespace\s+["']([^"']+)["']/)?.[1] || null const appId = gradle?.match(/applicationId\s+["']([^"']+)["']/)?.[1] || null validateAndroidPackage(reporter, 'namespace', namespace) if (appId) reporter.pass('applicationId', appId) else reporter.warn('applicationId', 'not set') return {androidPackage: appId} } // google-services.json must contain a client whose package_name matches the // Android package (FCM matches by package); the gradle plugin fails otherwise // ("No matching client found for package name"). function checkGoogleServices(reporter, {gsPath, androidPackage, strict}) { if (!fileExists(gsPath)) { if (strict) reporter.fail( 'google-services.json', 'missing — required for Android FCM builds (add the Firebase Android app, then download it here)', ) else reporter.pass('google-services.json', 'not present (push disabled)') return } const gs = readJson(gsPath) if (gs === undefined) { reporter.fail('google-services.json', 'present but not valid JSON') return } const isPlaceholder = gs.project_info?.project_id === 'your-firebase-project-id' || JSON.stringify(gs).includes('REPLACE_WITH_YOUR_FIREBASE') const packages = (gs.client || []) .map((c) => c.client_info?.android_client_info?.package_name) .filter(Boolean) if (isPlaceholder) { const detail = `placeholder values — replace with your real Firebase config (register package '${androidPackage}')` if (strict) reporter.fail('google-services.json', detail) else reporter.warn('google-services.json', detail) } else if (androidPackage && !packages.includes(androidPackage)) reporter.fail( 'google-services.json', `no client matches '${androidPackage}' (has: ${packages.join(', ') || 'none'}). Register that package in Firebase and re-download.`, ) else reporter.pass('google-services.json', 'package match') } // Collect *.entitlements files at most one level deep under iosDir (host + // extension target folders), skipping Pods/build. function findEntitlements(iosDir) { const out = [] let entries try { entries = fs.readdirSync(iosDir, {withFileTypes: true}) } catch { return out } for (const e of entries) { const p = path.join(iosDir, e.name) if (e.isFile() && e.name.endsWith('.entitlements')) out.push(p) else if (e.isDirectory() && e.name !== 'Pods' && e.name !== 'build') { try { for (const f of fs.readdirSync(p)) if (f.endsWith('.entitlements')) out.push(path.join(p, f)) } catch { /* ignore unreadable subdir */ } } } return out } // Bare iOS only: the host entitlements must declare aps-environment + an App // Group (the Config Plugin generates these for Expo). function checkBareIosEntitlements(reporter, dir) { const iosDir = path.join(dir, 'ios') if (!fileExists(iosDir)) { reporter.warn('ios/', 'no ios/ directory found') return } const hasBoth = findEntitlements(iosDir).some((p) => { const t = readText(p) || '' return t.includes('aps-environment') && t.includes('application-groups') }) if (hasBoth) reporter.pass('App entitlements (aps-environment + App Group)') else reporter.warn( 'App entitlements', 'no *.entitlements with both aps-environment and application-groups — run `acoustic-connect setup-ios-push`', ) } // Run all relevant checks for `dir`. Returns the Reporter (caller decides how // to print the summary / exit). export function runDoctor(dir, {reporter = new Reporter(), flags = {}} = {}) { const requirePush = flags.requirePush === true const type = detectProjectType(dir) console.log( color.bold('\nAcoustic Connect doctor') + color.dim(` (${type} project — ${dir})`), ) section('Prerequisites') checkNode(reporter) section('Configuration') ensureConnectConfig(reporter, dir) const {strict = requirePush} = checkConnectConfigValues(reporter, dir, { requirePush, }) section('Identifiers') let androidPackage let gsFile if (type === 'expo') { ;({androidPackage, gsFile} = checkAppJson(reporter, dir, {strict})) } else if (type === 'bare') { ;({androidPackage} = checkBareAndroidId(reporter, dir)) } else { reporter.warn( 'project type', 'could not detect Expo or bare RN layout — skipping identifier checks', ) } section('Android push (FCM)') const gsPath = type === 'expo' ? path.join(dir, gsFile || 'google-services.json') : path.join(dir, 'android', 'app', 'google-services.json') checkGoogleServices(reporter, {gsPath, androidPackage, strict}) if (type === 'expo') checkExpoCleanPrebuild(reporter, dir, {androidPackage, strict}) section('iOS push') if (type === 'bare') checkBareIosEntitlements(reporter, dir) // Signing-identity hint is only relevant to push (it's about the // aps-environment drop). Skip it for non-push projects to avoid noise; it's // always a warning when it does run, so it never blocks a Distribution-only // CI/release machine. if (strict) checkIosSigningIdentity(reporter) return reporter }