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
@@ -28,6 +28,7 @@ export default defineConfig({
main: {
plugins: [externalizeDepsPlugin({ exclude: workspacePackages })],
build: {
sourcemap: 'hidden',
rollupOptions: {
input: {
bootstrap: resolve(rootDir, 'src/main/bootstrap.ts'),
@@ -49,6 +50,7 @@ export default defineConfig({
preload: {
plugins: [externalizeDepsPlugin({ exclude: workspacePackages })],
build: {
sourcemap: 'hidden',
rollupOptions: {
input: {
bridge: resolve(rootDir, 'src/preload/bridge.ts'),
@@ -79,6 +81,9 @@ export default defineConfig({
],
}),
],
build: {
sourcemap: 'hidden',
},
resolve: {
alias: {
'@renderer': resolve(rootDir, 'src/renderer'),
+7 -3
View File
@@ -13,9 +13,11 @@
"scripts": {
"dev": "electron-vite dev --inspect=9229",
"build": "electron-vite build",
"package:mac": "pnpm run build && electron-builder --mac --arm64,universal",
"package:win": "pnpm run build && electron-builder --win --x64",
"package:linux": "pnpm run build && electron-builder --linux --x64,arm64",
"obfuscate": "node scripts/obfuscate.cjs",
"build:obf": "pnpm run build && pnpm run obfuscate",
"package:mac": "pnpm run build:obf && electron-builder --mac --arm64 --universal",
"package:win": "pnpm run build:obf && electron-builder --win --x64",
"package:linux": "pnpm run build:obf && electron-builder --linux --x64 --arm64",
"sign:setup": "bash scripts/sign-setup.sh",
"sign:mac": "bash scripts/sign-mac.sh",
"test": "vitest run",
@@ -30,6 +32,7 @@
"ws": "^8.20.0"
},
"devDependencies": {
"@ampproject/remapping": "^2.3.0",
"@ant-design/icons-vue": "^7.0.1",
"@geo/http-client": "workspace:*",
"@geo/publisher-platforms": "workspace:*",
@@ -43,6 +46,7 @@
"electron": "41.2.0",
"electron-builder": "^25.0.0",
"electron-vite": "^5.0.0",
"javascript-obfuscator": "^5.4.2",
"typescript": "^5.9.3",
"unplugin-vue-components": "^32.0.0",
"vite": "^7.3.2",
+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()
+546 -6
View File
File diff suppressed because it is too large Load Diff