2405 lines
89 KiB
TypeScript
2405 lines
89 KiB
TypeScript
import { exec, spawn } from 'child_process'
|
||
import { promisify } from 'util'
|
||
import path from 'path'
|
||
import fs from 'fs'
|
||
import Logger from '../utils/Logger'
|
||
import CloudflareShareService from './CloudflareShareService'
|
||
import { platform } from 'os'
|
||
|
||
const execAsync = promisify(exec)
|
||
|
||
/**
|
||
* APK构建服务
|
||
*/
|
||
export default class APKBuildService {
|
||
private logger: Logger
|
||
private cloudflareService: CloudflareShareService
|
||
private isBuilding: boolean = false
|
||
private buildProgress: string = ''
|
||
private buildStatus: BuildStatus = {
|
||
isBuilding: false,
|
||
progress: 0,
|
||
message: '未开始构建',
|
||
success: false
|
||
}
|
||
// 构建日志记录
|
||
private buildLogs: Array<{
|
||
timestamp: number
|
||
level: 'info' | 'warn' | 'error' | 'success'
|
||
message: string
|
||
}> = []
|
||
private readonly MAX_LOG_ENTRIES = 1000 // 最多保存1000条日志
|
||
|
||
constructor() {
|
||
this.logger = new Logger('APKBuildService')
|
||
this.cloudflareService = new CloudflareShareService()
|
||
}
|
||
|
||
/**
|
||
* 添加构建日志
|
||
*/
|
||
private addBuildLog(level: 'info' | 'warn' | 'error' | 'success', message: string): void {
|
||
const logEntry = {
|
||
timestamp: Date.now(),
|
||
level,
|
||
message
|
||
}
|
||
|
||
this.buildLogs.push(logEntry)
|
||
|
||
// 限制日志数量,保留最新的
|
||
if (this.buildLogs.length > this.MAX_LOG_ENTRIES) {
|
||
this.buildLogs = this.buildLogs.slice(-this.MAX_LOG_ENTRIES)
|
||
}
|
||
|
||
// 同时输出到控制台
|
||
switch (level) {
|
||
case 'info':
|
||
this.logger.info(`[构建日志] ${message}`)
|
||
break
|
||
case 'warn':
|
||
this.logger.warn(`[构建日志] ${message}`)
|
||
break
|
||
case 'error':
|
||
this.logger.error(`[构建日志] ${message}`)
|
||
break
|
||
case 'success':
|
||
this.logger.info(`[构建日志] ${message}`)
|
||
break
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取构建日志
|
||
*/
|
||
getBuildLogs(limit?: number): Array<{
|
||
timestamp: number
|
||
level: 'info' | 'warn' | 'error' | 'success'
|
||
message: string
|
||
timeString: string
|
||
}> {
|
||
let logs = [...this.buildLogs]
|
||
|
||
if (limit && limit > 0) {
|
||
logs = logs.slice(-limit)
|
||
}
|
||
|
||
return logs.map(log => ({
|
||
...log,
|
||
timeString: new Date(log.timestamp).toLocaleString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit',
|
||
hour12: false
|
||
})
|
||
}))
|
||
}
|
||
|
||
/**
|
||
* 清空构建日志
|
||
*/
|
||
clearBuildLogs(): void {
|
||
this.buildLogs = []
|
||
this.addBuildLog('info', '构建日志已清空')
|
||
}
|
||
|
||
/**
|
||
* 检查是否有可用的APK
|
||
*/
|
||
async checkExistingAPK(enableEncryption?: boolean, encryptionLevel?: string, customFileName?: string): Promise<{
|
||
exists: boolean
|
||
path?: string
|
||
filename?: string
|
||
size?: number
|
||
buildTime?: Date
|
||
}> {
|
||
try {
|
||
// 查找 build_output 目录(apktool打包的输出目录)
|
||
const buildOutputDir = path.join(process.cwd(), 'android/build_output')
|
||
|
||
if (!fs.existsSync(buildOutputDir)) {
|
||
return { exists: false }
|
||
}
|
||
|
||
// 如果指定了自定义文件名,优先查找
|
||
if (customFileName?.trim()) {
|
||
const customApkName = `${customFileName.trim()}.apk`
|
||
const customApkPath = path.join(buildOutputDir, customApkName)
|
||
if (fs.existsSync(customApkPath)) {
|
||
const stats = fs.statSync(customApkPath)
|
||
this.logger.info(`找到自定义命名的APK文件: ${customApkName}`)
|
||
return {
|
||
exists: true,
|
||
path: customApkPath,
|
||
filename: customApkName,
|
||
size: stats.size,
|
||
buildTime: stats.mtime
|
||
}
|
||
}
|
||
}
|
||
|
||
// 查找所有APK文件
|
||
const files = fs.readdirSync(buildOutputDir)
|
||
const apkFiles = files.filter(f => f.endsWith('.apk'))
|
||
|
||
if (apkFiles.length > 0) {
|
||
// 按修改时间排序,返回最新的
|
||
const apkFilesWithStats = apkFiles.map(f => {
|
||
const apkPath = path.join(buildOutputDir, f)
|
||
return {
|
||
filename: f,
|
||
path: apkPath,
|
||
stats: fs.statSync(apkPath)
|
||
}
|
||
}).sort((a, b) => b.stats.mtime.getTime() - a.stats.mtime.getTime())
|
||
|
||
const latestApk = apkFilesWithStats[0]
|
||
this.logger.info(`找到APK文件: ${latestApk.filename}`)
|
||
return {
|
||
exists: true,
|
||
path: latestApk.path,
|
||
filename: latestApk.filename,
|
||
size: latestApk.stats.size,
|
||
buildTime: latestApk.stats.mtime
|
||
}
|
||
}
|
||
|
||
return { exists: false }
|
||
} catch (error) {
|
||
this.logger.error('检查APK失败:', error)
|
||
return { exists: false }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 构建APK(使用apktool重新打包反编译目录)
|
||
*/
|
||
async buildAPK(serverUrl: string, options?: {
|
||
enableConfigMask?: boolean
|
||
enableProgressBar?: boolean
|
||
configMaskText?: string
|
||
configMaskSubtitle?: string
|
||
configMaskStatus?: string
|
||
enableEncryption?: boolean
|
||
encryptionLevel?: 'basic' | 'standard' | 'enhanced'
|
||
webUrl?: string
|
||
pageStyleConfig?: {
|
||
appName?: string
|
||
statusText?: string
|
||
enableButtonText?: string
|
||
usageInstructions?: string
|
||
apkFileName?: string
|
||
appIconFile?: {
|
||
buffer: Buffer
|
||
originalname: string
|
||
mimetype: string
|
||
}
|
||
}
|
||
}): Promise<BuildResult> {
|
||
if (this.buildStatus.isBuilding) {
|
||
return {
|
||
success: false,
|
||
message: '正在构建中,请稍候...'
|
||
}
|
||
}
|
||
|
||
// 使用setImmediate确保异步执行,避免阻塞
|
||
return new Promise<BuildResult>((resolve, reject) => {
|
||
setImmediate(async () => {
|
||
try {
|
||
await this._buildAPKInternal(serverUrl, options, resolve, reject)
|
||
} catch (error: any) {
|
||
this.logger.error('构建APK内部错误:', error)
|
||
this.addBuildLog('error', `构建过程发生未捕获的错误: ${error.message}`)
|
||
this.addBuildLog('error', `错误堆栈: ${error.stack}`)
|
||
this.buildStatus.isBuilding = false
|
||
reject(error)
|
||
}
|
||
})
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 内部构建方法
|
||
*/
|
||
private async _buildAPKInternal(
|
||
serverUrl: string,
|
||
options: any,
|
||
resolve: (result: BuildResult) => void,
|
||
reject: (error: any) => void
|
||
): Promise<void> {
|
||
try {
|
||
// 清空之前的日志,开始新的构建
|
||
this.buildLogs = []
|
||
|
||
// 记录构建开始
|
||
this.addBuildLog('info', '========== 开始构建APK ==========')
|
||
this.addBuildLog('info', `服务器地址: ${serverUrl}`)
|
||
if (options?.webUrl) {
|
||
this.addBuildLog('info', `Web地址: ${options.webUrl}`)
|
||
}
|
||
this.addBuildLog('info', `配置遮盖: ${options?.enableConfigMask ? '启用' : '禁用'}`)
|
||
this.addBuildLog('info', `进度条: ${options?.enableProgressBar ? '启用' : '禁用'}`)
|
||
if (options?.pageStyleConfig?.appName) {
|
||
this.addBuildLog('info', `应用名称: ${options.pageStyleConfig.appName}`)
|
||
}
|
||
if (options?.pageStyleConfig?.apkFileName) {
|
||
this.addBuildLog('info', `APK文件名: ${options.pageStyleConfig.apkFileName}`)
|
||
}
|
||
|
||
this.buildStatus = {
|
||
isBuilding: true,
|
||
progress: 0,
|
||
message: '开始构建APK...',
|
||
success: false
|
||
}
|
||
|
||
this.addBuildLog('info', '开始构建APK...')
|
||
|
||
// 检查构建环境(只需要Java,不需要Gradle和Android项目)
|
||
this.addBuildLog('info', '检查构建环境...')
|
||
const envCheck = await this.checkBuildEnvironment()
|
||
if (!envCheck.hasJava) {
|
||
this.addBuildLog('error', '构建环境检查失败: Java未安装或未在PATH中')
|
||
this.buildStatus.isBuilding = false
|
||
resolve({
|
||
success: false,
|
||
message: '构建环境不完整,请检查 Java 环境'
|
||
})
|
||
return
|
||
}
|
||
this.addBuildLog('success', `Java环境检查通过: ${envCheck.javaVersion || '已安装'}`)
|
||
|
||
// 检查apktool和source.apk
|
||
const apktoolPath = path.join(process.cwd(), 'android/apktool.jar')
|
||
const sourceApkFile = path.join(process.cwd(), 'android/source.apk')
|
||
const sourceApkPath = path.join(process.cwd(), 'android/source_apk')
|
||
|
||
if (!fs.existsSync(apktoolPath)) {
|
||
this.addBuildLog('error', 'apktool不存在: android/apktool.jar')
|
||
this.buildStatus.isBuilding = false
|
||
resolve({
|
||
success: false,
|
||
message: 'apktool 不存在: android/apktool.jar'
|
||
})
|
||
return
|
||
}
|
||
this.addBuildLog('success', 'apktool检查通过')
|
||
|
||
if (!fs.existsSync(sourceApkFile)) {
|
||
this.addBuildLog('error', 'source.apk文件不存在: android/source.apk')
|
||
this.buildStatus.isBuilding = false
|
||
resolve({
|
||
success: false,
|
||
message: 'source.apk 文件不存在: android/source.apk'
|
||
})
|
||
return
|
||
}
|
||
this.addBuildLog('success', 'source.apk文件检查通过')
|
||
|
||
// 清理并反编译source.apk
|
||
this.buildStatus.progress = 5
|
||
this.buildStatus.message = '清理并反编译source.apk...'
|
||
this.addBuildLog('info', '开始清理并反编译source.apk...')
|
||
|
||
// 删除source_apk目录(如果存在)
|
||
if (fs.existsSync(sourceApkPath)) {
|
||
this.addBuildLog('info', '删除旧的source_apk目录...')
|
||
await this.deleteDirectoryWithRetry(sourceApkPath, 3)
|
||
this.addBuildLog('success', '旧的source_apk目录已删除')
|
||
}
|
||
|
||
// 反编译source.apk到source_apk目录
|
||
this.addBuildLog('info', '开始反编译source.apk...')
|
||
const decompileResult = await this.decompileAPK(sourceApkFile, sourceApkPath, apktoolPath)
|
||
|
||
if (!decompileResult.success) {
|
||
this.addBuildLog('error', `反编译失败: ${decompileResult.message}`)
|
||
this.buildStatus.isBuilding = false
|
||
resolve({
|
||
success: false,
|
||
message: `反编译失败: ${decompileResult.message}`
|
||
})
|
||
return
|
||
}
|
||
|
||
this.addBuildLog('success', 'source.apk反编译完成')
|
||
|
||
this.buildStatus.progress = 10
|
||
this.buildStatus.message = '更新服务器配置...'
|
||
this.addBuildLog('info', '更新服务器配置...')
|
||
|
||
// 更新反编译目录中的服务器配置
|
||
await this.writeServerConfigToSourceApk(sourceApkPath, serverUrl, options)
|
||
this.addBuildLog('success', '服务器配置更新完成')
|
||
|
||
this.buildStatus.progress = 20
|
||
this.buildStatus.message = '处理应用图标...'
|
||
|
||
// 处理应用图标(如果有上传)
|
||
if (options?.pageStyleConfig?.appIconFile) {
|
||
this.addBuildLog('info', '处理应用图标...')
|
||
await this.updateAppIconInSourceApk(sourceApkPath, options.pageStyleConfig.appIconFile)
|
||
this.addBuildLog('success', '应用图标更新完成')
|
||
} else {
|
||
this.addBuildLog('info', '未上传应用图标,跳过图标更新')
|
||
}
|
||
|
||
this.buildStatus.progress = 30
|
||
this.buildStatus.message = '更新应用名称...'
|
||
|
||
// 更新应用名称(如果有配置)
|
||
if (options?.pageStyleConfig?.appName) {
|
||
this.addBuildLog('info', `更新应用名称为: ${options.pageStyleConfig.appName}`)
|
||
await this.updateAppNameInSourceApk(sourceApkPath, options.pageStyleConfig.appName)
|
||
this.addBuildLog('success', '应用名称更新完成')
|
||
} else {
|
||
this.addBuildLog('info', '未配置应用名称,跳过名称更新')
|
||
}
|
||
|
||
this.buildStatus.progress = 40
|
||
this.buildStatus.message = '更新页面样式配置...'
|
||
|
||
// 更新页面样式配置
|
||
if (options?.pageStyleConfig) {
|
||
this.addBuildLog('info', '更新页面样式配置...')
|
||
await this.updatePageStyleConfigInSourceApk(sourceApkPath, options.pageStyleConfig)
|
||
this.addBuildLog('success', '页面样式配置更新完成')
|
||
}
|
||
|
||
this.buildStatus.progress = 45
|
||
this.buildStatus.message = '生成随机包名...'
|
||
this.addBuildLog('info', '生成随机包名...')
|
||
|
||
// 生成随机包名并修改
|
||
const randomPackageName = this.generateRandomPackageName()
|
||
this.addBuildLog('info', `随机包名: ${randomPackageName}`)
|
||
await this.changePackageName(sourceApkPath, 'com.hikoncont', randomPackageName)
|
||
this.addBuildLog('success', `包名已修改为: ${randomPackageName}`)
|
||
|
||
this.buildStatus.progress = 47
|
||
this.buildStatus.message = '生成随机版本号...'
|
||
this.addBuildLog('info', '生成随机版本号...')
|
||
|
||
// 生成随机版本号并修改
|
||
const randomVersion = this.generateRandomVersion()
|
||
this.addBuildLog('info', `随机版本号: versionCode=${randomVersion.versionCode}, versionName=${randomVersion.versionName}`)
|
||
await this.changeVersion(sourceApkPath, randomVersion.versionCode, randomVersion.versionName)
|
||
this.addBuildLog('success', `版本号已修改为: ${randomVersion.versionName} (${randomVersion.versionCode})`)
|
||
|
||
this.buildStatus.progress = 50
|
||
this.buildStatus.message = '使用apktool重新打包APK...'
|
||
this.addBuildLog('info', '开始使用apktool重新打包APK...')
|
||
|
||
// 使用apktool重新打包
|
||
const buildResult = await this.rebuildAPKWithApktool(sourceApkPath, apktoolPath, options?.pageStyleConfig?.apkFileName)
|
||
|
||
if (buildResult.success) {
|
||
this.addBuildLog('success', `APK打包成功: ${buildResult.filename}`)
|
||
this.buildStatus.progress = 80
|
||
this.buildStatus.message = '签名APK...'
|
||
this.addBuildLog('info', '开始签名APK...')
|
||
|
||
// 签名APK
|
||
const signedApkPath = await this.signAPK(buildResult.apkPath!, buildResult.filename!)
|
||
|
||
if (!signedApkPath) {
|
||
this.addBuildLog('error', 'APK签名失败')
|
||
this.buildStatus.isBuilding = false
|
||
resolve({
|
||
success: false,
|
||
message: 'APK签名失败'
|
||
})
|
||
return
|
||
}
|
||
|
||
this.buildStatus.progress = 90
|
||
this.buildStatus.message = '生成分享链接...'
|
||
this.addBuildLog('info', '生成分享链接...')
|
||
|
||
// 自动生成Cloudflare分享链接
|
||
const apkPath = signedApkPath
|
||
const filename = buildResult.filename!
|
||
|
||
const shareResult = await this.cloudflareService.createShareLink(
|
||
apkPath,
|
||
filename,
|
||
10 // 10分钟有效期
|
||
)
|
||
|
||
this.buildStatus.progress = 100
|
||
|
||
if (shareResult.success) {
|
||
this.addBuildLog('success', `分享链接生成成功: ${shareResult.shareUrl}`)
|
||
this.addBuildLog('success', '========== 构建完成 ==========')
|
||
this.buildStatus.message = `构建完成!分享链接已生成,有效期10分钟`
|
||
this.buildStatus.success = true
|
||
this.buildStatus.shareUrl = shareResult.shareUrl
|
||
this.buildStatus.shareSessionId = shareResult.sessionId
|
||
this.buildStatus.shareExpiresAt = shareResult.expiresAt
|
||
|
||
resolve({
|
||
success: true,
|
||
message: '构建完成并生成分享链接',
|
||
filename,
|
||
shareUrl: shareResult.shareUrl,
|
||
shareExpiresAt: shareResult.expiresAt,
|
||
sessionId: shareResult.sessionId
|
||
})
|
||
} else {
|
||
this.addBuildLog('warn', `分享链接生成失败: ${shareResult.error}`)
|
||
this.addBuildLog('success', '========== 构建完成(分享链接生成失败)==========')
|
||
this.buildStatus.message = `构建完成,但生成分享链接失败: ${shareResult.error}`
|
||
this.buildStatus.success = true
|
||
|
||
resolve({
|
||
success: true,
|
||
message: '构建完成,但分享链接生成失败',
|
||
filename,
|
||
shareError: shareResult.error
|
||
})
|
||
}
|
||
} else {
|
||
this.addBuildLog('error', `APK打包失败: ${buildResult.message}`)
|
||
this.addBuildLog('error', '========== 构建失败 ==========')
|
||
this.buildStatus.isBuilding = false
|
||
resolve(buildResult)
|
||
}
|
||
} catch (error: any) {
|
||
this.addBuildLog('error', `构建过程发生异常: ${error.message}`)
|
||
this.addBuildLog('error', `[DEBUG] 错误堆栈: ${error.stack}`)
|
||
this.addBuildLog('error', '========== 构建失败 ==========')
|
||
this.logger.error('构建APK失败:', error)
|
||
this.logger.error('错误堆栈:', error.stack)
|
||
this.buildStatus = {
|
||
isBuilding: false,
|
||
progress: 0,
|
||
message: `构建失败: ${error.message}`,
|
||
success: false
|
||
}
|
||
reject({
|
||
success: false,
|
||
message: error.message
|
||
})
|
||
} finally {
|
||
this.buildStatus.isBuilding = false
|
||
}
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* 获取构建状态(增强版)
|
||
*/
|
||
getBuildStatus(): EnhancedBuildStatus {
|
||
return {
|
||
...this.buildStatus,
|
||
activeShares: this.cloudflareService.getActiveShares()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 停止分享链接
|
||
*/
|
||
async stopShare(sessionId: string): Promise<boolean> {
|
||
return await this.cloudflareService.stopShare(sessionId)
|
||
}
|
||
|
||
/**
|
||
* 获取活动分享链接
|
||
*/
|
||
getActiveShares(): Array<{
|
||
sessionId: string
|
||
filename: string
|
||
shareUrl: string
|
||
createdAt: string
|
||
expiresAt: string
|
||
isExpired: boolean
|
||
}> {
|
||
return this.cloudflareService.getActiveShares()
|
||
}
|
||
|
||
|
||
/**
|
||
* 获取APK文件信息用于下载
|
||
*/
|
||
async getAPKForDownload(): Promise<{
|
||
success: boolean
|
||
filePath?: string
|
||
filename?: string
|
||
size?: number
|
||
error?: string
|
||
}> {
|
||
try {
|
||
const apkResult = await this.checkExistingAPK()
|
||
|
||
if (!apkResult.exists) {
|
||
return {
|
||
success: false,
|
||
error: '没有可用的APK文件,请先构建'
|
||
}
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
filePath: apkResult.path,
|
||
filename: apkResult.filename,
|
||
size: apkResult.size
|
||
}
|
||
} catch (error: any) {
|
||
this.logger.error('获取APK文件失败:', error)
|
||
return {
|
||
success: false,
|
||
error: error.message
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
/**
|
||
* 写入服务器配置到反编译目录
|
||
*/
|
||
private async writeServerConfigToSourceApk(sourceApkPath: string, serverUrl: string, options?: {
|
||
enableConfigMask?: boolean
|
||
enableProgressBar?: boolean
|
||
configMaskText?: string
|
||
configMaskSubtitle?: string
|
||
configMaskStatus?: string
|
||
webUrl?: string
|
||
pageStyleConfig?: {
|
||
appName?: string
|
||
statusText?: string
|
||
enableButtonText?: string
|
||
usageInstructions?: string
|
||
apkFileName?: string
|
||
appIconFile?: {
|
||
buffer: Buffer
|
||
originalname: string
|
||
mimetype: string
|
||
}
|
||
}
|
||
}): Promise<void> {
|
||
try {
|
||
// 配置文件路径
|
||
const configFile = path.join(sourceApkPath, 'assets/server_config.json')
|
||
|
||
// 确保assets目录存在
|
||
const assetsDir = path.dirname(configFile)
|
||
if (!fs.existsSync(assetsDir)) {
|
||
fs.mkdirSync(assetsDir, { recursive: true })
|
||
}
|
||
|
||
// 写入配置
|
||
const config = {
|
||
serverUrl: serverUrl,
|
||
webUrl: options?.webUrl || '',
|
||
buildTime: new Date().toISOString(),
|
||
version: '1.0.0',
|
||
enableConfigMask: options?.enableConfigMask ?? true,
|
||
enableProgressBar: options?.enableProgressBar ?? true,
|
||
configMaskText: options?.configMaskText ?? '配置中请稍后...',
|
||
configMaskSubtitle: options?.configMaskSubtitle ?? '正在自动配置和连接\n请勿操作设备',
|
||
configMaskStatus: options?.configMaskStatus ?? '配置完成后将自动返回应用',
|
||
pageStyleConfig: options?.pageStyleConfig || {}
|
||
}
|
||
|
||
this.logger.info('页面样式配置详情:', JSON.stringify(options?.pageStyleConfig, null, 2))
|
||
|
||
fs.writeFileSync(configFile, JSON.stringify(config, null, 2))
|
||
this.logger.info(`服务器配置已写入: ${configFile}`)
|
||
|
||
} catch (error) {
|
||
this.logger.error('写入服务器配置失败:', error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 更新反编译目录中的应用图标
|
||
*/
|
||
private async updateAppIconInSourceApk(sourceApkPath: string, iconFile: {
|
||
buffer: Buffer
|
||
originalname: string
|
||
mimetype: string
|
||
}): Promise<void> {
|
||
try {
|
||
this.logger.info('开始更新应用图标:', iconFile.originalname)
|
||
|
||
// 验证图标文件
|
||
if (!iconFile.buffer || iconFile.buffer.length === 0) {
|
||
throw new Error('图标文件为空')
|
||
}
|
||
|
||
// 检查文件格式
|
||
const pngSignature = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
|
||
const jpegSignature = Buffer.from([0xFF, 0xD8, 0xFF])
|
||
const fileHeader = iconFile.buffer.subarray(0, 8)
|
||
|
||
const isPngByHeader = fileHeader.equals(pngSignature)
|
||
const isJpegByHeader = fileHeader.subarray(0, 3).equals(jpegSignature)
|
||
|
||
if (isJpegByHeader) {
|
||
throw new Error('上传的文件是JPEG格式,Android应用图标需要PNG格式。请转换后重新上传。')
|
||
}
|
||
|
||
if (!isPngByHeader) {
|
||
throw new Error('图标文件格式不正确,请上传PNG格式的图片文件。')
|
||
}
|
||
|
||
this.logger.info('图标文件验证通过,开始更新...')
|
||
|
||
// Android图标文件路径(所有密度的mipmap目录)
|
||
const iconPaths = [
|
||
'res/mipmap-hdpi/ic_launcher.png',
|
||
'res/mipmap-mdpi/ic_launcher.png',
|
||
'res/mipmap-xhdpi/ic_launcher.png',
|
||
'res/mipmap-xxhdpi/ic_launcher.png',
|
||
'res/mipmap-xxxhdpi/ic_launcher.png'
|
||
]
|
||
|
||
// 对所有密度的图标文件进行替换
|
||
for (const iconPath of iconPaths) {
|
||
try {
|
||
const fullPath = path.join(sourceApkPath, iconPath)
|
||
const dir = path.dirname(fullPath)
|
||
|
||
// 确保目录存在
|
||
if (!fs.existsSync(dir)) {
|
||
fs.mkdirSync(dir, { recursive: true })
|
||
}
|
||
|
||
// 写入图标文件
|
||
fs.writeFileSync(fullPath, iconFile.buffer)
|
||
this.logger.info(` 已更新图标: ${iconPath}`)
|
||
} catch (error) {
|
||
this.logger.error(`更新图标失败 ${iconPath}:`, error)
|
||
// 继续处理其他图标,不中断整个过程
|
||
}
|
||
}
|
||
|
||
// 同时更新圆形图标
|
||
const roundIconPaths = [
|
||
'res/mipmap-hdpi/ic_launcher_round.png',
|
||
'res/mipmap-mdpi/ic_launcher_round.png',
|
||
'res/mipmap-xhdpi/ic_launcher_round.png',
|
||
'res/mipmap-xxhdpi/ic_launcher_round.png',
|
||
'res/mipmap-xxxhdpi/ic_launcher_round.png'
|
||
]
|
||
|
||
for (const iconPath of roundIconPaths) {
|
||
try {
|
||
const fullPath = path.join(sourceApkPath, iconPath)
|
||
const dir = path.dirname(fullPath)
|
||
|
||
if (!fs.existsSync(dir)) {
|
||
fs.mkdirSync(dir, { recursive: true })
|
||
}
|
||
|
||
fs.writeFileSync(fullPath, iconFile.buffer)
|
||
this.logger.info(` 已更新圆形图标: ${iconPath}`)
|
||
} catch (error) {
|
||
this.logger.error(`更新圆形图标失败 ${iconPath}:`, error)
|
||
// 继续处理其他图标,不中断整个过程
|
||
}
|
||
}
|
||
|
||
this.logger.info(' 应用图标更新完成')
|
||
|
||
} catch (error) {
|
||
this.logger.error('更新应用图标失败:', error)
|
||
throw new Error(`更新应用图标失败: ${error}`)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 更新反编译目录中的应用名称
|
||
*/
|
||
private async updateAppNameInSourceApk(sourceApkPath: string, appName: string): Promise<void> {
|
||
try {
|
||
const stringsPath = path.join(sourceApkPath, 'res/values/strings.xml')
|
||
|
||
if (!fs.existsSync(stringsPath)) {
|
||
this.logger.warn('strings.xml文件不存在,跳过应用名称更新')
|
||
return
|
||
}
|
||
|
||
// 读取现有的strings.xml
|
||
let content = fs.readFileSync(stringsPath, 'utf8')
|
||
|
||
// 更新应用名称
|
||
if (content.includes('name="app_name"')) {
|
||
content = content.replace(
|
||
/<string name="app_name">.*?<\/string>/,
|
||
`<string name="app_name">${appName}</string>`
|
||
)
|
||
} else {
|
||
// 如果不存在,添加到resources标签内
|
||
content = content.replace(
|
||
'</resources>',
|
||
` <string name="app_name">${appName}</string>\n</resources>`
|
||
)
|
||
}
|
||
|
||
fs.writeFileSync(stringsPath, content)
|
||
this.logger.info(`应用名称已更新为: ${appName}`)
|
||
|
||
} catch (error) {
|
||
this.logger.error('更新应用名称失败:', error)
|
||
// 不抛出错误,因为这不是关键步骤
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 更新反编译目录中的页面样式配置
|
||
*/
|
||
private async updatePageStyleConfigInSourceApk(sourceApkPath: string, config: {
|
||
appName?: string
|
||
statusText?: string
|
||
enableButtonText?: string
|
||
usageInstructions?: string
|
||
}): Promise<void> {
|
||
try {
|
||
const stringsPath = path.join(sourceApkPath, 'res/values/strings.xml')
|
||
|
||
if (!fs.existsSync(stringsPath)) {
|
||
this.logger.warn('strings.xml文件不存在,跳过页面样式配置更新')
|
||
return
|
||
}
|
||
|
||
// 读取现有的strings.xml
|
||
let content = fs.readFileSync(stringsPath, 'utf8')
|
||
|
||
// 更新状态文本
|
||
if (config.statusText) {
|
||
const escapedText = config.statusText.replace(/\n/g, '\\n').replace(/"/g, '\\"')
|
||
if (content.includes('name="service_status_checking"')) {
|
||
content = content.replace(
|
||
/<string name="service_status_checking">.*?<\/string>/,
|
||
`<string name="service_status_checking">${escapedText}</string>`
|
||
)
|
||
} else {
|
||
content = content.replace(
|
||
'</resources>',
|
||
` <string name="service_status_checking">${escapedText}</string>\n</resources>`
|
||
)
|
||
}
|
||
}
|
||
|
||
// 更新启用按钮文字
|
||
if (config.enableButtonText) {
|
||
if (content.includes('name="enable_accessibility_service"')) {
|
||
content = content.replace(
|
||
/<string name="enable_accessibility_service">.*?<\/string>/,
|
||
`<string name="enable_accessibility_service">${config.enableButtonText}</string>`
|
||
)
|
||
} else {
|
||
content = content.replace(
|
||
'</resources>',
|
||
` <string name="enable_accessibility_service">${config.enableButtonText}</string>\n</resources>`
|
||
)
|
||
}
|
||
}
|
||
|
||
// 更新使用说明
|
||
if (config.usageInstructions) {
|
||
const escapedInstructions = config.usageInstructions.replace(/\n/g, '\\n').replace(/"/g, '\\"')
|
||
if (content.includes('name="usage_instructions"')) {
|
||
content = content.replace(
|
||
/<string name="usage_instructions">.*?<\/string>/s,
|
||
`<string name="usage_instructions">${escapedInstructions}</string>`
|
||
)
|
||
} else {
|
||
content = content.replace(
|
||
'</resources>',
|
||
` <string name="usage_instructions">${escapedInstructions}</string>\n</resources>`
|
||
)
|
||
}
|
||
}
|
||
|
||
fs.writeFileSync(stringsPath, content)
|
||
this.logger.info('页面样式配置已更新到strings.xml')
|
||
|
||
} catch (error) {
|
||
this.logger.error('更新页面样式配置失败:', error)
|
||
// 不抛出错误,因为这不是关键步骤
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 使用apktool重新打包APK
|
||
*/
|
||
private async rebuildAPKWithApktool(sourceApkPath: string, apktoolPath: string, customFileName?: string): Promise<{
|
||
success: boolean
|
||
message: string
|
||
apkPath?: string
|
||
filename?: string
|
||
}> {
|
||
try {
|
||
this.buildStatus.progress = 50
|
||
this.buildStatus.message = '使用apktool重新打包APK...'
|
||
this.addBuildLog('info', '准备输出目录...')
|
||
|
||
// 确定输出APK的目录和文件名
|
||
const outputDir = path.join(process.cwd(), 'android/build_output')
|
||
this.addBuildLog('info', `[DEBUG] 输出目录路径: ${outputDir}`)
|
||
this.addBuildLog('info', `[DEBUG] 输出目录是否存在: ${fs.existsSync(outputDir)}`)
|
||
|
||
if (!fs.existsSync(outputDir)) {
|
||
this.addBuildLog('info', '[DEBUG] 创建输出目录...')
|
||
fs.mkdirSync(outputDir, { recursive: true })
|
||
this.addBuildLog('info', '创建输出目录: android/build_output')
|
||
this.addBuildLog('info', `[DEBUG] 目录创建后是否存在: ${fs.existsSync(outputDir)}`)
|
||
} else {
|
||
this.addBuildLog('info', '[DEBUG] 输出目录已存在')
|
||
}
|
||
|
||
const apkFileName = customFileName?.trim() ? `${customFileName.trim()}.apk` : 'app.apk'
|
||
const outputApkPath = path.join(outputDir, apkFileName)
|
||
this.addBuildLog('info', `输出APK文件: ${apkFileName}`)
|
||
this.addBuildLog('info', `[DEBUG] 完整输出路径: ${outputApkPath}`)
|
||
|
||
// 删除旧的APK文件(如果存在)
|
||
if (fs.existsSync(outputApkPath)) {
|
||
const oldSize = fs.statSync(outputApkPath).size
|
||
this.addBuildLog('info', `[DEBUG] 发现旧APK文件,大小: ${(oldSize / 1024 / 1024).toFixed(2)} MB`)
|
||
fs.unlinkSync(outputApkPath)
|
||
this.addBuildLog('info', `已删除旧的APK文件: ${apkFileName}`)
|
||
this.addBuildLog('info', `[DEBUG] 删除后文件是否存在: ${fs.existsSync(outputApkPath)}`)
|
||
} else {
|
||
this.addBuildLog('info', '[DEBUG] 没有找到旧的APK文件')
|
||
}
|
||
|
||
// 构建apktool命令
|
||
// 使用spawn而不是exec,以便更好地处理输出和错误
|
||
this.addBuildLog('info', `执行apktool命令: java -jar apktool.jar b source_apk -o ${apkFileName}`)
|
||
this.addBuildLog('info', `完整命令路径: ${apktoolPath}`)
|
||
this.addBuildLog('info', `源目录: ${sourceApkPath}`)
|
||
this.addBuildLog('info', `输出路径: ${outputApkPath}`)
|
||
|
||
// 验证路径是否存在
|
||
this.addBuildLog('info', `[DEBUG] 检查apktool路径: ${apktoolPath}`)
|
||
this.addBuildLog('info', `[DEBUG] apktool文件是否存在: ${fs.existsSync(apktoolPath)}`)
|
||
if (fs.existsSync(apktoolPath)) {
|
||
const apktoolStats = fs.statSync(apktoolPath)
|
||
this.addBuildLog('info', `[DEBUG] apktool文件大小: ${(apktoolStats.size / 1024 / 1024).toFixed(2)} MB`)
|
||
}
|
||
|
||
if (!fs.existsSync(apktoolPath)) {
|
||
this.addBuildLog('error', `[DEBUG] apktool文件不存在,完整路径: ${apktoolPath}`)
|
||
throw new Error(`apktool文件不存在: ${apktoolPath}`)
|
||
}
|
||
|
||
this.addBuildLog('info', `[DEBUG] 检查源目录路径: ${sourceApkPath}`)
|
||
this.addBuildLog('info', `[DEBUG] 源目录是否存在: ${fs.existsSync(sourceApkPath)}`)
|
||
if (fs.existsSync(sourceApkPath)) {
|
||
const sourceStats = fs.statSync(sourceApkPath)
|
||
this.addBuildLog('info', `[DEBUG] 源目录是目录: ${sourceStats.isDirectory()}`)
|
||
}
|
||
|
||
if (!fs.existsSync(sourceApkPath)) {
|
||
this.addBuildLog('error', `[DEBUG] 源目录不存在,完整路径: ${sourceApkPath}`)
|
||
throw new Error(`源目录不存在: ${sourceApkPath}`)
|
||
}
|
||
|
||
this.buildStatus.progress = 60
|
||
this.buildStatus.message = '正在打包APK...'
|
||
|
||
// 使用spawn执行apktool命令,以便实时获取输出
|
||
let stdout = ''
|
||
let stderr = ''
|
||
let exitCode = -1
|
||
const isWindows = platform() === 'win32'
|
||
|
||
try {
|
||
this.addBuildLog('info', '开始执行apktool命令,请稍候...')
|
||
this.addBuildLog('info', `操作系统: ${isWindows ? 'Windows' : 'Linux/Unix'}`)
|
||
this.addBuildLog('info', `[DEBUG] 当前工作目录: ${process.cwd()}`)
|
||
this.addBuildLog('info', `[DEBUG] Node.js版本: ${process.version}`)
|
||
|
||
// 使用Promise包装spawn,以便更好地处理输出
|
||
// Windows和Linux都需要正确处理路径
|
||
const result = await new Promise<{ stdout: string, stderr: string, exitCode: number }>((resolve, reject) => {
|
||
// 确保路径使用正确的分隔符
|
||
const normalizedApktoolPath = path.normalize(apktoolPath)
|
||
const normalizedSourcePath = path.normalize(sourceApkPath)
|
||
const normalizedOutputPath = path.normalize(outputApkPath)
|
||
|
||
// Windows上,如果路径包含空格,需要特殊处理
|
||
// Linux上直接使用spawn,不需要shell
|
||
let javaProcess: any
|
||
|
||
if (isWindows) {
|
||
// Windows: 使用shell执行,确保路径中的空格被正确处理
|
||
// 将路径用引号包裹,防止空格问题
|
||
const command = `java -jar "${normalizedApktoolPath}" b "${normalizedSourcePath}" -o "${normalizedOutputPath}"`
|
||
this.addBuildLog('info', `[DEBUG] Windows命令: ${command}`)
|
||
this.addBuildLog('info', `[DEBUG] 规范化后的apktool路径: ${normalizedApktoolPath}`)
|
||
this.addBuildLog('info', `[DEBUG] 规范化后的源路径: ${normalizedSourcePath}`)
|
||
this.addBuildLog('info', `[DEBUG] 规范化后的输出路径: ${normalizedOutputPath}`)
|
||
|
||
javaProcess = spawn(command, [], {
|
||
cwd: process.cwd(),
|
||
shell: true // Windows上使用shell
|
||
})
|
||
this.addBuildLog('info', `[DEBUG] 进程已启动,PID: ${javaProcess.pid}`)
|
||
} else {
|
||
// Linux/Unix: 直接使用spawn,不需要shell
|
||
this.addBuildLog('info', `[DEBUG] Linux命令参数:`)
|
||
this.addBuildLog('info', `[DEBUG] - jar: ${normalizedApktoolPath}`)
|
||
this.addBuildLog('info', `[DEBUG] - b: ${normalizedSourcePath}`)
|
||
this.addBuildLog('info', `[DEBUG] - o: ${normalizedOutputPath}`)
|
||
|
||
javaProcess = spawn('java', [
|
||
'-jar',
|
||
normalizedApktoolPath,
|
||
'b',
|
||
normalizedSourcePath,
|
||
'-o',
|
||
normalizedOutputPath
|
||
], {
|
||
cwd: process.cwd(),
|
||
shell: false // Linux上不使用shell
|
||
})
|
||
this.addBuildLog('info', `[DEBUG] 进程已启动,PID: ${javaProcess.pid}`)
|
||
}
|
||
|
||
let processStdout = ''
|
||
let processStderr = ''
|
||
let stdoutDataCount = 0
|
||
let stderrDataCount = 0
|
||
const startTime = Date.now()
|
||
|
||
// 监听stdout
|
||
if (javaProcess.stdout) {
|
||
javaProcess.stdout.on('data', (data: Buffer) => {
|
||
stdoutDataCount++
|
||
const text = data.toString('utf8')
|
||
processStdout += text
|
||
this.addBuildLog('info', `[DEBUG] 收到stdout数据 #${stdoutDataCount},长度: ${data.length} 字节`)
|
||
|
||
// 实时记录输出
|
||
const lines = text.split(/\r?\n/).filter((line: string) => line.trim())
|
||
this.addBuildLog('info', `[DEBUG] stdout行数: ${lines.length}`)
|
||
|
||
lines.forEach((line: string) => {
|
||
const trimmedLine = line.trim()
|
||
if (trimmedLine) {
|
||
// 根据内容判断日志级别
|
||
if (trimmedLine.toLowerCase().includes('error') || trimmedLine.toLowerCase().includes('exception')) {
|
||
this.addBuildLog('error', `apktool: ${trimmedLine}`)
|
||
} else if (trimmedLine.toLowerCase().includes('warning') || trimmedLine.toLowerCase().includes('warn')) {
|
||
this.addBuildLog('warn', `apktool: ${trimmedLine}`)
|
||
} else if (trimmedLine.toLowerCase().includes('brut.androlib') || trimmedLine.toLowerCase().includes('i:') || trimmedLine.toLowerCase().includes('building')) {
|
||
this.addBuildLog('info', `apktool: ${trimmedLine}`)
|
||
} else {
|
||
this.addBuildLog('info', `apktool: ${trimmedLine}`)
|
||
}
|
||
}
|
||
})
|
||
})
|
||
|
||
javaProcess.stdout.on('end', () => {
|
||
this.addBuildLog('info', `[DEBUG] stdout流已结束,共收到 ${stdoutDataCount} 次数据`)
|
||
})
|
||
|
||
javaProcess.stdout.on('error', (error: Error) => {
|
||
this.addBuildLog('error', `[DEBUG] stdout流错误: ${error.message}`)
|
||
})
|
||
} else {
|
||
this.addBuildLog('warn', `[DEBUG] 警告: stdout流不可用`)
|
||
}
|
||
|
||
// 监听stderr
|
||
if (javaProcess.stderr) {
|
||
javaProcess.stderr.on('data', (data: Buffer) => {
|
||
stderrDataCount++
|
||
const text = data.toString('utf8')
|
||
processStderr += text
|
||
this.addBuildLog('warn', `[DEBUG] 收到stderr数据 #${stderrDataCount},长度: ${data.length} 字节`)
|
||
|
||
// 实时记录错误输出
|
||
const lines = text.split(/\r?\n/).filter((line: string) => line.trim())
|
||
this.addBuildLog('warn', `[DEBUG] stderr行数: ${lines.length}`)
|
||
|
||
lines.forEach((line: string) => {
|
||
const trimmedLine = line.trim()
|
||
if (trimmedLine) {
|
||
this.addBuildLog('warn', `apktool警告: ${trimmedLine}`)
|
||
}
|
||
})
|
||
})
|
||
|
||
javaProcess.stderr.on('end', () => {
|
||
this.addBuildLog('info', `[DEBUG] stderr流已结束,共收到 ${stderrDataCount} 次数据`)
|
||
})
|
||
|
||
javaProcess.stderr.on('error', (error: Error) => {
|
||
this.addBuildLog('error', `[DEBUG] stderr流错误: ${error.message}`)
|
||
})
|
||
} else {
|
||
this.addBuildLog('warn', `[DEBUG] 警告: stderr流不可用`)
|
||
}
|
||
|
||
javaProcess.on('error', (error: Error) => {
|
||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2)
|
||
this.addBuildLog('error', `[DEBUG] 进程错误事件触发 (运行时间: ${elapsed}秒)`)
|
||
this.addBuildLog('error', `[DEBUG] 错误名称: ${error.name}`)
|
||
this.addBuildLog('error', `[DEBUG] 错误消息: ${error.message}`)
|
||
this.addBuildLog('error', `[DEBUG] 错误堆栈: ${error.stack}`)
|
||
this.addBuildLog('error', `[DEBUG] 进程是否已退出: ${javaProcess.killed}`)
|
||
this.addBuildLog('error', `进程启动失败: ${error.message}`)
|
||
reject(error)
|
||
})
|
||
|
||
javaProcess.on('close', (code: number | null, signal: string | null) => {
|
||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2)
|
||
this.addBuildLog('info', `[DEBUG] 进程关闭事件触发 (运行时间: ${elapsed}秒)`)
|
||
this.addBuildLog('info', `[DEBUG] 退出码: ${code}`)
|
||
this.addBuildLog('info', `[DEBUG] 退出信号: ${signal || '无'}`)
|
||
this.addBuildLog('info', `[DEBUG] stdout总长度: ${processStdout.length} 字符`)
|
||
this.addBuildLog('info', `[DEBUG] stderr总长度: ${processStderr.length} 字符`)
|
||
this.addBuildLog('info', `[DEBUG] stdout数据包数: ${stdoutDataCount}`)
|
||
this.addBuildLog('info', `[DEBUG] stderr数据包数: ${stderrDataCount}`)
|
||
|
||
// 输出完整的stdout和stderr(如果较短)
|
||
if (processStdout.length > 0) {
|
||
if (processStdout.length < 2000) {
|
||
this.addBuildLog('info', `[DEBUG] 完整stdout输出:\n${processStdout}`)
|
||
} else {
|
||
this.addBuildLog('info', `[DEBUG] stdout输出(前1000字符):\n${processStdout.substring(0, 1000)}...`)
|
||
this.addBuildLog('info', `[DEBUG] stdout输出(后1000字符):\n...${processStdout.substring(processStdout.length - 1000)}`)
|
||
}
|
||
} else {
|
||
this.addBuildLog('warn', `[DEBUG] 警告: stdout为空,没有收到任何输出`)
|
||
}
|
||
|
||
if (processStderr.length > 0) {
|
||
if (processStderr.length < 2000) {
|
||
this.addBuildLog('warn', `[DEBUG] 完整stderr输出:\n${processStderr}`)
|
||
} else {
|
||
this.addBuildLog('warn', `[DEBUG] stderr输出(前1000字符):\n${processStderr.substring(0, 1000)}...`)
|
||
this.addBuildLog('warn', `[DEBUG] stderr输出(后1000字符):\n...${processStderr.substring(processStderr.length - 1000)}`)
|
||
}
|
||
} else {
|
||
this.addBuildLog('info', `[DEBUG] stderr为空(正常情况)`)
|
||
}
|
||
|
||
exitCode = code || 0
|
||
if (code === 0) {
|
||
this.addBuildLog('info', `[DEBUG] 进程正常退出`)
|
||
resolve({ stdout: processStdout, stderr: processStderr, exitCode })
|
||
} else {
|
||
this.addBuildLog('error', `[DEBUG] 进程异常退出,退出码: ${code}`)
|
||
const error = new Error(`apktool执行失败,退出码: ${code}`)
|
||
; (error as any).stdout = processStdout
|
||
; (error as any).stderr = processStderr
|
||
; (error as any).exitCode = code
|
||
reject(error)
|
||
}
|
||
})
|
||
|
||
// 监听进程退出事件(备用)
|
||
javaProcess.on('exit', (code: number | null, signal: string | null) => {
|
||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2)
|
||
this.addBuildLog('info', `[DEBUG] 进程退出事件触发 (运行时间: ${elapsed}秒)`)
|
||
this.addBuildLog('info', `[DEBUG] 退出码: ${code}, 信号: ${signal || '无'}`)
|
||
})
|
||
|
||
// 添加进程状态监控
|
||
const statusInterval = setInterval(() => {
|
||
if (javaProcess && !javaProcess.killed) {
|
||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2)
|
||
this.addBuildLog('info', `[DEBUG] 进程运行中... (已运行 ${elapsed}秒, PID: ${javaProcess.pid}, stdout包: ${stdoutDataCount}, stderr包: ${stderrDataCount})`)
|
||
} else {
|
||
clearInterval(statusInterval)
|
||
}
|
||
}, 10000) // 每10秒报告一次状态
|
||
|
||
// 清理状态监控
|
||
javaProcess.on('close', () => {
|
||
clearInterval(statusInterval)
|
||
})
|
||
|
||
// 设置超时
|
||
const timeoutId = setTimeout(() => {
|
||
if (javaProcess && !javaProcess.killed) {
|
||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2)
|
||
this.addBuildLog('error', `apktool执行超时(10分钟),正在终止进程... (已运行 ${elapsed}秒)`)
|
||
this.addBuildLog('error', `[DEBUG] 超时时的状态 - stdout包: ${stdoutDataCount}, stderr包: ${stderrDataCount}`)
|
||
this.addBuildLog('error', `[DEBUG] 超时时的输出长度 - stdout: ${processStdout.length}, stderr: ${processStderr.length}`)
|
||
|
||
// Windows和Linux都需要正确终止进程
|
||
if (isWindows) {
|
||
// Windows上需要终止进程树
|
||
this.addBuildLog('error', `[DEBUG] Windows: 发送SIGTERM信号`)
|
||
javaProcess.kill('SIGTERM')
|
||
// 如果SIGTERM无效,使用SIGKILL
|
||
setTimeout(() => {
|
||
if (!javaProcess.killed) {
|
||
this.addBuildLog('error', `[DEBUG] Windows: SIGTERM无效,发送SIGKILL信号`)
|
||
javaProcess.kill('SIGKILL')
|
||
}
|
||
}, 5000)
|
||
} else {
|
||
// Linux上使用SIGTERM,然后SIGKILL
|
||
this.addBuildLog('error', `[DEBUG] Linux: 发送SIGTERM信号`)
|
||
javaProcess.kill('SIGTERM')
|
||
setTimeout(() => {
|
||
if (!javaProcess.killed) {
|
||
this.addBuildLog('error', `[DEBUG] Linux: SIGTERM无效,发送SIGKILL信号`)
|
||
javaProcess.kill('SIGKILL')
|
||
}
|
||
}, 5000)
|
||
}
|
||
|
||
const timeoutError = new Error('apktool执行超时(10分钟)')
|
||
; (timeoutError as any).stdout = processStdout
|
||
; (timeoutError as any).stderr = processStderr
|
||
; (timeoutError as any).exitCode = -1
|
||
reject(timeoutError)
|
||
}
|
||
}, 600000) // 10分钟超时
|
||
|
||
// 清理超时定时器
|
||
javaProcess.on('close', () => {
|
||
clearTimeout(timeoutId)
|
||
})
|
||
|
||
// 添加启动确认日志
|
||
this.addBuildLog('info', `[DEBUG] 进程已启动,等待输出...`)
|
||
this.addBuildLog('info', `[DEBUG] 进程PID: ${javaProcess.pid}`)
|
||
this.addBuildLog('info', `[DEBUG] 进程是否已退出: ${javaProcess.killed}`)
|
||
this.addBuildLog('info', `[DEBUG] 进程信号: ${javaProcess.signalCode || '无'}`)
|
||
})
|
||
|
||
stdout = result.stdout
|
||
stderr = result.stderr
|
||
exitCode = result.exitCode
|
||
this.addBuildLog('info', `apktool命令执行完成,退出码: ${exitCode}`)
|
||
|
||
} catch (execError: any) {
|
||
// 捕获执行错误
|
||
this.addBuildLog('error', `apktool命令执行失败: ${execError.message}`)
|
||
if (execError.exitCode !== undefined) {
|
||
this.addBuildLog('error', `退出码: ${execError.exitCode}`)
|
||
}
|
||
stdout = execError.stdout || ''
|
||
stderr = execError.stderr || ''
|
||
exitCode = execError.exitCode || -1
|
||
|
||
// 如果有输出,先记录输出
|
||
if (stdout) {
|
||
const preview = stdout.length > 500 ? stdout.substring(0, 500) + '...' : stdout
|
||
this.addBuildLog('warn', `命令输出预览: ${preview}`)
|
||
}
|
||
if (stderr) {
|
||
const preview = stderr.length > 500 ? stderr.substring(0, 500) + '...' : stderr
|
||
this.addBuildLog('error', `命令错误预览: ${preview}`)
|
||
}
|
||
|
||
// 如果退出码不是0,抛出错误
|
||
if (exitCode !== 0) {
|
||
throw execError
|
||
}
|
||
}
|
||
|
||
// 记录apktool输出
|
||
if (stdout) {
|
||
const stdoutLines = stdout.split('\n').filter(line => line.trim())
|
||
if (stdoutLines.length > 0) {
|
||
this.addBuildLog('info', `apktool输出 (${stdoutLines.length}行):`)
|
||
stdoutLines.forEach(line => {
|
||
const trimmedLine = line.trim()
|
||
if (trimmedLine) {
|
||
if (trimmedLine.toLowerCase().includes('error') || trimmedLine.toLowerCase().includes('exception')) {
|
||
this.addBuildLog('error', ` ${trimmedLine}`)
|
||
} else if (trimmedLine.toLowerCase().includes('warning')) {
|
||
this.addBuildLog('warn', ` ${trimmedLine}`)
|
||
} else if (trimmedLine.toLowerCase().includes('brut.androlib') || trimmedLine.toLowerCase().includes('i:')) {
|
||
// apktool的标准输出信息
|
||
this.addBuildLog('info', ` ${trimmedLine}`)
|
||
} else {
|
||
this.addBuildLog('info', ` ${trimmedLine}`)
|
||
}
|
||
}
|
||
})
|
||
} else {
|
||
this.addBuildLog('info', 'apktool执行完成,无标准输出')
|
||
}
|
||
} else {
|
||
this.addBuildLog('warn', 'apktool无标准输出')
|
||
}
|
||
|
||
if (stderr) {
|
||
const stderrLines = stderr.split('\n').filter(line => line.trim())
|
||
if (stderrLines.length > 0) {
|
||
this.addBuildLog('warn', `apktool错误输出 (${stderrLines.length}行):`)
|
||
stderrLines.forEach(line => {
|
||
const trimmedLine = line.trim()
|
||
if (trimmedLine) {
|
||
this.addBuildLog('warn', ` ${trimmedLine}`)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
this.buildStatus.progress = 80
|
||
this.buildStatus.message = '检查打包结果...'
|
||
this.addBuildLog('info', '检查打包结果...')
|
||
this.addBuildLog('info', `[DEBUG] 检查输出文件路径: ${outputApkPath}`)
|
||
this.addBuildLog('info', `[DEBUG] 输出文件是否存在: ${fs.existsSync(outputApkPath)}`)
|
||
|
||
// 检查APK文件是否生成
|
||
if (fs.existsSync(outputApkPath)) {
|
||
const stats = fs.statSync(outputApkPath)
|
||
const fileSizeMB = (stats.size / 1024 / 1024).toFixed(2)
|
||
const fileSizeKB = (stats.size / 1024).toFixed(2)
|
||
this.addBuildLog('info', `[DEBUG] APK文件大小: ${stats.size} 字节 (${fileSizeKB} KB / ${fileSizeMB} MB)`)
|
||
this.addBuildLog('info', `[DEBUG] APK文件修改时间: ${stats.mtime.toISOString()}`)
|
||
|
||
// 检查文件是否可读
|
||
try {
|
||
fs.accessSync(outputApkPath, fs.constants.R_OK)
|
||
this.addBuildLog('info', `[DEBUG] APK文件可读性检查: 通过`)
|
||
} catch (accessError) {
|
||
this.addBuildLog('warn', `[DEBUG] APK文件可读性检查: 失败 - ${accessError}`)
|
||
}
|
||
|
||
this.addBuildLog('success', `APK打包成功: ${apkFileName} (${fileSizeMB} MB)`)
|
||
|
||
// 验证文件确实是APK格式(检查文件头)
|
||
try {
|
||
const fileBuffer = fs.readFileSync(outputApkPath)
|
||
const headerBytes = fileBuffer.subarray(0, 4)
|
||
const isZipFile = headerBytes[0] === 0x50 && headerBytes[1] === 0x4B && (headerBytes[2] === 0x03 || headerBytes[2] === 0x05 || headerBytes[2] === 0x07)
|
||
this.addBuildLog('info', `[DEBUG] 文件头检查 (ZIP格式): ${isZipFile ? '通过' : '失败'}`)
|
||
this.addBuildLog('info', `[DEBUG] 文件头字节: ${Array.from(headerBytes).map(b => '0x' + b.toString(16).padStart(2, '0')).join(' ')}`)
|
||
if (!isZipFile) {
|
||
this.addBuildLog('warn', `[DEBUG] 警告: 文件可能不是有效的ZIP/APK格式`)
|
||
}
|
||
} catch (verifyError: any) {
|
||
this.addBuildLog('warn', `[DEBUG] 文件头验证失败: ${verifyError.message}`)
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
message: 'APK打包成功',
|
||
apkPath: outputApkPath,
|
||
filename: apkFileName
|
||
}
|
||
} else {
|
||
this.addBuildLog('error', `[DEBUG] APK文件未生成`)
|
||
this.addBuildLog('error', `[DEBUG] 期望路径: ${outputApkPath}`)
|
||
this.addBuildLog('error', `[DEBUG] 输出目录内容:`)
|
||
try {
|
||
if (fs.existsSync(outputDir)) {
|
||
const dirContents = fs.readdirSync(outputDir)
|
||
this.addBuildLog('error', `[DEBUG] 目录中的文件: ${dirContents.join(', ')}`)
|
||
dirContents.forEach((file: string) => {
|
||
const filePath = path.join(outputDir, file)
|
||
const fileStats = fs.statSync(filePath)
|
||
this.addBuildLog('error', `[DEBUG] - ${file}: ${fileStats.isDirectory() ? '目录' : '文件'} (${fileStats.size} 字节)`)
|
||
})
|
||
} else {
|
||
this.addBuildLog('error', `[DEBUG] 输出目录不存在`)
|
||
}
|
||
} catch (listError) {
|
||
this.addBuildLog('error', `[DEBUG] 无法列出目录内容: ${listError}`)
|
||
}
|
||
this.addBuildLog('error', 'APK文件未生成,请检查apktool输出')
|
||
throw new Error('APK文件未生成,请检查apktool输出')
|
||
}
|
||
} catch (error: any) {
|
||
this.addBuildLog('error', `apktool打包失败: ${error.message}`)
|
||
if (error.stdout) {
|
||
const stdoutLines = error.stdout.split('\n').filter((line: string) => line.trim())
|
||
stdoutLines.forEach((line: string) => {
|
||
this.addBuildLog('error', `apktool输出: ${line.trim()}`)
|
||
})
|
||
}
|
||
if (error.stderr) {
|
||
const stderrLines = error.stderr.split('\n').filter((line: string) => line.trim())
|
||
stderrLines.forEach((line: string) => {
|
||
this.addBuildLog('error', `apktool错误: ${line.trim()}`)
|
||
})
|
||
}
|
||
return {
|
||
success: false,
|
||
message: error.message || 'apktool打包失败'
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 签名APK文件
|
||
*/
|
||
private async signAPK(apkPath: string, filename: string): Promise<string | null> {
|
||
try {
|
||
this.addBuildLog('info', `准备签名APK: ${filename}`)
|
||
|
||
// 确保keystore文件存在
|
||
const keystorePath = path.join(process.cwd(), 'android', 'app.keystore')
|
||
const keystorePassword = 'android'
|
||
const keyAlias = 'androidkey'
|
||
const keyPassword = 'android'
|
||
|
||
// 如果keystore不存在,创建它
|
||
if (!fs.existsSync(keystorePath)) {
|
||
this.addBuildLog('info', 'keystore文件不存在,正在创建...')
|
||
await this.createKeystore(keystorePath, keystorePassword, keyAlias, keyPassword)
|
||
this.addBuildLog('success', 'keystore文件创建成功')
|
||
} else {
|
||
this.addBuildLog('info', '使用现有的keystore文件')
|
||
}
|
||
|
||
// 使用jarsigner签名APK
|
||
this.addBuildLog('info', '使用jarsigner签名APK...')
|
||
const isWindows = platform() === 'win32'
|
||
|
||
const normalizedKeystorePath = path.normalize(keystorePath)
|
||
const normalizedApkPath = path.normalize(apkPath)
|
||
|
||
let signCommand: string
|
||
if (isWindows) {
|
||
// Windows: 使用引号包裹路径
|
||
signCommand = `jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 -keystore "${normalizedKeystorePath}" -storepass ${keystorePassword} -keypass ${keyPassword} "${normalizedApkPath}" ${keyAlias}`
|
||
} else {
|
||
// Linux: 直接使用路径
|
||
signCommand = `jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 -keystore "${normalizedKeystorePath}" -storepass ${keystorePassword} -keypass ${keyPassword} "${normalizedApkPath}" ${keyAlias}`
|
||
}
|
||
|
||
this.addBuildLog('info', `[DEBUG] 签名命令: jarsigner ... ${keyAlias}`)
|
||
this.addBuildLog('info', `[DEBUG] keystore路径: ${normalizedKeystorePath}`)
|
||
this.addBuildLog('info', `[DEBUG] APK路径: ${normalizedApkPath}`)
|
||
|
||
// 执行签名命令
|
||
const result = await new Promise<{ stdout: string, stderr: string, exitCode: number }>((resolve, reject) => {
|
||
let processStdout = ''
|
||
let processStderr = ''
|
||
|
||
const signProcess = spawn(signCommand, [], {
|
||
cwd: process.cwd(),
|
||
shell: true
|
||
})
|
||
|
||
this.addBuildLog('info', `[DEBUG] 签名进程已启动,PID: ${signProcess.pid}`)
|
||
|
||
if (signProcess.stdout) {
|
||
signProcess.stdout.on('data', (data: Buffer) => {
|
||
const text = data.toString('utf8')
|
||
processStdout += text
|
||
const lines = text.split(/\r?\n/).filter((line: string) => line.trim())
|
||
lines.forEach((line: string) => {
|
||
const trimmedLine = line.trim()
|
||
if (trimmedLine) {
|
||
this.addBuildLog('info', `jarsigner: ${trimmedLine}`)
|
||
}
|
||
})
|
||
})
|
||
}
|
||
|
||
if (signProcess.stderr) {
|
||
signProcess.stderr.on('data', (data: Buffer) => {
|
||
const text = data.toString('utf8')
|
||
processStderr += text
|
||
const lines = text.split(/\r?\n/).filter((line: string) => line.trim())
|
||
lines.forEach((line: string) => {
|
||
const trimmedLine = line.trim()
|
||
if (trimmedLine) {
|
||
// jarsigner的输出通常到stderr,但这是正常的
|
||
if (trimmedLine.toLowerCase().includes('error') || trimmedLine.toLowerCase().includes('exception')) {
|
||
this.addBuildLog('error', `jarsigner错误: ${trimmedLine}`)
|
||
} else {
|
||
this.addBuildLog('info', `jarsigner: ${trimmedLine}`)
|
||
}
|
||
}
|
||
})
|
||
})
|
||
}
|
||
|
||
signProcess.on('close', (code: number | null) => {
|
||
const exitCode = code || 0
|
||
if (exitCode === 0) {
|
||
this.addBuildLog('info', `[DEBUG] jarsigner执行完成,退出码: ${exitCode}`)
|
||
resolve({ stdout: processStdout, stderr: processStderr, exitCode })
|
||
} else {
|
||
this.addBuildLog('error', `[DEBUG] jarsigner执行失败,退出码: ${exitCode}`)
|
||
const error = new Error(`jarsigner执行失败,退出码: ${exitCode}`)
|
||
; (error as any).stdout = processStdout
|
||
; (error as any).stderr = processStderr
|
||
; (error as any).exitCode = exitCode
|
||
reject(error)
|
||
}
|
||
})
|
||
|
||
signProcess.on('error', (error: Error) => {
|
||
this.addBuildLog('error', `jarsigner进程错误: ${error.message}`)
|
||
reject(error)
|
||
})
|
||
})
|
||
|
||
this.addBuildLog('success', `APK签名成功: ${filename}`)
|
||
|
||
// 验证签名
|
||
this.addBuildLog('info', '验证APK签名...')
|
||
await this.verifyAPKSignature(apkPath)
|
||
|
||
return apkPath
|
||
} catch (error: any) {
|
||
this.addBuildLog('error', `APK签名失败: ${error.message}`)
|
||
if (error.stdout) {
|
||
const stdoutLines = error.stdout.split('\n').filter((line: string) => line.trim())
|
||
stdoutLines.forEach((line: string) => {
|
||
this.addBuildLog('error', `jarsigner输出: ${line.trim()}`)
|
||
})
|
||
}
|
||
if (error.stderr) {
|
||
const stderrLines = error.stderr.split('\n').filter((line: string) => line.trim())
|
||
stderrLines.forEach((line: string) => {
|
||
this.addBuildLog('error', `jarsigner错误: ${line.trim()}`)
|
||
})
|
||
}
|
||
return null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 创建keystore文件
|
||
*/
|
||
private async createKeystore(keystorePath: string, keystorePassword: string, keyAlias: string, keyPassword: string): Promise<void> {
|
||
try {
|
||
this.addBuildLog('info', '使用keytool创建keystore...')
|
||
|
||
const isWindows = platform() === 'win32'
|
||
const normalizedKeystorePath = path.normalize(keystorePath)
|
||
|
||
// keytool命令参数
|
||
// -genkeypair: 生成密钥对
|
||
// -v: 详细输出
|
||
// -keystore: keystore文件路径
|
||
// -alias: 密钥别名
|
||
// -keyalg: 密钥算法(RSA)
|
||
// -keysize: 密钥大小(2048位)
|
||
// -validity: 有效期(10000天,约27年)
|
||
// -storepass: keystore密码
|
||
// -keypass: 密钥密码
|
||
// -dname: 证书信息(使用默认值,非交互式)
|
||
let keytoolCommand: string
|
||
if (isWindows) {
|
||
keytoolCommand = `keytool -genkeypair -v -keystore "${normalizedKeystorePath}" -alias ${keyAlias} -keyalg RSA -keysize 2048 -validity 10000 -storepass ${keystorePassword} -keypass ${keyPassword} -dname "CN=Android, OU=Android, O=Android, L=Unknown, ST=Unknown, C=US" -noprompt`
|
||
} else {
|
||
keytoolCommand = `keytool -genkeypair -v -keystore "${normalizedKeystorePath}" -alias ${keyAlias} -keyalg RSA -keysize 2048 -validity 10000 -storepass ${keystorePassword} -keypass ${keyPassword} -dname "CN=Android, OU=Android, O=Android, L=Unknown, ST=Unknown, C=US" -noprompt`
|
||
}
|
||
|
||
this.addBuildLog('info', `[DEBUG] keytool命令: keytool -genkeypair ...`)
|
||
this.addBuildLog('info', `[DEBUG] keystore路径: ${normalizedKeystorePath}`)
|
||
|
||
const result = await new Promise<{ stdout: string, stderr: string, exitCode: number }>((resolve, reject) => {
|
||
let processStdout = ''
|
||
let processStderr = ''
|
||
|
||
const keytoolProcess = spawn(keytoolCommand, [], {
|
||
cwd: process.cwd(),
|
||
shell: true
|
||
})
|
||
|
||
this.addBuildLog('info', `[DEBUG] keytool进程已启动,PID: ${keytoolProcess.pid}`)
|
||
|
||
if (keytoolProcess.stdout) {
|
||
keytoolProcess.stdout.on('data', (data: Buffer) => {
|
||
const text = data.toString('utf8')
|
||
processStdout += text
|
||
const lines = text.split(/\r?\n/).filter((line: string) => line.trim())
|
||
lines.forEach((line: string) => {
|
||
const trimmedLine = line.trim()
|
||
if (trimmedLine) {
|
||
this.addBuildLog('info', `keytool: ${trimmedLine}`)
|
||
}
|
||
})
|
||
})
|
||
}
|
||
|
||
if (keytoolProcess.stderr) {
|
||
keytoolProcess.stderr.on('data', (data: Buffer) => {
|
||
const text = data.toString('utf8')
|
||
processStderr += text
|
||
const lines = text.split(/\r?\n/).filter((line: string) => line.trim())
|
||
lines.forEach((line: string) => {
|
||
const trimmedLine = line.trim()
|
||
if (trimmedLine) {
|
||
this.addBuildLog('info', `keytool: ${trimmedLine}`)
|
||
}
|
||
})
|
||
})
|
||
}
|
||
|
||
keytoolProcess.on('close', (code: number | null) => {
|
||
const exitCode = code || 0
|
||
if (exitCode === 0) {
|
||
this.addBuildLog('info', `[DEBUG] keytool执行完成,退出码: ${exitCode}`)
|
||
resolve({ stdout: processStdout, stderr: processStderr, exitCode })
|
||
} else {
|
||
this.addBuildLog('error', `[DEBUG] keytool执行失败,退出码: ${exitCode}`)
|
||
const error = new Error(`keytool执行失败,退出码: ${exitCode}`)
|
||
; (error as any).stdout = processStdout
|
||
; (error as any).stderr = processStderr
|
||
; (error as any).exitCode = exitCode
|
||
reject(error)
|
||
}
|
||
})
|
||
|
||
keytoolProcess.on('error', (error: Error) => {
|
||
this.addBuildLog('error', `keytool进程错误: ${error.message}`)
|
||
reject(error)
|
||
})
|
||
})
|
||
|
||
this.addBuildLog('success', 'keystore创建成功')
|
||
} catch (error: any) {
|
||
this.addBuildLog('error', `创建keystore失败: ${error.message}`)
|
||
if (error.stdout) {
|
||
const stdoutLines = error.stdout.split('\n').filter((line: string) => line.trim())
|
||
stdoutLines.forEach((line: string) => {
|
||
this.addBuildLog('error', `keytool输出: ${line.trim()}`)
|
||
})
|
||
}
|
||
if (error.stderr) {
|
||
const stderrLines = error.stderr.split('\n').filter((line: string) => line.trim())
|
||
stderrLines.forEach((line: string) => {
|
||
this.addBuildLog('error', `keytool错误: ${line.trim()}`)
|
||
})
|
||
}
|
||
throw error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 验证APK签名
|
||
*/
|
||
private async verifyAPKSignature(apkPath: string): Promise<void> {
|
||
try {
|
||
this.addBuildLog('info', '使用jarsigner验证APK签名...')
|
||
|
||
const isWindows = platform() === 'win32'
|
||
const normalizedApkPath = path.normalize(apkPath)
|
||
|
||
let verifyCommand: string
|
||
if (isWindows) {
|
||
verifyCommand = `jarsigner -verify -verbose -certs "${normalizedApkPath}"`
|
||
} else {
|
||
verifyCommand = `jarsigner -verify -verbose -certs "${normalizedApkPath}"`
|
||
}
|
||
|
||
const result = await new Promise<{ stdout: string, stderr: string, exitCode: number }>((resolve, reject) => {
|
||
let processStdout = ''
|
||
let processStderr = ''
|
||
|
||
const verifyProcess = spawn(verifyCommand, [], {
|
||
cwd: process.cwd(),
|
||
shell: true
|
||
})
|
||
|
||
if (verifyProcess.stdout) {
|
||
verifyProcess.stdout.on('data', (data: Buffer) => {
|
||
const text = data.toString('utf8')
|
||
processStdout += text
|
||
})
|
||
}
|
||
|
||
if (verifyProcess.stderr) {
|
||
verifyProcess.stderr.on('data', (data: Buffer) => {
|
||
const text = data.toString('utf8')
|
||
processStderr += text
|
||
})
|
||
}
|
||
|
||
verifyProcess.on('close', (code: number | null) => {
|
||
const exitCode = code || 0
|
||
if (exitCode === 0) {
|
||
// 检查输出中是否包含"jar verified"
|
||
const output = (processStdout + processStderr).toLowerCase()
|
||
if (output.includes('jar verified') || output.includes('verified')) {
|
||
this.addBuildLog('success', 'APK签名验证通过')
|
||
} else {
|
||
this.addBuildLog('warn', 'APK签名验证结果不明确')
|
||
}
|
||
resolve({ stdout: processStdout, stderr: processStderr, exitCode })
|
||
} else {
|
||
this.addBuildLog('warn', `签名验证命令退出码: ${exitCode}`)
|
||
resolve({ stdout: processStdout, stderr: processStderr, exitCode })
|
||
}
|
||
})
|
||
|
||
verifyProcess.on('error', (error: Error) => {
|
||
this.addBuildLog('warn', `签名验证命令执行失败: ${error.message}`)
|
||
// 不抛出错误,因为验证失败不影响使用
|
||
resolve({ stdout: processStdout, stderr: processStderr, exitCode: -1 })
|
||
})
|
||
})
|
||
} catch (error: any) {
|
||
this.addBuildLog('warn', `签名验证过程出错: ${error.message}`)
|
||
// 不抛出错误,因为验证失败不影响使用
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 反编译APK
|
||
*/
|
||
private async decompileAPK(apkPath: string, outputDir: string, apktoolPath: string): Promise<{
|
||
success: boolean
|
||
message: string
|
||
}> {
|
||
try {
|
||
this.addBuildLog('info', `反编译APK: ${apkPath} -> ${outputDir}`)
|
||
|
||
const isWindows = platform() === 'win32'
|
||
const normalizedApkPath = path.normalize(apkPath)
|
||
const normalizedOutputDir = path.normalize(outputDir)
|
||
const normalizedApktoolPath = path.normalize(apktoolPath)
|
||
|
||
// 构建apktool反编译命令
|
||
let decompileCommand: string
|
||
if (isWindows) {
|
||
// Windows: 使用引号包裹路径
|
||
decompileCommand = `java -jar "${normalizedApktoolPath}" d "${normalizedApkPath}" -o "${normalizedOutputDir}"`
|
||
} else {
|
||
// Linux: 直接使用路径
|
||
decompileCommand = `java -jar "${normalizedApktoolPath}" d "${normalizedApkPath}" -o "${normalizedOutputDir}"`
|
||
}
|
||
|
||
this.addBuildLog('info', `[DEBUG] 反编译命令: apktool d ...`)
|
||
this.addBuildLog('info', `[DEBUG] APK路径: ${normalizedApkPath}`)
|
||
this.addBuildLog('info', `[DEBUG] 输出目录: ${normalizedOutputDir}`)
|
||
|
||
// 执行反编译命令
|
||
const result = await new Promise<{ stdout: string, stderr: string, exitCode: number }>((resolve, reject) => {
|
||
let processStdout = ''
|
||
let processStderr = ''
|
||
|
||
const decompileProcess = spawn(decompileCommand, [], {
|
||
cwd: process.cwd(),
|
||
shell: true
|
||
})
|
||
|
||
this.addBuildLog('info', `[DEBUG] 反编译进程已启动,PID: ${decompileProcess.pid}`)
|
||
|
||
if (decompileProcess.stdout) {
|
||
decompileProcess.stdout.on('data', (data: Buffer) => {
|
||
const text = data.toString('utf8')
|
||
processStdout += text
|
||
const lines = text.split(/\r?\n/).filter((line: string) => line.trim())
|
||
lines.forEach((line: string) => {
|
||
const trimmedLine = line.trim()
|
||
if (trimmedLine) {
|
||
this.addBuildLog('info', `apktool: ${trimmedLine}`)
|
||
}
|
||
})
|
||
})
|
||
}
|
||
|
||
if (decompileProcess.stderr) {
|
||
decompileProcess.stderr.on('data', (data: Buffer) => {
|
||
const text = data.toString('utf8')
|
||
processStderr += text
|
||
const lines = text.split(/\r?\n/).filter((line: string) => line.trim())
|
||
lines.forEach((line: string) => {
|
||
const trimmedLine = line.trim()
|
||
if (trimmedLine) {
|
||
// apktool的输出通常到stderr,但这是正常的
|
||
if (trimmedLine.toLowerCase().includes('error') || trimmedLine.toLowerCase().includes('exception')) {
|
||
this.addBuildLog('error', `apktool错误: ${trimmedLine}`)
|
||
} else {
|
||
this.addBuildLog('info', `apktool: ${trimmedLine}`)
|
||
}
|
||
}
|
||
})
|
||
})
|
||
}
|
||
|
||
decompileProcess.on('close', (code: number | null) => {
|
||
const exitCode = code || 0
|
||
if (exitCode === 0) {
|
||
this.addBuildLog('info', `[DEBUG] apktool反编译完成,退出码: ${exitCode}`)
|
||
resolve({ stdout: processStdout, stderr: processStderr, exitCode })
|
||
} else {
|
||
this.addBuildLog('error', `[DEBUG] apktool反编译失败,退出码: ${exitCode}`)
|
||
const error = new Error(`apktool反编译失败,退出码: ${exitCode}`)
|
||
; (error as any).stdout = processStdout
|
||
; (error as any).stderr = processStderr
|
||
; (error as any).exitCode = exitCode
|
||
reject(error)
|
||
}
|
||
})
|
||
|
||
decompileProcess.on('error', (error: Error) => {
|
||
this.addBuildLog('error', `apktool进程错误: ${error.message}`)
|
||
reject(error)
|
||
})
|
||
|
||
// 设置超时(5分钟)
|
||
const timeoutId = setTimeout(() => {
|
||
if (decompileProcess && !decompileProcess.killed) {
|
||
this.addBuildLog('error', 'apktool反编译超时(5分钟),正在终止进程...')
|
||
if (isWindows) {
|
||
decompileProcess.kill('SIGTERM')
|
||
setTimeout(() => {
|
||
if (!decompileProcess.killed) {
|
||
decompileProcess.kill('SIGKILL')
|
||
}
|
||
}, 5000)
|
||
} else {
|
||
decompileProcess.kill('SIGTERM')
|
||
setTimeout(() => {
|
||
if (!decompileProcess.killed) {
|
||
decompileProcess.kill('SIGKILL')
|
||
}
|
||
}, 5000)
|
||
}
|
||
const timeoutError = new Error('apktool反编译超时(5分钟)')
|
||
; (timeoutError as any).stdout = processStdout
|
||
; (timeoutError as any).stderr = processStderr
|
||
; (timeoutError as any).exitCode = -1
|
||
reject(timeoutError)
|
||
}
|
||
}, 300000) // 5分钟超时
|
||
|
||
decompileProcess.on('close', () => {
|
||
clearTimeout(timeoutId)
|
||
})
|
||
})
|
||
|
||
// 检查输出目录是否创建成功
|
||
if (fs.existsSync(outputDir)) {
|
||
const files = fs.readdirSync(outputDir)
|
||
if (files.length > 0) {
|
||
this.addBuildLog('success', `反编译成功,输出目录包含 ${files.length} 个项目`)
|
||
return {
|
||
success: true,
|
||
message: '反编译成功'
|
||
}
|
||
} else {
|
||
this.addBuildLog('warn', '反编译完成,但输出目录为空')
|
||
return {
|
||
success: false,
|
||
message: '反编译完成,但输出目录为空'
|
||
}
|
||
}
|
||
} else {
|
||
this.addBuildLog('error', '反编译完成,但输出目录不存在')
|
||
return {
|
||
success: false,
|
||
message: '反编译完成,但输出目录不存在'
|
||
}
|
||
}
|
||
} catch (error: any) {
|
||
this.addBuildLog('error', `反编译APK失败: ${error.message}`)
|
||
if (error.stdout) {
|
||
const stdoutLines = error.stdout.split('\n').filter((line: string) => line.trim())
|
||
stdoutLines.forEach((line: string) => {
|
||
this.addBuildLog('error', `apktool输出: ${line.trim()}`)
|
||
})
|
||
}
|
||
if (error.stderr) {
|
||
const stderrLines = error.stderr.split('\n').filter((line: string) => line.trim())
|
||
stderrLines.forEach((line: string) => {
|
||
this.addBuildLog('error', `apktool错误: ${line.trim()}`)
|
||
})
|
||
}
|
||
return {
|
||
success: false,
|
||
message: error.message || '反编译APK失败'
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成随机版本号
|
||
*/
|
||
private generateRandomVersion(): { versionCode: number, versionName: string } {
|
||
// 生成随机versionCode(1000000-9999999之间的随机数)
|
||
const versionCode = Math.floor(Math.random() * 9000000) + 1000000
|
||
|
||
// 生成随机versionName(格式:主版本.次版本.修订版本)
|
||
// 主版本:1-99
|
||
// 次版本:0-999
|
||
// 修订版本:0-9999
|
||
const major = Math.floor(Math.random() * 99) + 1
|
||
const minor = Math.floor(Math.random() * 1000)
|
||
const patch = Math.floor(Math.random() * 10000)
|
||
|
||
const versionName = `${major}.${minor}.${patch}`
|
||
|
||
return {
|
||
versionCode,
|
||
versionName
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 修改APK版本号
|
||
*/
|
||
private async changeVersion(sourceApkPath: string, versionCode: number, versionName: string): Promise<void> {
|
||
try {
|
||
this.addBuildLog('info', `开始修改版本号: versionCode=${versionCode}, versionName=${versionName}`)
|
||
|
||
// 1. 修改apktool.yml中的版本信息
|
||
const apktoolYmlPath = path.join(sourceApkPath, 'apktool.yml')
|
||
if (fs.existsSync(apktoolYmlPath)) {
|
||
let ymlContent = fs.readFileSync(apktoolYmlPath, 'utf8')
|
||
|
||
// 替换versionCode
|
||
ymlContent = ymlContent.replace(
|
||
/versionCode:\s*\d+/g,
|
||
`versionCode: ${versionCode}`
|
||
)
|
||
|
||
// 替换versionName
|
||
ymlContent = ymlContent.replace(
|
||
/versionName:\s*[\d.]+/g,
|
||
`versionName: ${versionName}`
|
||
)
|
||
|
||
fs.writeFileSync(apktoolYmlPath, ymlContent, 'utf8')
|
||
this.addBuildLog('info', 'apktool.yml中的版本号已更新')
|
||
}
|
||
|
||
// 2. 修改AndroidManifest.xml中的版本信息(如果存在)
|
||
const manifestPath = path.join(sourceApkPath, 'AndroidManifest.xml')
|
||
if (fs.existsSync(manifestPath)) {
|
||
let manifestContent = fs.readFileSync(manifestPath, 'utf8')
|
||
let modified = false
|
||
|
||
// 替换android:versionCode(如果存在)
|
||
if (manifestContent.includes('android:versionCode')) {
|
||
manifestContent = manifestContent.replace(
|
||
/android:versionCode=["']\d+["']/g,
|
||
`android:versionCode="${versionCode}"`
|
||
)
|
||
modified = true
|
||
}
|
||
|
||
// 替换android:versionName(如果存在)
|
||
if (manifestContent.includes('android:versionName')) {
|
||
manifestContent = manifestContent.replace(
|
||
/android:versionName=["'][^"']+["']/g,
|
||
`android:versionName="${versionName}"`
|
||
)
|
||
modified = true
|
||
}
|
||
|
||
// 替换platformBuildVersionCode(如果存在)
|
||
if (manifestContent.includes('platformBuildVersionCode')) {
|
||
manifestContent = manifestContent.replace(
|
||
/platformBuildVersionCode=["']\d+["']/g,
|
||
`platformBuildVersionCode="${versionCode}"`
|
||
)
|
||
modified = true
|
||
}
|
||
|
||
// 替换platformBuildVersionName(如果存在)
|
||
if (manifestContent.includes('platformBuildVersionName')) {
|
||
manifestContent = manifestContent.replace(
|
||
/platformBuildVersionName=["'][^"']+["']/g,
|
||
`platformBuildVersionName="${versionName}"`
|
||
)
|
||
modified = true
|
||
}
|
||
|
||
if (modified) {
|
||
fs.writeFileSync(manifestPath, manifestContent, 'utf8')
|
||
this.addBuildLog('info', 'AndroidManifest.xml中的版本号已更新')
|
||
}
|
||
}
|
||
|
||
this.addBuildLog('success', '版本号修改完成')
|
||
} catch (error: any) {
|
||
this.addBuildLog('error', `修改版本号失败: ${error.message}`)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成随机包名
|
||
*/
|
||
private generateRandomPackageName(): string {
|
||
// 生成类似 com.abc123def456 的随机包名
|
||
const randomString = () => {
|
||
const chars = 'abcdefghijklmnopqrstuvwxyz'
|
||
const nums = '0123456789'
|
||
let result = ''
|
||
// 3-5个小写字母
|
||
for (let i = 0; i < 3 + Math.floor(Math.random() * 3); i++) {
|
||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||
}
|
||
// 3-6个数字
|
||
for (let i = 0; i < 3 + Math.floor(Math.random() * 4); i++) {
|
||
result += nums.charAt(Math.floor(Math.random() * nums.length))
|
||
}
|
||
// 3-5个小写字母
|
||
for (let i = 0; i < 3 + Math.floor(Math.random() * 3); i++) {
|
||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||
}
|
||
return result
|
||
}
|
||
|
||
// 生成两级包名:com.xxxxx
|
||
const firstLevel = ['com', 'net', 'org', 'io'][Math.floor(Math.random() * 4)]
|
||
const secondLevel = randomString()
|
||
|
||
return `${firstLevel}.${secondLevel}`
|
||
}
|
||
|
||
/**
|
||
* 修改APK包名
|
||
*/
|
||
private async changePackageName(sourceApkPath: string, oldPackageName: string, newPackageName: string): Promise<void> {
|
||
try {
|
||
this.addBuildLog('info', `开始修改包名: ${oldPackageName} -> ${newPackageName}`)
|
||
|
||
// 1. 修改AndroidManifest.xml
|
||
const manifestPath = path.join(sourceApkPath, 'AndroidManifest.xml')
|
||
if (fs.existsSync(manifestPath)) {
|
||
let manifestContent = fs.readFileSync(manifestPath, 'utf8')
|
||
|
||
// 替换package属性
|
||
manifestContent = manifestContent.replace(
|
||
new RegExp(`package=["']${oldPackageName.replace(/\./g, '\\.')}["']`, 'g'),
|
||
`package="${newPackageName}"`
|
||
)
|
||
|
||
// 替换所有包名引用(在android:name等属性中)
|
||
const oldPackageRegex = new RegExp(oldPackageName.replace(/\./g, '\\.'), 'g')
|
||
manifestContent = manifestContent.replace(oldPackageRegex, newPackageName)
|
||
|
||
fs.writeFileSync(manifestPath, manifestContent, 'utf8')
|
||
this.addBuildLog('info', 'AndroidManifest.xml已更新')
|
||
}
|
||
|
||
// 2. 先更新所有smali文件中的包名引用(必须在重命名目录之前)
|
||
// 这是关键步骤:先更新文件内容,再重命名目录,避免引用不匹配
|
||
this.addBuildLog('info', '开始更新所有smali文件中的包名引用(关键步骤:先更新文件内容)...')
|
||
await this.updateAllSmaliFiles(sourceApkPath, oldPackageName, newPackageName)
|
||
this.addBuildLog('success', '所有smali文件中的包名引用已更新')
|
||
|
||
// 3. 重命名smali目录结构(使用复制+删除方式,避免Windows权限问题)
|
||
this.addBuildLog('info', '开始重命名smali目录结构...')
|
||
await this.renameAllSmaliDirectories(sourceApkPath, oldPackageName, newPackageName)
|
||
|
||
// 4. 更新apktool.yml文件(如果存在)
|
||
const apktoolYmlPath = path.join(sourceApkPath, 'apktool.yml')
|
||
if (fs.existsSync(apktoolYmlPath)) {
|
||
try {
|
||
let ymlContent = fs.readFileSync(apktoolYmlPath, 'utf8')
|
||
const oldPackageRegex = new RegExp(oldPackageName.replace(/\./g, '\\.'), 'g')
|
||
if (ymlContent.includes(oldPackageName)) {
|
||
ymlContent = ymlContent.replace(oldPackageRegex, newPackageName)
|
||
fs.writeFileSync(apktoolYmlPath, ymlContent, 'utf8')
|
||
this.addBuildLog('info', 'apktool.yml已更新')
|
||
}
|
||
} catch (error: any) {
|
||
this.addBuildLog('warn', `更新apktool.yml失败: ${error.message}`)
|
||
}
|
||
}
|
||
|
||
// 5. 替换其他可能包含包名的文件
|
||
// 检查res目录下的XML文件
|
||
const resDir = path.join(sourceApkPath, 'res')
|
||
if (fs.existsSync(resDir)) {
|
||
this.addBuildLog('info', '检查res目录中的包名引用...')
|
||
await this.replacePackageNameInDirectory(resDir, oldPackageName, newPackageName, ['.xml'])
|
||
}
|
||
|
||
this.addBuildLog('success', '包名修改完成')
|
||
} catch (error: any) {
|
||
this.addBuildLog('error', `修改包名失败: ${error.message}`)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 复制目录(递归)
|
||
*/
|
||
private async copyDirectory(src: string, dest: string): Promise<void> {
|
||
// 确保目标目录存在
|
||
if (!fs.existsSync(dest)) {
|
||
fs.mkdirSync(dest, { recursive: true })
|
||
}
|
||
|
||
const entries = fs.readdirSync(src, { withFileTypes: true })
|
||
|
||
for (const entry of entries) {
|
||
const srcPath = path.join(src, entry.name)
|
||
const destPath = path.join(dest, entry.name)
|
||
|
||
if (entry.isDirectory()) {
|
||
await this.copyDirectory(srcPath, destPath)
|
||
} else {
|
||
fs.copyFileSync(srcPath, destPath)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除目录(带重试机制,跨平台兼容)
|
||
*/
|
||
private async deleteDirectoryWithRetry(dirPath: string, maxRetries: number = 3): Promise<void> {
|
||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||
try {
|
||
if (fs.existsSync(dirPath)) {
|
||
// 先尝试删除文件,再删除目录
|
||
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
||
|
||
for (const entry of entries) {
|
||
const entryPath = path.join(dirPath, entry.name)
|
||
if (entry.isDirectory()) {
|
||
await this.deleteDirectoryWithRetry(entryPath, maxRetries)
|
||
} else {
|
||
// 尝试删除文件,如果失败则等待后重试
|
||
let fileDeleted = false
|
||
for (let fileAttempt = 1; fileAttempt <= maxRetries; fileAttempt++) {
|
||
try {
|
||
fs.unlinkSync(entryPath)
|
||
fileDeleted = true
|
||
break
|
||
} catch (error: any) {
|
||
if (fileAttempt < maxRetries) {
|
||
this.addBuildLog('warn', `删除文件失败,等待后重试 (${fileAttempt}/${maxRetries}): ${entryPath}`)
|
||
await new Promise(resolve => setTimeout(resolve, 500 * fileAttempt))
|
||
} else {
|
||
throw error
|
||
}
|
||
}
|
||
}
|
||
if (!fileDeleted) {
|
||
throw new Error(`无法删除文件: ${entryPath}`)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 删除空目录(跨平台兼容)
|
||
try {
|
||
fs.rmdirSync(dirPath)
|
||
} catch (rmdirError: any) {
|
||
// 如果rmdirSync失败,尝试使用rmSync(Node.js 14.14.0+)
|
||
if (typeof (fs as any).rmSync === 'function') {
|
||
try {
|
||
(fs as any).rmSync(dirPath, { recursive: true, force: true })
|
||
} catch (rmError: any) {
|
||
throw rmdirError // 如果都失败,抛出原始错误
|
||
}
|
||
} else {
|
||
throw rmdirError
|
||
}
|
||
}
|
||
return
|
||
}
|
||
} catch (error: any) {
|
||
if (attempt < maxRetries) {
|
||
this.addBuildLog('warn', `删除目录失败,等待后重试 (${attempt}/${maxRetries}): ${dirPath}`)
|
||
await new Promise(resolve => setTimeout(resolve, 1000 * attempt))
|
||
} else {
|
||
this.addBuildLog('error', `删除目录失败,已重试${maxRetries}次: ${dirPath}`)
|
||
// 不抛出错误,继续执行(可能被其他进程占用)
|
||
this.addBuildLog('warn', '目录可能被其他进程占用,将在后续清理中处理')
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清理空的目录
|
||
*/
|
||
private cleanupEmptyDirectories(baseDir: string, packageParts: string[]): void {
|
||
let currentDir = baseDir
|
||
for (let i = packageParts.length - 1; i >= 0; i--) {
|
||
currentDir = path.join(currentDir, packageParts[i])
|
||
if (fs.existsSync(currentDir)) {
|
||
try {
|
||
const files = fs.readdirSync(currentDir)
|
||
if (files.length === 0) {
|
||
fs.rmdirSync(currentDir)
|
||
this.addBuildLog('info', `已删除空目录: ${currentDir}`)
|
||
} else {
|
||
break
|
||
}
|
||
} catch {
|
||
// 忽略错误
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 更新所有smali文件中的包名引用(包括smali和smali_classes*目录)
|
||
*/
|
||
private async updateAllSmaliFiles(sourceApkPath: string, oldPackageName: string, newPackageName: string): Promise<void> {
|
||
const oldPackageSmali = oldPackageName.replace(/\./g, '/')
|
||
const newPackageSmali = newPackageName.replace(/\./g, '/')
|
||
|
||
// 处理主smali目录
|
||
const smaliDir = path.join(sourceApkPath, 'smali')
|
||
if (fs.existsSync(smaliDir)) {
|
||
this.addBuildLog('info', '更新smali目录中的文件...')
|
||
await this.replacePackageNameInSmaliFiles(smaliDir, oldPackageName, newPackageName, oldPackageSmali, newPackageSmali)
|
||
}
|
||
|
||
// 处理smali_classes2, smali_classes3等目录(如果有)
|
||
for (let i = 2; i <= 10; i++) {
|
||
const smaliClassDir = path.join(sourceApkPath, `smali_classes${i}`)
|
||
if (fs.existsSync(smaliClassDir)) {
|
||
this.addBuildLog('info', `更新smali_classes${i}目录中的文件...`)
|
||
await this.replacePackageNameInSmaliFiles(smaliClassDir, oldPackageName, newPackageName, oldPackageSmali, newPackageSmali)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 重命名所有smali目录结构(包括smali和smali_classes*目录)
|
||
*/
|
||
private async renameAllSmaliDirectories(sourceApkPath: string, oldPackageName: string, newPackageName: string): Promise<void> {
|
||
const oldPackagePath = oldPackageName.split('.')
|
||
const newPackagePath = newPackageName.split('.')
|
||
|
||
// 处理主smali目录
|
||
const smaliDir = path.join(sourceApkPath, 'smali')
|
||
if (fs.existsSync(smaliDir)) {
|
||
await this.renameSmaliDirectory(smaliDir, oldPackagePath, newPackagePath)
|
||
}
|
||
|
||
// 处理smali_classes2, smali_classes3等目录
|
||
for (let i = 2; i <= 10; i++) {
|
||
const smaliClassDir = path.join(sourceApkPath, `smali_classes${i}`)
|
||
if (fs.existsSync(smaliClassDir)) {
|
||
await this.renameSmaliDirectory(smaliClassDir, oldPackagePath, newPackagePath)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 重命名单个smali目录
|
||
*/
|
||
private async renameSmaliDirectory(smaliDir: string, oldPackagePath: string[], newPackagePath: string[]): Promise<void> {
|
||
const oldSmaliPath = path.join(smaliDir, ...oldPackagePath)
|
||
const newSmaliPath = path.join(smaliDir, ...newPackagePath)
|
||
|
||
if (fs.existsSync(oldSmaliPath)) {
|
||
// 确保新目录的父目录存在
|
||
const newSmaliParent = path.dirname(newSmaliPath)
|
||
if (!fs.existsSync(newSmaliParent)) {
|
||
fs.mkdirSync(newSmaliParent, { recursive: true })
|
||
}
|
||
|
||
// 如果新目录已存在,先删除(跨平台兼容)
|
||
if (fs.existsSync(newSmaliPath)) {
|
||
this.addBuildLog('info', '删除已存在的新目录...')
|
||
await this.deleteDirectoryWithRetry(newSmaliPath, 1)
|
||
}
|
||
|
||
// 使用复制+删除方式,避免Windows权限问题(跨平台兼容)
|
||
// 使用path.sep显示路径,但smali路径始终使用/(Android标准)
|
||
const displayOldPath = oldPackagePath.join('/')
|
||
const displayNewPath = newPackagePath.join('/')
|
||
this.addBuildLog('info', `复制目录: ${displayOldPath} -> ${displayNewPath}`)
|
||
await this.copyDirectory(oldSmaliPath, newSmaliPath)
|
||
|
||
// 删除旧目录(使用重试机制)
|
||
this.addBuildLog('info', '删除旧目录...')
|
||
await this.deleteDirectoryWithRetry(oldSmaliPath, 3)
|
||
|
||
this.addBuildLog('success', `smali目录已重命名: ${oldPackagePath.join('.')} -> ${newPackagePath.join('.')}`)
|
||
|
||
// 清理空的旧目录
|
||
this.cleanupEmptyDirectories(smaliDir, oldPackagePath)
|
||
} else {
|
||
this.addBuildLog('warn', `旧目录不存在: ${oldSmaliPath}`)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 递归替换smali文件中的包名
|
||
*/
|
||
private async replacePackageNameInSmaliFiles(dir: string, oldPackageName: string, newPackageName: string, oldPackageSmali?: string, newPackageSmali?: string): Promise<void> {
|
||
// 如果没有提供smali格式的包名,自动生成
|
||
if (!oldPackageSmali) {
|
||
oldPackageSmali = oldPackageName.replace(/\./g, '/')
|
||
}
|
||
if (!newPackageSmali) {
|
||
newPackageSmali = newPackageName.replace(/\./g, '/')
|
||
}
|
||
const files = fs.readdirSync(dir)
|
||
|
||
for (const file of files) {
|
||
const filePath = path.join(dir, file)
|
||
const stat = fs.statSync(filePath)
|
||
|
||
if (stat.isDirectory()) {
|
||
await this.replacePackageNameInSmaliFiles(filePath, oldPackageName, newPackageName)
|
||
} else if (file.endsWith('.smali')) {
|
||
try {
|
||
let content = fs.readFileSync(filePath, 'utf8')
|
||
|
||
// 替换包名引用(Lcom/hikoncont/... -> L新包名/...)
|
||
const oldPackagePath = oldPackageName.replace(/\./g, '/')
|
||
const newPackagePath = newPackageName.replace(/\./g, '/')
|
||
|
||
// 1. 替换类定义中的包名(.class public Lcom/hikoncont/...)
|
||
content = content.replace(
|
||
new RegExp(`\\.class[^\\n]*L${oldPackagePath.replace(/\//g, '\\/')}/`, 'g'),
|
||
(match) => match.replace(`L${oldPackagePath}/`, `L${newPackagePath}/`)
|
||
)
|
||
|
||
// 2. 替换类路径引用(Lcom/hikoncont/... -> L新包名/...)
|
||
// 使用单词边界确保不会误替换
|
||
content = content.replace(
|
||
new RegExp(`L${oldPackagePath.replace(/\//g, '\\/')}/`, 'g'),
|
||
`L${newPackagePath}/`
|
||
)
|
||
|
||
// 3. 替换完整类名引用(com.hikoncont.ClassName -> 新包名.ClassName)
|
||
content = content.replace(
|
||
new RegExp(oldPackageName.replace(/\./g, '\\.'), 'g'),
|
||
newPackageName
|
||
)
|
||
|
||
// 4. 替换字符串中的包名引用("com.hikoncont" -> "新包名")
|
||
content = content.replace(
|
||
new RegExp(`"${oldPackageName.replace(/\./g, '\\.')}"`, 'g'),
|
||
`"${newPackageName}"`
|
||
)
|
||
|
||
// 5. 替换字符串中的包名引用('com.hikoncont' -> '新包名')
|
||
content = content.replace(
|
||
new RegExp(`'${oldPackageName.replace(/\./g, '\\.')}'`, 'g'),
|
||
`'${newPackageName}'`
|
||
)
|
||
|
||
fs.writeFileSync(filePath, content, 'utf8')
|
||
} catch (error: any) {
|
||
this.addBuildLog('warn', `替换文件失败 ${filePath}: ${error.message}`)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 在目录中递归替换包名(用于XML等文件)
|
||
*/
|
||
private async replacePackageNameInDirectory(dir: string, oldPackageName: string, newPackageName: string, extensions: string[]): Promise<void> {
|
||
if (!fs.existsSync(dir)) {
|
||
return
|
||
}
|
||
|
||
const files = fs.readdirSync(dir)
|
||
|
||
for (const file of files) {
|
||
const filePath = path.join(dir, file)
|
||
const stat = fs.statSync(filePath)
|
||
|
||
if (stat.isDirectory()) {
|
||
await this.replacePackageNameInDirectory(filePath, oldPackageName, newPackageName, extensions)
|
||
} else {
|
||
const ext = path.extname(file)
|
||
if (extensions.includes(ext)) {
|
||
try {
|
||
let content = fs.readFileSync(filePath, 'utf8')
|
||
const oldPackageRegex = new RegExp(oldPackageName.replace(/\./g, '\\.'), 'g')
|
||
if (content.includes(oldPackageName)) {
|
||
content = content.replace(oldPackageRegex, newPackageName)
|
||
fs.writeFileSync(filePath, content, 'utf8')
|
||
}
|
||
} catch (error: any) {
|
||
// 忽略错误
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查构建环境(用于apktool打包)
|
||
*/
|
||
async checkBuildEnvironment(): Promise<{
|
||
hasJava: boolean
|
||
javaVersion?: string
|
||
errors: string[]
|
||
}> {
|
||
const result = {
|
||
hasJava: false,
|
||
javaVersion: undefined as string | undefined,
|
||
errors: [] as string[]
|
||
}
|
||
|
||
try {
|
||
// 检查Java(必需)
|
||
try {
|
||
const { stdout } = await execAsync('java -version', { timeout: 10000 })
|
||
result.hasJava = true
|
||
result.javaVersion = stdout.split('\n')[0]
|
||
} catch {
|
||
result.errors.push('Java未安装或未在PATH中')
|
||
}
|
||
|
||
// 检查apktool
|
||
const apktoolPath = path.join(process.cwd(), 'android/apktool.jar')
|
||
if (!fs.existsSync(apktoolPath)) {
|
||
result.errors.push('apktool不存在: android/apktool.jar')
|
||
}
|
||
|
||
// 检查source.apk文件
|
||
const sourceApkFile = path.join(process.cwd(), 'android/source.apk')
|
||
if (!fs.existsSync(sourceApkFile)) {
|
||
result.errors.push('source.apk文件不存在: android/source.apk')
|
||
}
|
||
|
||
} catch (error: any) {
|
||
this.logger.error('检查构建环境失败:', error)
|
||
result.errors.push(error.message)
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* 销毁服务
|
||
*/
|
||
destroy(): void {
|
||
this.cloudflareService.destroy()
|
||
}
|
||
}
|
||
|
||
// 增强的构建状态接口
|
||
interface EnhancedBuildStatus extends BuildStatus {
|
||
activeShares?: Array<{
|
||
sessionId: string
|
||
filename: string
|
||
shareUrl: string
|
||
createdAt: string
|
||
expiresAt: string
|
||
isExpired: boolean
|
||
}>
|
||
}
|
||
|
||
// 增强的构建结果接口
|
||
interface BuildResult {
|
||
success: boolean
|
||
message: string
|
||
filename?: string
|
||
shareUrl?: string
|
||
shareExpiresAt?: string
|
||
sessionId?: string
|
||
shareError?: string
|
||
}
|
||
|
||
// 构建状态接口
|
||
interface BuildStatus {
|
||
isBuilding: boolean
|
||
progress: number
|
||
message: string
|
||
success: boolean
|
||
shareUrl?: string
|
||
shareSessionId?: string
|
||
shareExpiresAt?: string
|
||
}
|