2026-07-17 13:50:30 +08:00
|
|
|
import { createHash } from 'node:crypto'
|
|
|
|
|
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
|
|
|
import path from 'node:path'
|
|
|
|
|
|
|
|
|
|
const NATIVE_EXTENSIONS = new Set([
|
|
|
|
|
'.c',
|
|
|
|
|
'.cc',
|
|
|
|
|
'.cmake',
|
|
|
|
|
'.cpp',
|
|
|
|
|
'.gradle',
|
|
|
|
|
'.h',
|
|
|
|
|
'.java',
|
|
|
|
|
'.kts',
|
|
|
|
|
'.kt',
|
2026-07-17 14:43:05 +08:00
|
|
|
'.m',
|
|
|
|
|
'.mm',
|
|
|
|
|
'.pbxproj',
|
|
|
|
|
'.podspec',
|
2026-07-17 13:50:30 +08:00
|
|
|
'.properties',
|
2026-07-17 14:43:05 +08:00
|
|
|
'.swift',
|
2026-07-17 13:50:30 +08:00
|
|
|
'.toml',
|
2026-07-17 14:43:05 +08:00
|
|
|
'.xcconfig',
|
2026-07-17 13:50:30 +08:00
|
|
|
'.xml',
|
|
|
|
|
])
|
2026-07-17 14:43:05 +08:00
|
|
|
const PACKAGED_ASSET_EXTENSIONS = new Set([
|
|
|
|
|
'.bmp',
|
|
|
|
|
'.gif',
|
|
|
|
|
'.jpeg',
|
|
|
|
|
'.jpg',
|
|
|
|
|
'.mp3',
|
|
|
|
|
'.mp4',
|
|
|
|
|
'.otf',
|
|
|
|
|
'.png',
|
|
|
|
|
'.svg',
|
|
|
|
|
'.ttf',
|
|
|
|
|
'.webp',
|
|
|
|
|
'.wav',
|
|
|
|
|
])
|
|
|
|
|
const IGNORED_DIRECTORIES = new Set([
|
|
|
|
|
'.cxx',
|
|
|
|
|
'.git',
|
|
|
|
|
'.gradle',
|
|
|
|
|
'build',
|
|
|
|
|
'bundle',
|
|
|
|
|
'coverage',
|
|
|
|
|
'dist',
|
|
|
|
|
'generated',
|
|
|
|
|
'node_modules',
|
|
|
|
|
'rn-bundles',
|
|
|
|
|
])
|
2026-07-17 13:50:30 +08:00
|
|
|
const IGNORED_FILES = new Set(['local.properties'])
|
|
|
|
|
|
2026-07-17 14:43:05 +08:00
|
|
|
function walkFiles(directory, root, extensions, exactNames = new Set()) {
|
2026-07-17 13:50:30 +08:00
|
|
|
if (!existsSync(directory)) return []
|
|
|
|
|
const files = []
|
|
|
|
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
|
|
|
if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) continue
|
|
|
|
|
const absolute = path.join(directory, entry.name)
|
2026-07-17 14:43:05 +08:00
|
|
|
if (entry.isDirectory()) files.push(...walkFiles(absolute, root, extensions, exactNames))
|
2026-07-17 13:50:30 +08:00
|
|
|
else if (
|
|
|
|
|
!IGNORED_FILES.has(entry.name) &&
|
2026-07-17 14:43:05 +08:00
|
|
|
(extensions.has(path.extname(entry.name).toLowerCase()) || exactNames.has(entry.name))
|
2026-07-17 13:50:30 +08:00
|
|
|
) {
|
|
|
|
|
files.push({ absolute, relative: path.relative(root, absolute).split(path.sep).join('/') })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return files
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function packageJson(directory) {
|
|
|
|
|
const file = path.join(directory, 'package.json')
|
|
|
|
|
return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 14:43:05 +08:00
|
|
|
function runtimeDependencyVersions(root, hostPackage) {
|
|
|
|
|
const dependencies = hostPackage.dependencies ?? {}
|
|
|
|
|
const runtime = []
|
2026-07-17 13:50:30 +08:00
|
|
|
for (const name of Object.keys(dependencies).sort()) {
|
|
|
|
|
const directory = path.join(root, 'node_modules', ...name.split('/'))
|
|
|
|
|
const manifest = packageJson(directory)
|
2026-07-17 14:43:05 +08:00
|
|
|
runtime.push(`${name}@${manifest?.version ?? dependencies[name]}`)
|
2026-07-17 13:50:30 +08:00
|
|
|
}
|
2026-07-17 14:43:05 +08:00
|
|
|
return runtime
|
2026-07-17 13:50:30 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Deterministic identity of code and dependencies that can only change through a full APK. */
|
2026-07-17 14:43:05 +08:00
|
|
|
export function computeNativeBaseline(root = process.cwd(), platform = 'android') {
|
|
|
|
|
if (platform !== 'android' && platform !== 'ios') {
|
|
|
|
|
throw new Error('platform must be android or ios')
|
|
|
|
|
}
|
2026-07-17 13:50:30 +08:00
|
|
|
const hostPackage = packageJson(root)
|
|
|
|
|
if (!hostPackage) throw new Error('package.json is missing')
|
|
|
|
|
const hash = createHash('sha256')
|
2026-07-17 14:43:05 +08:00
|
|
|
hash.update(`platform\0${platform}\0`)
|
|
|
|
|
for (const dependency of runtimeDependencyVersions(root, hostPackage)) {
|
2026-07-17 13:50:30 +08:00
|
|
|
hash.update(`dependency\0${dependency}\0`)
|
|
|
|
|
}
|
2026-07-17 14:43:05 +08:00
|
|
|
const nativeFiles = walkFiles(
|
|
|
|
|
path.join(root, platform),
|
|
|
|
|
root,
|
|
|
|
|
new Set([...NATIVE_EXTENSIONS, ...PACKAGED_ASSET_EXTENSIONS]),
|
|
|
|
|
new Set(platform === 'android' ? ['gradlew'] : ['Podfile']),
|
|
|
|
|
)
|
|
|
|
|
const sourceAssets = [path.join(root, 'src'), path.join(root, 'assets')].flatMap(directory =>
|
|
|
|
|
walkFiles(directory, root, PACKAGED_ASSET_EXTENSIONS),
|
|
|
|
|
)
|
|
|
|
|
for (const file of [...nativeFiles, ...sourceAssets].sort((a, b) =>
|
2026-07-17 13:50:30 +08:00
|
|
|
a.relative.localeCompare(b.relative),
|
|
|
|
|
)) {
|
|
|
|
|
hash.update(`file\0${file.relative}\0`)
|
|
|
|
|
hash.update(readFileSync(file.absolute))
|
|
|
|
|
hash.update('\0')
|
|
|
|
|
}
|
2026-07-17 14:43:05 +08:00
|
|
|
return `${platform}-sha256:${hash.digest('hex')}`
|
2026-07-17 13:50:30 +08:00
|
|
|
}
|