feat(desktop-client): obfuscate JS bundles before packaging
Frontend CI / Frontend (push) Successful in 4m56s

Adds a post-build obfuscation step using javascript-obfuscator with
hidden vite sourcemaps chained back to TypeScript via @ampproject/remapping,
so production packages ship mangled code while debug-time maps still
resolve to original sources. Wires obfuscation into all package:* scripts
via a new build:obf step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 17:12:55 +08:00
parent 3ee87fd91e
commit de63d0be5b
4 changed files with 763 additions and 9 deletions
+205
View File
@@ -0,0 +1,205 @@
#!/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 = [
{
label: 'main',
dir: path.join(outDir, 'main'),
match: (name) => name.endsWith('.cjs'),
options: { ...baseOptions, target: 'node' },
},
{
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 返回 viteMapchain 一层),
// 对 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()