// Project detection for `storebound scan`: reads a mobile app or game project (read-only, offline) and works out the // stack, the dependencies (with versions and the file:line they come from), the Info.plist keys (from Info.plist files, // Xcode INFOPLIST_KEY_* build settings, Expo app.json, Unity ProjectSettings and Godot export presets), entitlements, // privacy manifests, the Android manifest (permissions, services, target SDK) and the app's own source files. // Tolerant on purpose: an unreadable or odd file is skipped, never an exception. import { type Dirent, readdirSync, readFileSync, statSync } from 'node:fs'; import { basename, dirname, extname, join, relative, sep } from 'node:path'; import { resolvePackage } from '../node-packages'; import { skipDir } from '../walk-skip'; import { isBuildCopyPath, type Manifest, parseManifest } from '../privacy-manifest'; import { type DepRef, type Eco, matchesSdk, SDKS, type SdkDef } from './sdks'; import { exportedIpas, ipaInfo } from '../xcode'; export interface Loc { file: string; line: number } export interface PlistEntry { key: string; value: unknown; at: Loc } export interface Hit extends Loc { text: string } export type Stack = 'ios-native' | 'android-native' | 'react-native' | 'expo' | 'capacitor' | 'cordova' | 'flutter' | 'unity' | 'godot' | 'web'; export interface DetectedSdk { sdk: SdkDef; deps: DepRef[] } export interface AndroidComponent { kind: 'activity' | 'service' | 'receiver'; name: string; /** android:permission (e.g. BIND_ACCESSIBILITY_SERVICE, BIND_VPN_SERVICE, BIND_DEVICE_ADMIN). */ permission: string | null; fgsType: string | null; actions: string[]; categories: string[]; meta: string[]; at: Loc; } export interface ProjectFacts { root: string; stacks: Stack[]; /** The project has iOS / Android build files (or a cross-platform engine that targets them). */ ios: boolean; android: boolean; deps: DepRef[]; sdks: DetectedSdk[]; /** Info.plist keys merged from every source; first source wins. */ plist: Map; /** Where Info.plist keys came from (empty = the project has no readable iOS settings: plist rules are skipped). */ plistSources: string[]; entitlements: Map; privacyManifests: Manifest[]; androidInfo: { manifests: string[]; permissions: { name: string; at: Loc }[]; services: { name: string; fgsType: string | null; at: Loc }[]; targetSdk: { value: number; at: Loc } | null; /** targetSdk is set by a tool we cannot read (Flutter SDK, Expo SDK, Unity "automatic"). */ targetSdkFrom: string | null; formFactor: 'phone' | 'wear' | 'tv' | 'automotive'; /** : permissions the app strips from the merged manifest (e.g. AD_ID). */ removedPermissions: { name: string; at: Loc }[]; /** Activities, services and receivers with their android:permission, intent-filter actions and meta-data names. */ components: AndroidComponent[]; /** */ extractNativeLibs: { value: boolean; at: Loc } | null; /** A merged manifest from a local build (build/intermediates), read for the permissions libraries add. */ mergedManifest: string | null; /** res/xml accessibility-service configs (android:isAccessibilityTool). */ accessibilityConfigs: { at: Loc; isTool: boolean }[]; /** res/values*\/strings.xml (for disclosure texts). */ strings: { path: string; text: string }[]; }; /** Native code and the toolchain versions that decide 16 KB page size support. */ native: { /** Prebuilt .so files in the project (jniLibs, Unity Plugins/Android, ...). */ libs: string[]; ndkVersion: { value: string; at: Loc } | null; /** The NDK version is set by a tool (flutter.ndkVersion). */ ndkFrom: string | null; /** Android Gradle Plugin version. */ agp: { value: string; at: Loc } | null; /** The project builds C / C++ itself (externalNativeBuild, CMakeLists.txt, Android.mk). */ nativeBuild: Loc | null; /** ANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON or -Wl,-z,max-page-size=16384 in the native build. */ flexiblePageSizes: Loc | null; /** jniLibs.useLegacyPackaging = true (compressed native libraries). */ legacyPackaging: Loc | null; /** ProjectSettings/ProjectVersion.txt m_EditorVersion. */ unityVersion: { value: string; at: Loc } | null; }; /** Capacitor server.url / Cordova pointing at a remote site. */ remoteStart: { url: string; at: Loc } | null; /** App source files (Swift, Objective-C, Kotlin, Java, Dart, JS / TS, C#, GDScript, HTML). */ sources: { path: string; text: string }[]; /** Folders of built web output inside the project (Capacitor webDir): low-trust evidence, see isBuilt. */ builtDirs: string[]; /** Xcode / SDK of the newest IPA in build/ios/export (Info.plist DTXcode, DTSDKName, DTPlatformVersion). */ iosBuild?: { file: string; xcode?: string; sdkName?: string; platformVersion?: string } | null; /** Files looked at, relative to root (for the report). */ scanned: number; } const SKIP_DIRS = new Set(['.git', 'node_modules', 'Pods', 'build', 'Build', 'DerivedData', '.gradle', '.dart_tool', '.build', 'Library', 'Temp', 'Logs', 'obj', 'UserSettings', '.storebound', 'renders', '.idea', '.vscode', '.expo', 'vendor', 'xcuserdata', 'Carthage', '.next', 'coverage', '.godot', 'intermediates', 'generated', 'outputs', 'tmp', '.cxx', 'fastlane', 'dist', 'www', 'public', 'clips', 'frames', 'fonts', '.fvm', // AI tool folders: .claude/worktrees holds whole copies of the repo (seen live: evidence pointed into them) '.claude', '.cursor', '.windsurf', '.codex', '.gemini']); const SRC_EXT = new Set(['.swift', '.m', '.mm', '.h', '.kt', '.java', '.dart', '.js', '.jsx', '.ts', '.tsx', '.cs', '.gd', '.html', '.vue', '.svelte']); const MAX_FILES = 25_000; const MAX_TEXT = 1_500_000; /** Every file under root (relative paths), skipping build output, dependency folders and VCS data. Native libraries * (.so) of any size also go into `bins` when given (they are not read, only listed). */ export function listFiles(root: string, bins?: string[]): string[] { const out: string[] = []; const walk = (dir: string, depth: number) => { if (depth > 14 || out.length >= MAX_FILES) return; let ents: Dirent[]; try { ents = readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const e of ents.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) { const n = e.name; if (SKIP_DIRS.has(n) || n === '.DS_Store') continue; const p = join(dir, n); // the entry type comes with the listing; only files (for their size) and links get a stat let isDir = e.isDirectory(); if (e.isSymbolicLink()) { try { isDir = statSync(p).isDirectory(); } catch { continue; } } if (isDir) { // compiled frameworks are SDK binaries, not app code if (/\.(xc)?framework$|\.xcassets$|\.bundle$|\.app$|\.dSYM$/.test(n)) continue; // dot folders, git worktrees (other checkouts of the same repo; submodules stay in), git-ignored output if (skipDir(root, p, n)) continue; walk(p, depth + 1); } else { if (bins && n.endsWith('.so')) bins.push(relative(root, p).split(sep).join('/')); let size = 0; try { size = statSync(p).size; } catch { continue; } if (size <= MAX_TEXT) out.push(relative(root, p).split(sep).join('/')); } if (out.length >= MAX_FILES) return; } }; walk(root, 0); return out; } export const lineAt = (text: string, index: number): number => (index <= 0 ? 1 : text.slice(0, index).split('\n').length); /** Line of the first occurrence of `needle` (1 when not found). */ export const lineOf = (text: string, needle: string | RegExp): number => { const i = typeof needle === 'string' ? text.indexOf(needle) : text.search(needle); return i < 0 ? 1 : lineAt(text, i); }; // ---- plist ---- type PV = string | number | boolean | PV[] | { [k: string]: PV }; /** Minimal XML plist reader: the value tree, plus the line of every top-level key. Never throws (bad input -> {}). */ export function parsePlist(xml: string): { value: Record; lines: Map } { const lines = new Map(); const re = /<(\/?)(dict|array|key|string|integer|real|true|false|date|data)(\s*\/)?>||<\?[\s\S]*?\?>|]*>|<\/?plist[^>]*>/g; const toks: { tag: string; close: boolean; self: boolean; at: number; end: number }[] = []; for (const m of xml.matchAll(re)) if (m[2]) toks.push({ tag: m[2], close: m[1] === '/', self: !!m[3] || m[2] === 'true' || m[2] === 'false', at: m.index!, end: m.index! + m[0].length }); let i = 0; const unesc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '\'').replace(/&/g, '&'); const readText = (tag: string): string => { const open = toks[i]!; if (open.self) { i++; return ''; } const close = toks[i + 1]; i += 2; return close && close.tag === tag && close.close ? unesc(xml.slice(open.end, close.at)) : ''; }; const value = (depth: number): PV => { const t = toks[i]; if (!t || depth > 30) { i++; return ''; } switch (t.tag) { case 'true': i++; if (toks[i]?.tag === 'true' && toks[i]!.close) i++; return true; case 'false': i++; if (toks[i]?.tag === 'false' && toks[i]!.close) i++; return false; case 'integer': case 'real': return Number(readText(t.tag)); case 'string': case 'date': case 'data': return readText(t.tag); case 'array': { i++; const a: PV[] = []; if (t.self) return a; while (i < toks.length && !(toks[i]!.tag === 'array' && toks[i]!.close)) a.push(value(depth + 1)); i++; return a; } case 'dict': { i++; const o: Record = {}; if (t.self) return o; while (i < toks.length && !(toks[i]!.tag === 'dict' && toks[i]!.close)) { if (toks[i]!.tag !== 'key') { i++; continue; } const at = toks[i]!.at; const k = readText('key'); if (depth === 0) lines.set(k, lineAt(xml, at)); o[k] = value(depth + 1); } i++; return o; } default: i++; return ''; } }; while (i < toks.length && toks[i]!.tag !== 'dict') i++; const v = i < toks.length ? value(0) : {}; return { value: (v && typeof v === 'object' && !Array.isArray(v) ? v : {}) as Record, lines }; } // ---- dependency files ---- function podsLock(file: string, text: string): DepRef[] { const out: DepRef[] = []; const pods = /^PODS:\n([\s\S]*?)(?=\n\S|$(?![\s\S]))/m.exec(text)?.[1] ?? ''; const off = text.indexOf(pods); for (const m of pods.matchAll(/^ {2}- "?([A-Za-z0-9_.+\-/]+) \(([^)]+)\)"?:?$/gm)) out.push({ eco: 'cocoapods', name: m[1]!, version: m[2]!, file, line: lineAt(text, off + m.index!) }); return out; } function podfile(file: string, text: string): DepRef[] { return [...text.matchAll(/^\s*pod\s+['"]([^'"]+)['"](?:\s*,\s*['"]([^'"]+)['"])?/gm)].map(m => ({ eco: 'cocoapods' as const, name: m[1]!, version: m[2] ?? null, file, line: lineAt(text, m.index!) })); } function packageResolved(file: string, text: string): DepRef[] { try { const j = JSON.parse(text) as { pins?: { identity?: string; location?: string; state?: { version?: string } }[]; object?: { pins?: { package?: string; repositoryURL?: string; state?: { version?: string } }[] } }; const pins = j.pins ?? j.object?.pins?.map(p => ({ identity: p.package, location: p.repositoryURL, state: p.state })) ?? []; return pins.map(p => ({ eco: 'spm' as const, name: p.location ?? p.identity ?? '', version: p.state?.version ?? null, file, line: lineOf(text, p.location ?? p.identity ?? '') })); } catch { return []; } } function pbxproj(file: string, text: string): DepRef[] { const out: DepRef[] = []; for (const m of text.matchAll(/repositoryURL = "([^"]+)";/g)) out.push({ eco: 'spm', name: m[1]!, version: null, file, line: lineAt(text, m.index!) }); for (const m of text.matchAll(/isa = XCSwiftPackageProductDependency;[\s\S]{0,300}?productName = "?([A-Za-z0-9_-]+)"?;/g)) out.push({ eco: 'spm', name: m[1]!, version: null, file, line: lineAt(text, m.index!) }); return out; } function cartfile(file: string, text: string): DepRef[] { return [...text.matchAll(/^\s*(?:github|git|binary)\s+"([^"]+)"(?:\s+"?([^"\n]+)"?)?/gm)].map(m => ({ eco: 'carthage' as const, name: m[1]!, version: m[2]?.trim() ?? null, file, line: lineAt(text, m.index!) })); } function gradle(file: string, text: string): DepRef[] { const out: DepRef[] = []; for (const m of text.matchAll(/(?:implementation|api|compileOnly|runtimeOnly|kapt|ksp|annotationProcessor|classpath)\s*\(?\s*(?:platform\(\s*)?["']([A-Za-z0-9_.\-]+):([A-Za-z0-9_.\-]+)(?::([^"'@]+))?/g)) out.push({ eco: 'gradle', name: `${m[1]}:${m[2]}`, version: m[3] ?? null, file, line: lineAt(text, m.index!) }); return out; } function versionCatalog(file: string, text: string): DepRef[] { const versions = new Map(); const vsec = /^\[versions\]\n([\s\S]*?)(?=^\[|$(?![\s\S]))/m.exec(text)?.[1] ?? ''; for (const m of vsec.matchAll(/^\s*([A-Za-z0-9_.\-]+)\s*=\s*"([^"]+)"/gm)) versions.set(m[1]!, m[2]!); const out: DepRef[] = []; for (const m of text.matchAll(/^\s*[A-Za-z0-9_.\-]+\s*=\s*\{([^}]*)\}/gm)) { const body = m[1]!; const mod = /module\s*=\s*"([^":]+):([^"]+)"/.exec(body); const grp = /group\s*=\s*"([^"]+)"/.exec(body), nm = /name\s*=\s*"([^"]+)"/.exec(body); const plugin = /\bid\s*=\s*"(com\.android\.(?:application|library))"/.exec(body); const name = mod ? `${mod[1]}:${mod[2]}` : grp && nm ? `${grp[1]}:${nm[1]}` : plugin ? 'com.android.application' : null; if (!name) continue; const ver = /version\.ref\s*=\s*"([^"]+)"/.exec(body)?.[1]; out.push({ eco: 'gradle', name, version: ver ? versions.get(ver) ?? null : /version\s*=\s*"([^"]+)"/.exec(body)?.[1] ?? null, file, line: lineAt(text, m.index!) }); } for (const m of text.matchAll(/^\s*[A-Za-z0-9_.\-]+\s*=\s*"([A-Za-z0-9_.\-]+):([A-Za-z0-9_.\-]+):([^"]+)"/gm)) out.push({ eco: 'gradle', name: `${m[1]}:${m[2]}`, version: m[3]!, file, line: lineAt(text, m.index!) }); return out; } function packageJson(file: string, text: string): DepRef[] { try { const j = JSON.parse(text) as { dependencies?: Record }; return Object.entries(j.dependencies ?? {}).map(([n, v]) => ({ eco: 'npm' as const, name: n, version: v, file, line: lineOf(text, `"${n}"`) })); } catch { return []; } } /** `s.dependency 'IronSourceSDK', '~> 9.6'` lines of a podspec (a native plugin's own CocoaPods dependencies). */ function podspec(file: string, text: string): DepRef[] { return [...text.matchAll(/^\s*\w+\.dependency\s+['"]([^'"]+)['"](?:\s*,\s*['"]([^'"]+)['"])?/gm)].map(m => ({ eco: 'cocoapods' as const, name: m[1]!, version: m[2] ?? null, file, line: lineAt(text, m.index!) })); } /** `.package(url: "...", from: "1.0")` lines of a Package.swift. */ function packageSwift(file: string, text: string): DepRef[] { return [...text.matchAll(/\.package\(\s*(?:name:\s*"[^"]*"\s*,\s*)?url:\s*"([^"]+)"\s*,?\s*(?:(?:from|exact):\s*)?(?:"([^"]+)")?/g)] .map(m => ({ eco: 'spm' as const, name: m[1]!, version: m[2] ?? null, file, line: lineAt(text, m.index!) })); } /** The native dependencies an npm plugin package brings (its podspec, Package.swift and android/build.gradle), with * paths relative to the scanned root. Capacitor / Cordova / React Native plugins wrap the real SDK this way. */ function pluginNativeDeps(root: string, dir: string): DepRef[] { const out: DepRef[] = []; const rd = (p: string): string => { try { return readFileSync(p, 'utf8'); } catch { return ''; } }; const rel = (p: string) => relative(root, p).split(sep).join('/'); let names: string[] = []; try { names = readdirSync(dir); } catch { return out; } for (const n of names) { if (n.endsWith('.podspec')) out.push(...podspec(rel(join(dir, n)), rd(join(dir, n)))); else if (n === 'Package.swift') out.push(...packageSwift(rel(join(dir, n)), rd(join(dir, n)))); } for (const g of ['android/build.gradle', 'android/build.gradle.kts']) { const t = rd(join(dir, g)); if (t) out.push(...gradle(rel(join(dir, g)), t)); } return out; } /** Built output (bundles, dist / build folders, minified files, the Capacitor webDir): low-trust evidence for what the * app collects; the own sources are preferred and these are only a fallback. */ export function isBuilt(path: string, builtDirs: readonly string[] = []): boolean { return /(^|\/)(bundles?|dist|build|out|www|\.output|\.vite)\//.test(path) || /\.min\.(js|css)$|\.map$|\.(bundle|chunk)\.js$/.test(path) || builtDirs.some(d => path.startsWith(`${d}/`)); } /** grep the own sources first; built output (isBuilt) only when they have no match. `built` marks that fallback. */ export function grepOwn(p: Pick, re: RegExp, max = 5): { hits: Hit[]; built: boolean } { const own = grep(p.sources, re, max, f => !isBuilt(f, p.builtDirs)); if (own.length) return { hits: own, built: false }; const b = grep(p.sources, re, max, f => isBuilt(f, p.builtDirs)); return { hits: b, built: b.length > 0 }; } function pubspec(file: string, text: string): DepRef[] { const sec = /^dependencies:\n([\s\S]*?)(?=^\S|$(?![\s\S]))/m.exec(text); if (!sec) return []; const off = sec.index + 'dependencies:\n'.length; return [...sec[1]!.matchAll(/^ {2}([a-z0-9_]+):\s*([^\n#]*)/gm)].map(m => ({ eco: 'pub' as const, name: m[1]!, version: m[2]!.trim() || null, file, line: lineAt(text, off + m.index!) })); } function pubspecLock(text: string): Map { const out = new Map(); for (const m of text.matchAll(/^ {2}([a-z0-9_]+):\n(?: {4}.*\n)*? {4}version: "([^"]+)"/gm)) out.set(m[1]!, m[2]!); return out; } function unityManifest(file: string, text: string): DepRef[] { try { const j = JSON.parse(text) as { dependencies?: Record }; return Object.entries(j.dependencies ?? {}).map(([n, v]) => ({ eco: 'unity' as const, name: n, version: v, file, line: lineOf(text, `"${n}"`) })); } catch { return []; } } /** External Dependency Manager (Assets/.../Editor/*Dependencies.xml): the pods and Gradle packages a Unity plugin adds. */ function edmXml(file: string, text: string): DepRef[] { const out: DepRef[] = []; for (const m of text.matchAll(/ ({ eco: 'npm' as const, name: m[1]!, version: m[2] ?? null, file, line: lineAt(text, m.index!) })); } // ---- Info.plist key sources other than Info.plist ---- const UNITY_KEYS: Record = { cameraUsageDescription: 'NSCameraUsageDescription', locationUsageDescription: 'NSLocationWhenInUseUsageDescription', microphoneUsageDescription: 'NSMicrophoneUsageDescription' }; const GODOT_KEYS: Record = { 'privacy/camera_usage_description': 'NSCameraUsageDescription', 'privacy/microphone_usage_description': 'NSMicrophoneUsageDescription', 'privacy/photolibrary_usage_description': 'NSPhotoLibraryUsageDescription', }; export function detectProject(root: string): ProjectFacts { const bins: string[] = []; const files = listFiles(root, bins); const cache = new Map(); const read = (f: string): string => { if (!cache.has(f)) { try { cache.set(f, readFileSync(join(root, f), 'utf8')); } catch { cache.set(f, ''); } } return cache.get(f)!; }; const has = (re: RegExp) => files.some(f => re.test(f)); const deps: DepRef[] = []; const plist = new Map(); const plistSources: string[] = []; const entitlements = new Map(); const privacyManifests: Manifest[] = []; const setKey = (m: Map, key: string, value: unknown, at: Loc) => { if (!m.has(key)) m.set(key, { key, value, at }); }; const android: ProjectFacts['androidInfo'] = { manifests: [], permissions: [], services: [], targetSdk: null, targetSdkFrom: null, formFactor: 'phone', removedPermissions: [], components: [], extractNativeLibs: null, mergedManifest: null, accessibilityConfigs: [], strings: [] }; const native: ProjectFacts['native'] = { libs: bins, ndkVersion: null, ndkFrom: null, agp: null, nativeBuild: null, flexiblePageSizes: null, legacyPackaging: null, unityVersion: null }; const sdkHits: { value: number; at: Loc; prio: number }[] = []; let remoteStart: ProjectFacts['remoteStart'] = null; const sources: { path: string; text: string }[] = []; const stacks = new Set(); const builtDirs: string[] = []; let pubLock = new Map(); for (const f of files) { const n = basename(f); const testy = /(^|\/)(Tests?|UITests|[A-Za-z]+Tests|test|androidTest|__tests__|Example|Samples?)\//.test(f); if (n === 'Info.plist' && !testy && !/\.(app|appex)\//.test(f)) { const { value, lines } = parsePlist(read(f)); if (Object.keys(value).length) plistSources.push(f); for (const [k, v] of Object.entries(value)) setKey(plist, k, v, { file: f, line: lines.get(k) ?? 1 }); } else if (n.endsWith('.entitlements')) { const { value, lines } = parsePlist(read(f)); for (const [k, v] of Object.entries(value)) setKey(entitlements, k, v, { file: f, line: lines.get(k) ?? 1 }); } else if (n === 'PrivacyInfo.xcprivacy') { if (!isBuildCopyPath(f)) privacyManifests.push(parseManifest(f, read(f))); } else if (n === 'project.pbxproj') { const t = read(f); deps.push(...pbxproj(f, t)); let found = false; for (const m of t.matchAll(/INFOPLIST_KEY_([A-Za-z]+) = ("(?:[^"\\]|\\.)*"|[^;\n]*);/g)) { found = true; setKey(plist, m[1]!, m[2]!.replace(/^"|"$/g, '').replace(/\\"/g, '"'), { file: f, line: lineAt(t, m.index!) }); } if (found) plistSources.push(f); } else if (n === 'Podfile.lock') deps.push(...podsLock(f, read(f))); else if (n === 'Podfile') deps.push(...podfile(f, read(f))); else if (n === 'Package.resolved') deps.push(...packageResolved(f, read(f))); else if (n === 'Cartfile' || n === 'Cartfile.resolved') deps.push(...cartfile(f, read(f))); else if (/^(build\.gradle(\.kts)?|variables\.gradle|settings\.gradle(\.kts)?)$/.test(n)) { const t = read(f); deps.push(...gradle(f, t)); for (const m of t.matchAll(/\btargetSdk(?:Version)?\s*(?:=|\s)\s*\(?\s*(\d{2})\b/g)) sdkHits.push({ value: Number(m[1]), at: { file: f, line: lineAt(t, m.index!) }, prio: /(^|\/)app\/|variables\.gradle/.test(f) ? 0 : 1 }); if (/targetSdk(?:Version)?\s*(?:=|\s)\s*flutter\.targetSdkVersion/.test(t)) android.targetSdkFrom = 'the Flutter SDK (flutter.targetSdkVersion)'; gradleNative(f, t, native, deps); } else if (n === 'libs.versions.toml') { const t = read(f); deps.push(...versionCatalog(f, t)); for (const m of t.matchAll(/^\s*(?:target-?sdk|targetSdk|android-?target-?sdk)\w*\s*=\s*"(\d{2})"/gim)) sdkHits.push({ value: Number(m[1]), at: { file: f, line: lineAt(t, m.index!) }, prio: 0 }); const nd = /^\s*ndk(?:-?version)?\s*=\s*"(\d+\.[\d.]+)"/im.exec(t); if (nd && !native.ndkVersion) native.ndkVersion = { value: nd[1]!, at: { file: f, line: lineAt(t, nd.index) } }; } else if (n === 'AndroidManifest.xml' && /(^|\/)src\/main\/AndroidManifest\.xml$|^AndroidManifest\.xml$|Plugins\/Android\/AndroidManifest\.xml$/.test(f) && !testy) { android.manifests.push(f); readManifest(f, read(f), android); } else if (/\/res\/xml\/[^/]+\.xml$/.test(f) && read(f).includes(']*/.exec(t)!; android.accessibilityConfigs.push({ at: { file: f, line: lineAt(t, m.index) }, isTool: /android:isAccessibilityTool="true"/.test(m[0]) }); } else if (n === 'strings.xml' && /(^|\/)res\/values[^/]*\/strings\.xml$/.test(f) && !testy) { android.strings.push({ path: f, text: read(f) }); } else if (n === 'CMakeLists.txt' || n === 'Android.mk' || n === 'Application.mk') { const t = read(f); if (!native.nativeBuild) native.nativeBuild = { file: f, line: 1 }; const fl = FLEXIBLE_PAGES.exec(t); if (fl && !native.flexiblePageSizes) native.flexiblePageSizes = { file: f, line: lineAt(t, fl.index) }; } else if (n === 'ProjectVersion.txt' && /(^|\/)ProjectSettings\//.test(f)) { const t = read(f); const m = /^m_EditorVersion:\s*(\S+)/m.exec(t); if (m) native.unityVersion = { value: m[1]!, at: { file: f, line: lineAt(t, m.index) } }; } else if (n === 'package.json' && !f.includes('/node_modules/')) { const t = read(f); const d = packageJson(f, t); deps.push(...d); // the native SDKs behind each plugin, resolved like Node does (node_modules next to this package.json, then up // to the root, then workspace packages): apps/native/node_modules in a monorepo too for (const x of d) { const dir = resolvePackage(x.name, join(root, dirname(f)), root); if (dir) deps.push(...pluginNativeDeps(root, dir)); } const names = new Set(d.map(x => x.name)); if (names.has('react-native')) stacks.add('react-native'); if (names.has('expo')) stacks.add('expo'); if ([...names].some(x => x.startsWith('@capacitor/'))) stacks.add('capacitor'); if (names.has('cordova-android') || names.has('cordova-ios')) stacks.add('cordova'); } else if ((n === 'app.json' || n === 'app.config.json') && !f.includes('/')) { const t = read(f); try { const j = JSON.parse(t) as { expo?: { ios?: { infoPlist?: Record; usesAppleSignIn?: boolean }; android?: { permissions?: string[] }; plugins?: unknown[] } }; if (j.expo) { stacks.add('expo'); plistSources.push(f); for (const [k, v] of Object.entries(j.expo.ios?.infoPlist ?? {})) setKey(plist, k, v, { file: f, line: lineOf(t, `"${k}"`) }); if (j.expo.ios?.usesAppleSignIn) setKey(entitlements, 'com.apple.developer.applesignin', ['Default'], { file: f, line: lineOf(t, '"usesAppleSignIn"') }); for (const p of j.expo.android?.permissions ?? []) android.permissions.push({ name: p.includes('.') ? p : `android.permission.${p}`, at: { file: f, line: lineOf(t, `"${p}"`) } }); for (const p of j.expo.plugins ?? []) { if (Array.isArray(p) && p[0] === 'expo-build-properties') { const ts = (p[1] as { android?: { targetSdkVersion?: number } } | undefined)?.android?.targetSdkVersion; if (ts) sdkHits.push({ value: ts, at: { file: f, line: lineOf(t, '"targetSdkVersion"') }, prio: 0 }); } } if (!android.targetSdkFrom) android.targetSdkFrom = 'the Expo SDK version (expo-build-properties android.targetSdkVersion overrides it)'; } } catch { /* not JSON */ } } else if (/^capacitor\.config\.(json|ts|js)$/.test(n)) { stacks.add('capacitor'); const t = read(f); const wd = /webDir\s*["']?\s*:\s*["']([^"']+)["']/.exec(t)?.[1]; if (wd) builtDirs.push(join(dirname(f), wd).split(sep).join('/').replace(/^\.\//, '').replace(/\/$/, '')); const m = /server\s*["']?\s*:\s*\{[\s\S]*?\burl["']?\s*:\s*["'](https?:\/\/[^"']+)["']/.exec(t); if (m && !/^https?:\/\/(localhost|127\.0\.0\.1|10\.0\.2\.2|192\.168\.)/.test(m[1]!)) remoteStart = { url: m[1]!, at: { file: f, line: lineAt(t, m.index! + m[0].indexOf(m[1]!)) } }; } else if (n === 'config.xml' && / 0) sdkHits.push({ value: Number(ts[1]), at: { file: f, line: lineAt(t, ts.index) }, prio: 0 }); else if (ts) android.targetSdkFrom = 'Unity "Automatic (highest installed)": the Android SDK the build machine has'; } else if (n === 'export_presets.cfg') { stacks.add('godot'); const t = read(f); let any = false; for (const [gk, pk] of Object.entries(GODOT_KEYS)) { const m = new RegExp(`^${gk.replace('/', '\\/')}="(.*)"`, 'm').exec(t); if (m) { any = true; setKey(plist, pk, m[1]!, { file: f, line: lineAt(t, m.index) }); } } if (any) plistSources.push(f); const ts = /^gradle_build\/target_sdk="(\d+)"/m.exec(t); if (ts) sdkHits.push({ value: Number(ts[1]), at: { file: f, line: lineAt(t, ts.index) }, prio: 0 }); for (const m of t.matchAll(/^permissions\/([a-z_]+)=true/gm)) android.permissions.push({ name: `android.permission.${m[1]!.toUpperCase()}`, at: { file: f, line: lineAt(t, m.index!) } }); } // app sources if (SRC_EXT.has(extname(n)) && !testy && !/\.(test|spec)\.[cm]?[jt]sx?$/.test(n) && !/\.min\.js$|\.d\.ts$|(^|\/)(Plugins|ThirdParty|third_party|Packages|addons)\//.test(f) && !/\.config\.(ts|js)$/.test(n)) { const t = read(f); if (t && !t.includes('\u0000')) sources.push({ path: f, text: t }); } } // Unity / Godot plugin folders count as dependencies (a folder under Assets/ or addons/) for (const d of new Set(files.map(f => /^Assets\/([^/]+)\//.exec(f)?.[1]).filter((x): x is string => !!x))) deps.push({ eco: 'unity', name: d, version: null, file: `Assets/${d}/`, line: 1 }); for (const d of new Set(files.map(f => /^addons\/([^/]+)\//.exec(f)?.[1]).filter((x): x is string => !!x))) deps.push({ eco: 'godot', name: d, version: null, file: `addons/${d}/`, line: 1 }); for (const d of deps) if (d.eco === 'pub' && pubLock.has(d.name)) d.version = pubLock.get(d.name)!; if (has(/\.xcodeproj\/project\.pbxproj$/) && !stacks.has('flutter') && !stacks.has('react-native') && !stacks.has('capacitor') && !stacks.has('cordova') && !stacks.has('unity')) stacks.add('ios-native'); if (android.manifests.length && !stacks.has('flutter') && !stacks.has('react-native') && !stacks.has('capacitor') && !stacks.has('cordova') && !stacks.has('unity') && !stacks.has('godot')) stacks.add('android-native'); if (!stacks.size && has(/(^|\/)index\.html$/)) stacks.add('web'); const ios = has(/\.xcodeproj\/project\.pbxproj$/) || plistSources.length > 0 || stacks.has('expo') || stacks.has('unity') || (stacks.has('godot') && /platform="iOS"/.test(read(files.find(f => basename(f) === 'export_presets.cfg') ?? ''))); const isAndroid = android.manifests.length > 0 || has(/(^|\/)build\.gradle(\.kts)?$/) || stacks.has('expo') || stacks.has('unity') || (stacks.has('godot') && /platform="Android"/.test(read(files.find(f => basename(f) === 'export_presets.cfg') ?? ''))); if (sdkHits.length) { sdkHits.sort((a, b) => a.prio - b.prio || a.value - b.value); android.targetSdk = { value: sdkHits[0]!.value, at: sdkHits[0]!.at }; } const sdks: DetectedSdk[] = []; for (const s of SDKS) { const hits = deps.filter(d => matchesSdk(d, s)); if (hits.length) sdks.push({ sdk: s, deps: hits }); } // the Android Gradle Plugin: buildscript classpath, plugins { id(...) version }, or the version catalog const agp = deps.find(d => (d.name === 'com.android.tools.build:gradle' || d.name === 'com.android.application') && d.version && /^\d/.test(d.version)); if (agp) native.agp = { value: agp.version!, at: { file: agp.file, line: agp.line } }; // the merged manifest of a local build, when there is one (libraries add permissions there: AD_ID, location, ...) if (isAndroid) { const merged = findMergedManifest(root, android.manifests, stacks.has('flutter')); let t = ''; if (merged) { try { t = readFileSync(join(root, merged), 'utf8'); } catch { /* unreadable */ } } if (merged && t) { android.mergedManifest = merged; const extra: ProjectFacts['androidInfo'] = { ...android, permissions: [], services: [], components: [], removedPermissions: [], extractNativeLibs: null }; readManifest(merged, t, extra); const removed = new Set(android.removedPermissions.map(x => x.name)); const have = new Set(android.permissions.map(x => x.name)); for (const x of extra.permissions) if (!have.has(x.name) && !removed.has(x.name)) { android.permissions.push(x); have.add(x.name); } const key = (k: string, n: string) => `${k}:${n.split('.').pop()}`; const comps = new Set(android.components.map(x => key(x.kind, x.name))); for (const x of extra.components) if (!comps.has(key(x.kind, x.name))) android.components.push(x); const svc = new Set(android.services.map(x => key('service', x.name))); for (const x of extra.services) if (!svc.has(key('service', x.name))) android.services.push(x); } } return { root, stacks: [...stacks], ios, android: isAndroid, deps, sdks, plist, plistSources, entitlements, privacyManifests, androidInfo: android, native, remoteStart, sources, builtDirs, iosBuild: newestIpaBuild(root), scanned: files.length }; } /** NDK r27 flag and the linker flag of older NDKs that make native libraries 16 KB aligned. */ const FLEXIBLE_PAGES = /ANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES\s*=?\s*ON|max-page-size=16384/; /** Permissions, components and settings of one AndroidManifest.xml, added to `a`. */ function readManifest(f: string, t: string, a: ProjectFacts['androidInfo']): void { for (const m of t.matchAll(/]*?)\/?>/g)) { const name = /android:name="([^"]+)"/.exec(m[1]!)?.[1]; if (!name) continue; const at = { file: f, line: lineAt(t, m.index!) }; if (/tools:node="remove"/.test(m[1]!)) a.removedPermissions.push({ name, at }); else a.permissions.push({ name, at }); } for (const kind of ['activity', 'activity-alias', 'service', 'receiver'] as const) { for (const m of t.matchAll(new RegExp(`<${kind}\\b([^>]*?)(/>|>([\\s\\S]*?))`, 'g'))) { const attrs = m[1]!, body = m[3] ?? ''; if (/tools:node="remove"/.test(attrs)) continue; const c: AndroidComponent = { kind: kind === 'activity-alias' ? 'activity' : kind, name: /android:name="([^"]+)"/.exec(attrs)?.[1] ?? '?', permission: /android:permission="([^"]+)"/.exec(attrs)?.[1] ?? null, fgsType: /android:foregroundServiceType="([^"]+)"/.exec(attrs)?.[1] ?? null, actions: [...body.matchAll(/ x[1]!), categories: [...body.matchAll(/ x[1]!), meta: [...body.matchAll(/ x[1]!), at: { file: f, line: lineAt(t, m.index!) }, }; a.components.push(c); if (c.kind === 'service') a.services.push({ name: c.name, fgsType: c.fgsType, at: c.at }); } } const app = /]*/.exec(t); const ex = app ? /android:extractNativeLibs="(true|false)"/.exec(app[0]) : null; if (app && ex && !a.extractNativeLibs) a.extractNativeLibs = { value: ex[1] === 'true', at: { file: f, line: lineAt(t, app.index + ex.index) } }; if (/android\.hardware\.type\.watch/.test(t)) a.formFactor = 'wear'; else if (/android\.software\.leanback"[^>]*android:required="true"|android:required="true"[^>]*android\.software\.leanback/.test(t)) a.formFactor = 'tv'; else if (/android\.hardware\.type\.automotive/.test(t)) a.formFactor = 'automotive'; } /** NDK version, AGP plugin version, native build and packaging settings in one Gradle file. */ function gradleNative(f: string, t: string, n: ProjectFacts['native'], deps: DepRef[]): void { const nd = /\bndkVersion\s*=?\s*\(?\s*["'](\d+\.[\d.]+)["']/.exec(t); if (nd && !n.ndkVersion) n.ndkVersion = { value: nd[1]!, at: { file: f, line: lineAt(t, nd.index) } }; if (/\bndkVersion\s*=?\s*flutter\.ndkVersion/.test(t)) n.ndkFrom = 'the Flutter SDK (flutter.ndkVersion)'; const ext = /\bexternalNativeBuild\s*\{/.exec(t); if (ext && !n.nativeBuild) n.nativeBuild = { file: f, line: lineAt(t, ext.index) }; const fl = FLEXIBLE_PAGES.exec(t); if (fl && !n.flexiblePageSizes) n.flexiblePageSizes = { file: f, line: lineAt(t, fl.index) }; const lp = /\buseLegacyPackaging\s*(?:=\s*)?true\b/.exec(t); if (lp && !n.legacyPackaging) n.legacyPackaging = { file: f, line: lineAt(t, lp.index) }; for (const m of t.matchAll(/\bid\s*\(?\s*["']com\.android\.(?:application|library)["']\s*\)?\s*version\s*["']([^"']+)["']/g)) deps.push({ eco: 'gradle', name: 'com.android.application', version: m[1]!, file: f, line: lineAt(t, m.index!) }); } /** A merged AndroidManifest.xml of a local build under /build/intermediates (release first), relative to * root; Flutter builds into build/app/ at the project root. null when the project was not built here. */ function findMergedManifest(root: string, manifests: string[], flutter: boolean): string | null { const modules = new Set(manifests.filter(m => m.endsWith('src/main/AndroidManifest.xml')).map(m => `${m.slice(0, -'src/main/AndroidManifest.xml'.length)}build/`)); if (flutter) modules.add('build/app/'); const found: string[] = []; const walk = (dir: string, depth: number) => { if (depth > 4) return; let names: string[]; try { names = readdirSync(join(root, dir)); } catch { return; } for (const n of names.sort()) { if (n === 'AndroidManifest.xml') found.push(`${dir}/${n}`); else if (!n.includes('.')) walk(`${dir}/${n}`, depth + 1); } }; for (const m of modules) for (const sub of ['merged_manifest', 'merged_manifests', 'packaged_manifests']) walk(`${m}intermediates/${sub}`, 0); return found.find(f => /release/i.test(f)) ?? found[0] ?? null; } const COMMENT = /^\s*(\/\/|#|\*|\/\*|