2026-05-02 17:12:55 +08:00
|
|
|
|
#!/usr/bin/env node
|
|
|
|
|
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
|
|
|
|
'use strict'
|
|
|
|
|
|
|
|
|
|
|
|
const fs = require('node:fs')
|
|
|
|
|
|
const path = require('node:path')
|
|
|
|
|
|
const JavaScriptObfuscator = require('javascript-obfuscator')
|
|
|
|
|
|
const remapping = require('@ampproject/remapping')
|
|
|
|
|
|
|
|
|
|
|
|
const projectRoot = path.resolve(__dirname, '..')
|
|
|
|
|
|
const outDir = path.join(projectRoot, 'out')
|
|
|
|
|
|
|
|
|
|
|
|
const baseOptions = {
|
|
|
|
|
|
compact: true,
|
|
|
|
|
|
controlFlowFlattening: false,
|
|
|
|
|
|
deadCodeInjection: false,
|
|
|
|
|
|
debugProtection: false,
|
|
|
|
|
|
disableConsoleOutput: false,
|
|
|
|
|
|
identifierNamesGenerator: 'mangled-shuffled',
|
|
|
|
|
|
log: false,
|
|
|
|
|
|
numbersToExpressions: true,
|
|
|
|
|
|
renameGlobals: false,
|
|
|
|
|
|
rotateStringArray: true,
|
|
|
|
|
|
selfDefending: false,
|
|
|
|
|
|
shuffleStringArray: true,
|
|
|
|
|
|
simplify: true,
|
|
|
|
|
|
splitStrings: true,
|
|
|
|
|
|
splitStringsChunkLength: 10,
|
|
|
|
|
|
stringArray: true,
|
|
|
|
|
|
stringArrayCallsTransform: true,
|
|
|
|
|
|
stringArrayCallsTransformThreshold: 0.5,
|
|
|
|
|
|
stringArrayEncoding: ['base64'],
|
|
|
|
|
|
stringArrayIndexShift: true,
|
|
|
|
|
|
stringArrayRotate: true,
|
|
|
|
|
|
stringArrayShuffle: true,
|
|
|
|
|
|
stringArrayThreshold: 0.75,
|
|
|
|
|
|
transformObjectKeys: false,
|
|
|
|
|
|
unicodeEscapeSequence: false,
|
|
|
|
|
|
sourceMap: true,
|
|
|
|
|
|
sourceMapMode: 'separate',
|
|
|
|
|
|
sourceMapSourcesMode: 'sources-content',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const targets = [
|
2026-05-09 17:46:37 +08:00
|
|
|
|
// Keep the Electron main process unobfuscated. It owns the Playwright
|
|
|
|
|
|
// adapters, and Playwright serializes `page.evaluate` callbacks with
|
|
|
|
|
|
// Function.prototype.toString(). Obfuscator string-array helpers live in the
|
|
|
|
|
|
// main bundle, not in the target web page, so packaged media publishers such
|
|
|
|
|
|
// as bilibili and dongchedi can fail with "ReferenceError: <helper> is not
|
|
|
|
|
|
// defined" inside the browser context.
|
2026-05-02 17:12:55 +08:00
|
|
|
|
{
|
|
|
|
|
|
label: 'renderer',
|
|
|
|
|
|
dir: path.join(outDir, 'renderer', 'assets'),
|
|
|
|
|
|
match: (name) => name.endsWith('.js'),
|
|
|
|
|
|
options: { ...baseOptions, target: 'browser' },
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
function walk(dir) {
|
|
|
|
|
|
const result = []
|
|
|
|
|
|
if (!fs.existsSync(dir)) return result
|
|
|
|
|
|
const stack = [dir]
|
|
|
|
|
|
while (stack.length > 0) {
|
|
|
|
|
|
const current = stack.pop()
|
|
|
|
|
|
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
|
|
|
|
const full = path.join(current, entry.name)
|
|
|
|
|
|
if (entry.isDirectory()) {
|
|
|
|
|
|
stack.push(full)
|
|
|
|
|
|
} else if (entry.isFile()) {
|
|
|
|
|
|
result.push(full)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatBytes(n) {
|
|
|
|
|
|
if (n < 1024) return `${n}B`
|
|
|
|
|
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`
|
|
|
|
|
|
return `${(n / 1024 / 1024).toFixed(2)}MB`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function obfuscateFile(file, options) {
|
|
|
|
|
|
const original = fs.readFileSync(file, 'utf8')
|
|
|
|
|
|
const beforeBytes = Buffer.byteLength(original, 'utf8')
|
|
|
|
|
|
const inputFileName = path.basename(file)
|
|
|
|
|
|
const sourceMapFileName = `${inputFileName}.map`
|
|
|
|
|
|
const viteMapPath = `${file}.map`
|
|
|
|
|
|
|
|
|
|
|
|
// vite 以 sourcemap: 'hidden' 输出时会把 .map 写在文件旁边,但不在 bundle 末尾加注释。
|
|
|
|
|
|
// 在混淆前把这份 map 读出来,混淆后用 remapping chain 出 obfuscated → ts。
|
|
|
|
|
|
let viteMap = null
|
|
|
|
|
|
if (fs.existsSync(viteMapPath)) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
viteMap = JSON.parse(fs.readFileSync(viteMapPath, 'utf8'))
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
viteMap = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const result = JavaScriptObfuscator.obfuscate(original, {
|
|
|
|
|
|
...options,
|
|
|
|
|
|
inputFileName,
|
|
|
|
|
|
sourceMapFileName,
|
|
|
|
|
|
})
|
|
|
|
|
|
let code = result.getObfuscatedCode()
|
|
|
|
|
|
// javascript-obfuscator 对 sourceMapFileName 做扩展名规整(.cjs → .js),
|
|
|
|
|
|
// 会让注释指向 bootstrap.js.map 而真实文件是 bootstrap.cjs.map — 重写注释修正。
|
|
|
|
|
|
code = code.replace(
|
|
|
|
|
|
/\/\/# sourceMappingURL=[^\n]*$/,
|
|
|
|
|
|
`//# sourceMappingURL=${sourceMapFileName}`,
|
|
|
|
|
|
)
|
|
|
|
|
|
fs.writeFileSync(file, code, 'utf8')
|
|
|
|
|
|
const afterBytes = Buffer.byteLength(code, 'utf8')
|
|
|
|
|
|
|
|
|
|
|
|
let mapBytes = 0
|
|
|
|
|
|
let chained = false
|
|
|
|
|
|
if (options.sourceMap) {
|
|
|
|
|
|
let mapJson = result.getSourceMap()
|
|
|
|
|
|
if (mapJson) {
|
|
|
|
|
|
if (viteMap) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const obfMap = JSON.parse(mapJson)
|
|
|
|
|
|
// remapping 会递归地为 sources 数组里的每一项调 loader。
|
|
|
|
|
|
// 我们只想为 obfMap 自己的 sources 返回 viteMap(chain 一层),
|
|
|
|
|
|
// 对 viteMap 内部的 ts 路径返回 null,否则 loader 会无限递归 → stack overflow。
|
|
|
|
|
|
const obfSources = new Set(obfMap.sources || [])
|
|
|
|
|
|
const chainedMap = remapping(obfMap, (file) =>
|
|
|
|
|
|
obfSources.has(file) ? viteMap : null,
|
|
|
|
|
|
)
|
|
|
|
|
|
mapJson = chainedMap.toString()
|
|
|
|
|
|
chained = true
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.warn(
|
|
|
|
|
|
` ! ${path.relative(projectRoot, file)} chain failed, keeping obfuscator-only map: ${err.message}`,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
fs.writeFileSync(viteMapPath, mapJson, 'utf8')
|
|
|
|
|
|
mapBytes = Buffer.byteLength(mapJson, 'utf8')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return { beforeBytes, afterBytes, mapBytes, chained }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function main() {
|
|
|
|
|
|
if (!fs.existsSync(outDir)) {
|
|
|
|
|
|
console.error(`[obfuscate] missing build output: ${outDir} — run \`pnpm build\` first.`)
|
|
|
|
|
|
process.exit(1)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let totalFiles = 0
|
|
|
|
|
|
let totalBefore = 0
|
|
|
|
|
|
let totalAfter = 0
|
|
|
|
|
|
let totalMap = 0
|
|
|
|
|
|
let totalChained = 0
|
|
|
|
|
|
let failed = 0
|
|
|
|
|
|
|
|
|
|
|
|
for (const target of targets) {
|
|
|
|
|
|
if (!fs.existsSync(target.dir)) {
|
|
|
|
|
|
console.warn(`[obfuscate] skip ${target.label}: directory not found (${target.dir})`)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
const files = walk(target.dir).filter((f) => target.match(path.basename(f)))
|
|
|
|
|
|
if (files.length === 0) {
|
|
|
|
|
|
console.warn(`[obfuscate] skip ${target.label}: no matching files in ${target.dir}`)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.log(`[obfuscate] ${target.label}: ${files.length} file(s)`)
|
|
|
|
|
|
for (const file of files) {
|
|
|
|
|
|
const rel = path.relative(projectRoot, file)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { beforeBytes, afterBytes, mapBytes, chained } = obfuscateFile(file, target.options)
|
|
|
|
|
|
totalFiles += 1
|
|
|
|
|
|
totalBefore += beforeBytes
|
|
|
|
|
|
totalAfter += afterBytes
|
|
|
|
|
|
totalMap += mapBytes
|
|
|
|
|
|
if (chained) totalChained += 1
|
|
|
|
|
|
const ratio = ((afterBytes / beforeBytes) * 100).toFixed(0)
|
|
|
|
|
|
const mapNote = mapBytes > 0 ? ` +map ${formatBytes(mapBytes)}${chained ? ' (chained → ts)' : ''}` : ''
|
|
|
|
|
|
console.log(
|
|
|
|
|
|
` ✓ ${rel} ${formatBytes(beforeBytes)} → ${formatBytes(afterBytes)} (${ratio}%)${mapNote}`,
|
|
|
|
|
|
)
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
failed += 1
|
|
|
|
|
|
console.error(` ✗ ${rel} ${err && err.message ? err.message : err}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const mapSummary = totalMap > 0
|
|
|
|
|
|
? `, sourcemaps ${formatBytes(totalMap)} (${totalChained}/${totalFiles} chained → ts, excluded from package)`
|
|
|
|
|
|
: ''
|
|
|
|
|
|
console.log(
|
|
|
|
|
|
`[obfuscate] done: ${totalFiles} file(s), ${formatBytes(totalBefore)} → ${formatBytes(totalAfter)}${mapSummary}` +
|
|
|
|
|
|
(failed > 0 ? `, ${failed} failed` : ''),
|
|
|
|
|
|
)
|
|
|
|
|
|
if (failed > 0) {
|
|
|
|
|
|
process.exit(1)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
main()
|