377 lines
18 KiB
JavaScript
377 lines
18 KiB
JavaScript
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import crypto from 'node:crypto'
|
|
import { applyCosMediaOutput, DEFAULT_MEDIA_MANIFEST_PATH, isMediaFile, loadCosMediaManifest, SOURCE_MANIFEST_PATH } from './tang-detective-cos-media.mjs'
|
|
|
|
export const NAMESPACE = 'tang-detective'
|
|
const TEXT_EXTENSIONS = new Set(['.js', '.json', '.wxml', '.wxss', '.wxs'])
|
|
const PAGE_WINDOW_KEYS = ['navigationStyle', 'pageOrientation', 'backgroundColor', 'backgroundTextStyle']
|
|
const STATE_FILE = '.native-import-state.json'
|
|
const BOOT_MASK_WXML = '<view wx:if="{{tangBootPending}}" class="tang-boot-mask" catchtap="tangIgnoreBootTap" catchtouchmove="tangIgnoreBootTap">正在读取阅读存档…</view>'
|
|
const BOOT_MASK_WXSS = `
|
|
.tang-boot-mask {
|
|
position: fixed;
|
|
inset: 0;
|
|
top: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
left: 0;
|
|
z-index: 2147483647;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-sizing: border-box;
|
|
padding: 24px;
|
|
background: #201711;
|
|
color: #f3e5bd;
|
|
font-size: 18px;
|
|
text-align: center;
|
|
}
|
|
`
|
|
|
|
export function sha256(value) {
|
|
return crypto.createHash('sha256').update(value).digest('hex')
|
|
}
|
|
|
|
export function listFiles(directory) {
|
|
if (!fs.existsSync(directory)) return []
|
|
const result = []
|
|
function visit(current, prefix = '') {
|
|
for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
const relative = prefix ? `${prefix}/${entry.name}` : entry.name
|
|
if (entry.isSymbolicLink()) throw new Error(`Native import does not follow symbolic links: ${relative}`)
|
|
if (entry.isDirectory()) visit(path.join(current, entry.name), relative)
|
|
else if (entry.isFile()) result.push(relative)
|
|
}
|
|
}
|
|
visit(directory)
|
|
return result
|
|
}
|
|
|
|
function assertNamespace(namespace) {
|
|
if (!/^[a-z][a-z0-9-]*$/.test(namespace)) throw new Error(`Invalid native namespace: ${namespace}`)
|
|
}
|
|
|
|
function outputPath(directory, relative) {
|
|
if (typeof relative !== 'string' || !relative || path.isAbsolute(relative)
|
|
|| relative.includes('\\') || relative.split('/').some(part => !part || part === '.' || part === '..')) {
|
|
throw new Error(`Unsafe native output path: ${relative}`)
|
|
}
|
|
return path.join(directory, relative)
|
|
}
|
|
|
|
function lstatOrNull(filename) {
|
|
try { return fs.lstatSync(filename) } catch (error) {
|
|
if (error.code === 'ENOENT') return null
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/** Ancestors such as macOS /tmp may be aliases, but the supplied output root
|
|
* itself and every component below it must be actual directories/files. */
|
|
export function createNativeOutputGuard(directory) {
|
|
const suppliedRoot = path.resolve(directory)
|
|
const suppliedStat = lstatOrNull(suppliedRoot)
|
|
if (suppliedStat?.isSymbolicLink()) throw new Error(`Native output forbids symbolic links: ${suppliedRoot}`)
|
|
if (!suppliedStat?.isDirectory()) throw new Error(`Native output root must be an existing directory: ${suppliedRoot}`)
|
|
const root = fs.realpathSync(suppliedRoot)
|
|
function check(relative, expectedType = 'file') {
|
|
const rootStat = lstatOrNull(root)
|
|
if (rootStat?.isSymbolicLink()) throw new Error(`Native output forbids symbolic links: ${root}`)
|
|
if (!rootStat?.isDirectory() || fs.realpathSync(root) !== root) throw new Error(`Native output root changed: ${root}`)
|
|
const destination = outputPath(root, relative)
|
|
const parts = relative.split('/')
|
|
let current = root
|
|
for (const [index, part] of parts.entries()) {
|
|
current = path.join(current, part)
|
|
const stat = lstatOrNull(current)
|
|
if (!stat) break
|
|
if (stat.isSymbolicLink()) throw new Error(`Native output forbids symbolic links: ${current}`)
|
|
const real = fs.realpathSync(current)
|
|
const inside = path.relative(root, real)
|
|
if (path.isAbsolute(inside) || inside === '..' || inside.startsWith(`..${path.sep}`)) {
|
|
throw new Error(`Native output escaped its real directory: ${current}`)
|
|
}
|
|
const needsDirectory = index < parts.length - 1 || expectedType === 'directory'
|
|
if (needsDirectory ? !stat.isDirectory() : !stat.isFile()) {
|
|
throw new Error(`Unexpected native output path type: ${current}`)
|
|
}
|
|
}
|
|
return destination
|
|
}
|
|
function write(relative, data) {
|
|
const destination = check(relative)
|
|
fs.mkdirSync(path.dirname(destination), { recursive: true })
|
|
check(relative)
|
|
// Re-check parent components after mkdir and prevent following a replaced
|
|
// final-file link between lstat and opening the destination.
|
|
const descriptor = fs.openSync(destination,
|
|
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW)
|
|
try { fs.writeFileSync(descriptor, data) } finally { fs.closeSync(descriptor) }
|
|
}
|
|
return { root, path: check, write }
|
|
}
|
|
|
|
function jsonBytes(value) {
|
|
return `${JSON.stringify(value, null, 2)}\n`
|
|
}
|
|
|
|
/** Transform only the imported copy. Remote URLs, hashes, and relative requires stay intact. */
|
|
export function transformNativeText(source, relativePath, { namespace = NAMESPACE } = {}) {
|
|
assertNamespace(namespace)
|
|
if (!TEXT_EXTENSIONS.has(path.extname(relativePath))) return source
|
|
return source
|
|
.replace(/(["'`])\/(?=(?:assets|pages|package-[a-z0-9-]+)\/)/g, `$1/${namespace}/`)
|
|
.replace(/(["'`])(?=package-(?:game|chapter-|audio-))/g, `$1${namespace}/`)
|
|
.replaceAll(
|
|
String.raw`/^\/package-[a-z0-9-]+\//i`,
|
|
String.raw`/^\/${namespace}\/package-[a-z0-9-]+\//i`,
|
|
)
|
|
}
|
|
|
|
export function createNativeManifest(sourceApp, { namespace = NAMESPACE } = {}) {
|
|
assertNamespace(namespace)
|
|
const sourcePackages = sourceApp.subPackages || sourceApp.subpackages || []
|
|
const packageNames = new Map(sourcePackages.map(item => [item.name || item.root, `${namespace}-${item.name || item.root}`]))
|
|
const pages = (sourceApp.pages || []).map(item => `${namespace}/${item}`)
|
|
const subPackages = sourcePackages.map(item => ({
|
|
...item,
|
|
root: `${namespace}/${item.root}`,
|
|
name: packageNames.get(item.name || item.root),
|
|
pages: [...item.pages],
|
|
}))
|
|
const preloadRule = Object.fromEntries(Object.entries(sourceApp.preloadRule || {}).map(([page, rule]) => [
|
|
`${namespace}/${page}`,
|
|
{ ...rule, packages: rule.packages.map(name => {
|
|
const mapped = packageNames.get(name)
|
|
|| subPackages.find(item => item.root === `${namespace}/${name}`)?.name
|
|
if (!mapped) throw new Error(`Unregistered native preload package: ${name}`)
|
|
return mapped
|
|
}) },
|
|
]))
|
|
const allPages = [...pages, ...subPackages.flatMap(item => item.pages.map(page => `${item.root}/${page}`))]
|
|
if (new Set(allPages).size !== allPages.length) throw new Error('Duplicate native page route')
|
|
return { namespace, pages, subPackages, preloadRule, allPages }
|
|
}
|
|
|
|
/** Append native routes without replacing the host's app settings or route ordering. */
|
|
export function mergeNativeAppManifest(hostApp, nativeManifest) {
|
|
const result = structuredClone(hostApp)
|
|
const packageKey = Object.hasOwn(hostApp, 'subpackages') && !Object.hasOwn(hostApp, 'subPackages')
|
|
? 'subpackages' : 'subPackages'
|
|
const hostPages = result.pages || []
|
|
const hostPackages = result[packageKey] || []
|
|
const nativePages = new Set(nativeManifest.allPages)
|
|
for (const item of hostPackages) {
|
|
const proposed = nativeManifest.subPackages.find(candidate => candidate.root === item.root)
|
|
if (proposed) {
|
|
if (JSON.stringify(item) !== JSON.stringify(proposed)) throw new Error(`Native subpackage conflicts with host: ${item.root}`)
|
|
continue
|
|
}
|
|
if ((item.pages || []).some(page => nativePages.has(`${item.root}/${page}`))) {
|
|
throw new Error(`Native page conflicts with host subpackage: ${item.root}`)
|
|
}
|
|
if (nativeManifest.subPackages.some(candidate => candidate.name === item.name)) {
|
|
throw new Error(`Native subpackage name conflicts with host: ${item.name}`)
|
|
}
|
|
}
|
|
for (const page of hostPages) {
|
|
if (nativePages.has(page) && !nativeManifest.pages.includes(page)) {
|
|
throw new Error(`Native subpackage page is already a host main page: ${page}`)
|
|
}
|
|
}
|
|
result.pages = [...hostPages, ...nativeManifest.pages.filter(page => !hostPages.includes(page))]
|
|
result[packageKey] = [...hostPackages, ...nativeManifest.subPackages.filter(item => !hostPackages.some(existing => existing.root === item.root))]
|
|
const preloadRule = { ...(result.preloadRule || {}) }
|
|
for (const [page, rule] of Object.entries(nativeManifest.preloadRule)) {
|
|
if (preloadRule[page] && JSON.stringify(preloadRule[page]) !== JSON.stringify(rule)) {
|
|
throw new Error(`Native preload conflicts with host: ${page}`)
|
|
}
|
|
preloadRule[page] = rule
|
|
}
|
|
result.preloadRule = preloadRule
|
|
return result
|
|
}
|
|
|
|
export function wrapNativePage(source, relativePath) {
|
|
const wrapperPath = path.posix.relative(path.posix.dirname(relativePath), 'utils/tangPage.js')
|
|
const topLevelCalls = [...source.matchAll(/^Page\(\{/gm)]
|
|
if (topLevelCalls.length !== 1) throw new Error(`Expected one top-level native Page registration: ${relativePath}`)
|
|
return source.replace(/^Page\(\{/m, `require(${JSON.stringify(wrapperPath)})({`)
|
|
}
|
|
|
|
function collectOutputFiles(sourceDirectory, overlayDirectory, sourceApp, nativeManifest, apiBaseUrl, media) {
|
|
const files = new Map()
|
|
const sourcePages = new Set(nativeManifest.allPages.map(page => page.slice(nativeManifest.namespace.length + 1)))
|
|
const pageDefaults = Object.fromEntries(PAGE_WINDOW_KEYS
|
|
.filter(key => sourceApp.window?.[key] !== undefined)
|
|
.map(key => [key, sourceApp.window[key]]))
|
|
for (const relative of listFiles(sourceDirectory)) {
|
|
if (['app.js', 'app.json', 'sitemap.json'].includes(relative)) continue
|
|
const original = fs.readFileSync(outputPath(sourceDirectory, relative))
|
|
if (relative === 'app.wxss') {
|
|
files.set('shared.wxss', original)
|
|
continue
|
|
}
|
|
const extension = path.extname(relative)
|
|
if (!TEXT_EXTENSIONS.has(extension)) {
|
|
files.set(relative, original)
|
|
continue
|
|
}
|
|
let content = transformNativeText(original.toString('utf8'), relative, nativeManifest)
|
|
const isPage = sourcePages.has(relative.slice(0, -extension.length))
|
|
if (isPage && extension === '.json') content = jsonBytes({ ...pageDefaults, ...JSON.parse(content) })
|
|
if (isPage && extension === '.wxss') {
|
|
content = `@import "/${nativeManifest.namespace}/shared.wxss";\n${content}`
|
|
}
|
|
files.set(relative, Buffer.from(content))
|
|
}
|
|
// Adapters contain final output paths. Do not transform them a second time.
|
|
for (const relative of listFiles(overlayDirectory)) {
|
|
// Remote helpers are activated together with a verified manifest only.
|
|
if (relative === 'utils/cosMedia.js') continue
|
|
if (['app.js', 'app.json', 'app.wxss', 'sitemap.json', STATE_FILE].includes(relative)
|
|
|| /(^|\/)project(?:\.private)?\.config\.json$/.test(relative)) {
|
|
throw new Error(`Adapter cannot replace the host app or import state: ${relative}`)
|
|
}
|
|
if (isMediaFile(relative)) {
|
|
throw new Error(`Adapter cannot replace source media: ${relative}`)
|
|
}
|
|
files.set(relative, fs.readFileSync(outputPath(overlayDirectory, relative)))
|
|
}
|
|
files.set('utils/platformConfig.js', Buffer.from(`module.exports = ${JSON.stringify({ apiBaseUrl })};\n`))
|
|
// Media conversion also covers the adapter's final paths, without repeating
|
|
// namespace conversion or changing the host/account lifecycle adapters.
|
|
applyCosMediaOutput(files, media, nativeManifest)
|
|
if (!files.has('utils/tangPage.js')) throw new Error('Native adapter is missing utils/tangPage.js')
|
|
files.set('shared.wxss', Buffer.from(`${files.get('shared.wxss').toString('utf8')}\n${BOOT_MASK_WXSS}`))
|
|
for (const page of sourcePages) {
|
|
for (const extension of ['.js', '.json', '.wxml', '.wxss']) {
|
|
if (!files.has(`${page}${extension}`)) throw new Error(`Native page file is missing: ${page}${extension}`)
|
|
}
|
|
const pageJson = JSON.parse(files.get(`${page}.json`).toString('utf8'))
|
|
if (pageJson.navigationStyle !== 'custom' || pageJson.pageOrientation !== 'landscape') {
|
|
throw new Error(`Adapter lost native page configuration: ${page}`)
|
|
}
|
|
files.set(`${page}.js`, Buffer.from(wrapNativePage(files.get(`${page}.js`).toString('utf8'), `${page}.js`)))
|
|
files.set(`${page}.wxml`, Buffer.from(`${files.get(`${page}.wxml`).toString('utf8')}\n${BOOT_MASK_WXML}\n`))
|
|
}
|
|
return files
|
|
}
|
|
|
|
export function summarizePackageSizes(files, nativeManifest) {
|
|
const result = { sourceFileBytes: 0, mainPackageBytes: 0, subPackages: {}, mediaFileCount: 0, mediaBytes: 0 }
|
|
for (const [relative, value] of files) {
|
|
const fullPath = `${nativeManifest.namespace}/${relative}`
|
|
const subpackage = nativeManifest.subPackages.find(item => fullPath.startsWith(`${item.root}/`))
|
|
result.sourceFileBytes += value.length
|
|
if (subpackage) result.subPackages[subpackage.root] = (result.subPackages[subpackage.root] || 0) + value.length
|
|
else result.mainPackageBytes += value.length
|
|
if (isMediaFile(relative)) {
|
|
result.mediaFileCount += 1
|
|
result.mediaBytes += value.length
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
/** Reusable by the Vite hook and by static tests; no build or server is started here. */
|
|
export function copyNativeProgram({ sourceDirectory, outputDirectory, overlayDirectory, namespace = NAMESPACE, apiBaseUrl = '', mediaManifest, mediaManifestPath }) {
|
|
assertNamespace(namespace)
|
|
const outputGuard = createNativeOutputGuard(outputDirectory)
|
|
outputDirectory = outputGuard.root
|
|
const media = loadCosMediaManifest({ sourceDirectory, mediaManifest, mediaManifestPath })
|
|
const sourceApp = JSON.parse(fs.readFileSync(path.join(sourceDirectory, 'app.json'), 'utf8'))
|
|
const hostAppPath = outputGuard.path('app.json')
|
|
const hostApp = JSON.parse(fs.readFileSync(hostAppPath, 'utf8'))
|
|
const nativeManifest = createNativeManifest(sourceApp, { namespace })
|
|
const mergedApp = mergeNativeAppManifest(hostApp, nativeManifest)
|
|
const files = collectOutputFiles(sourceDirectory, overlayDirectory, sourceApp, nativeManifest, apiBaseUrl, media)
|
|
const nativeOutputDirectory = outputGuard.path(namespace, 'directory')
|
|
const nativeTarget = relative => {
|
|
// Validate the untrusted old-state relative path before adding namespace.
|
|
const target = outputPath(nativeOutputDirectory, relative)
|
|
return outputGuard.path(path.relative(outputDirectory, target))
|
|
}
|
|
const statePath = nativeTarget(STATE_FILE)
|
|
const previousState = fs.existsSync(statePath) ? JSON.parse(fs.readFileSync(statePath, 'utf8')) : null
|
|
if (previousState && previousState.namespace !== namespace) throw new Error('Native output ownership mismatch')
|
|
const previousFiles = new Map((previousState?.files || []).map(item => [item.path, item.sha256]))
|
|
// Refuse to overwrite files whose ownership or later edits cannot be established.
|
|
for (const [relative, value] of files) {
|
|
const destination = nativeTarget(relative)
|
|
if (!fs.existsSync(destination)) continue
|
|
const existingHash = sha256(fs.readFileSync(destination))
|
|
if (existingHash !== previousFiles.get(relative) && existingHash !== sha256(value)) {
|
|
throw new Error(`Refusing to overwrite unowned native output: ${relative}`)
|
|
}
|
|
}
|
|
const staleFiles = []
|
|
for (const [relative, expectedHash] of previousFiles) {
|
|
if (files.has(relative)) continue
|
|
const stalePath = nativeTarget(relative)
|
|
if (fs.existsSync(stalePath)) {
|
|
if (sha256(fs.readFileSync(stalePath)) !== expectedHash) throw new Error(`Stale native output has external edits: ${relative}`)
|
|
staleFiles.push(relative)
|
|
}
|
|
}
|
|
// Check every stale file before removing any: a conflict late in the media
|
|
// list must not leave a previously usable local build partially stripped.
|
|
for (const relative of staleFiles) fs.unlinkSync(nativeTarget(relative))
|
|
for (const [relative, value] of files) outputGuard.write(`${namespace}/${relative}`, value)
|
|
const state = {
|
|
version: 1,
|
|
namespace,
|
|
pages: nativeManifest.allPages,
|
|
files: [...files].map(([relative, value]) => ({ path: relative, bytes: value.length, sha256: sha256(value) })),
|
|
sizes: summarizePackageSizes(files, nativeManifest),
|
|
...(media ? { media: { mode: 'cos', sourceManifestSha256: media.sourceManifestSha256,
|
|
manifestSha256: media.manifestSha256, sourceMediaFiles: media.entries.size, objectCount: media.objectCount } } : {}),
|
|
validationBoundary: 'Static copy and route integration only; no device, upload, content review, or release approval.',
|
|
}
|
|
outputGuard.write(`${namespace}/${STATE_FILE}`, jsonBytes(state))
|
|
outputGuard.write('app.json', jsonBytes(mergedApp))
|
|
return { ...state, nativeManifest }
|
|
}
|
|
|
|
export default function tangDetectiveNativePlugin(options = {}) {
|
|
let root
|
|
let buildOutput
|
|
const sourceRelative = options.sourceDirectory || 'native/tang-detective'
|
|
const overlayRelative = options.overlayDirectory || 'native-adapter/tang-detective'
|
|
return {
|
|
name: 'tang-detective-native-pages',
|
|
enforce: 'post',
|
|
apply: () => process.env.UNI_PLATFORM === 'mp-weixin',
|
|
configResolved(config) {
|
|
root = config.root
|
|
buildOutput = path.resolve(root, config.build.outDir)
|
|
},
|
|
buildStart() {
|
|
this.addWatchFile(options.mediaManifestPath || DEFAULT_MEDIA_MANIFEST_PATH)
|
|
this.addWatchFile(SOURCE_MANIFEST_PATH)
|
|
for (const directory of [path.resolve(root, sourceRelative), path.resolve(root, overlayRelative)]) {
|
|
this.addWatchFile(directory)
|
|
for (const relative of listFiles(directory)) this.addWatchFile(path.join(directory, relative))
|
|
}
|
|
},
|
|
// Sequential post-order runs after normal write hooks and is repeated for watch rebuilds.
|
|
writeBundle: {
|
|
order: 'post',
|
|
sequential: true,
|
|
handler(outputOptions) {
|
|
const report = copyNativeProgram({
|
|
sourceDirectory: path.resolve(root, sourceRelative),
|
|
overlayDirectory: path.resolve(root, overlayRelative),
|
|
outputDirectory: outputOptions.dir ? path.resolve(root, outputOptions.dir) : buildOutput,
|
|
namespace: options.namespace || NAMESPACE,
|
|
apiBaseUrl: options.apiBaseUrl || '',
|
|
mediaManifest: options.mediaManifest,
|
|
mediaManifestPath: options.mediaManifestPath,
|
|
})
|
|
this.warn(`唐侦探原生页面已合并:${report.pages.length} 页,原生文件 ${report.sizes.sourceFileBytes} bytes,其中主包新增 ${report.sizes.mainPackageBytes} bytes;此结果不代表包体积或发布验收通过。`)
|
|
},
|
|
},
|
|
}
|
|
}
|