feat(desktop-client): obfuscate JS bundles before packaging
Frontend CI / Frontend (push) Successful in 4m56s
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:
@@ -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'),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 返回 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()
|
||||
Generated
+546
-6
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user