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 = '正在读取阅读存档…' 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;此结果不代表包体积或发布验收通过。`) }, }, } }