Files
xuetang/TUICallKit-Vue3/build/tang-detective-native-plugin.mjs
2026-09-09 14:47:29 +08:00

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;此结果不代表包体积或发布验收通过。`)
},
},
}
}