android-decompile-mcp 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,419 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
+ import { join } from "node:path";
6
+ import { textResult } from "./util.js";
7
+ import { listDevices, listPackages, resolveSerial } from "./adb.js";
8
+ import { detectPacker } from "./detect.js";
9
+ import { staticUnpack, pullApkFromDevice } from "./unpack/static.js";
10
+ import { fridaUnpack, fridaPrepare } from "./unpack/frida.js";
11
+ import { blackdexPrepare, blackdexPull } from "./unpack/blackdex.js";
12
+ import { dexToSmali, smaliListClasses, smaliReadClass, smaliSearch, } from "./smali.js";
13
+ import { findAppEntry, formatAppEntry } from "./entry.js";
14
+ import { apkDecode, apkRepack } from "./repack.js";
15
+ import { smaliPatch, formatSmaliPatch } from "./smali_patch.js";
16
+ const DEFAULT_OUT = process.env.DEX_OUT_DIR || join(process.cwd(), "unpack-output");
17
+ const TOOLS = [
18
+ {
19
+ name: "list_devices",
20
+ description: "列出当前通过 ADB 连接的安卓设备及其状态。",
21
+ inputSchema: { type: "object", properties: {} },
22
+ },
23
+ {
24
+ name: "list_packages",
25
+ description: "列出设备上已安装的应用包名,可用关键字过滤。",
26
+ inputSchema: {
27
+ type: "object",
28
+ properties: {
29
+ keyword: { type: "string", description: "按包名关键字过滤" },
30
+ serial: { type: "string", description: "设备 serial(多设备时必填)" },
31
+ },
32
+ },
33
+ },
34
+ {
35
+ name: "detect_packer",
36
+ description: "识别 APK 是否被加固及加固厂商(360/腾讯/梆梆/爱加密等)。可传本地 apkPath,或传 pkg 自动从设备拉取后检测。",
37
+ inputSchema: {
38
+ type: "object",
39
+ properties: {
40
+ apkPath: { type: "string", description: "本地 APK 路径" },
41
+ pkg: { type: "string", description: "设备上应用包名(将先拉取 APK)" },
42
+ serial: { type: "string" },
43
+ },
44
+ },
45
+ },
46
+ {
47
+ name: "pull_apk",
48
+ description: "从设备拉取已安装应用的 APK(含 split)到本机。",
49
+ inputSchema: {
50
+ type: "object",
51
+ properties: {
52
+ pkg: { type: "string", description: "应用包名" },
53
+ outDir: { type: "string", description: "输出目录,默认 unpack-output" },
54
+ serial: { type: "string" },
55
+ },
56
+ required: ["pkg"],
57
+ },
58
+ },
59
+ {
60
+ name: "static_unpack",
61
+ description: "静态解包:解压 APK 提取所有 classes*.dex。可传 apkPath 或 pkg(自动拉取)。加固 APK 得到的是壳 dex。",
62
+ inputSchema: {
63
+ type: "object",
64
+ properties: {
65
+ apkPath: { type: "string" },
66
+ pkg: { type: "string" },
67
+ outDir: { type: "string" },
68
+ serial: { type: "string" },
69
+ },
70
+ },
71
+ },
72
+ {
73
+ name: "frida_prepare",
74
+ description: "root 方案准备:推送并启动 frida-server(默认 tools/frida-server,需 Magisk/su)。",
75
+ inputSchema: {
76
+ type: "object",
77
+ properties: {
78
+ serial: { type: "string" },
79
+ serverLocalPath: { type: "string", description: "本机 frida-server 路径" },
80
+ },
81
+ },
82
+ },
83
+ {
84
+ name: "frida_unpack",
85
+ description: "root 方案:用 frida-dexdump 从内存 dump 已解密 dex。需设备已 root + frida-server 运行 + 本机安装 frida-dexdump。",
86
+ inputSchema: {
87
+ type: "object",
88
+ properties: {
89
+ pkg: { type: "string", description: "目标应用包名" },
90
+ outDir: { type: "string" },
91
+ serial: { type: "string" },
92
+ spawn: {
93
+ type: "boolean",
94
+ description: "true=由 frida 启动应用(脱壳更完整),false=按包名 attach",
95
+ },
96
+ deepSearch: { type: "boolean", description: "深度搜索,默认 true" },
97
+ },
98
+ required: ["pkg"],
99
+ },
100
+ },
101
+ {
102
+ name: "dex_to_smali",
103
+ description: "把 dex 反编译为 smali。dexInput 可为单个 .dex 文件或包含多个 dex 的目录(如脱壳输出目录)。",
104
+ inputSchema: {
105
+ type: "object",
106
+ properties: {
107
+ dexInput: { type: "string", description: ".dex 文件或含 dex 的目录" },
108
+ outDir: { type: "string", description: "smali 输出目录,默认 <out>/smali" },
109
+ },
110
+ required: ["dexInput"],
111
+ },
112
+ },
113
+ {
114
+ name: "smali_list_classes",
115
+ description: "列出 smali 目录下所有类名,可用关键字过滤。",
116
+ inputSchema: {
117
+ type: "object",
118
+ properties: {
119
+ smaliDir: { type: "string", description: "smali 根目录" },
120
+ keyword: { type: "string" },
121
+ },
122
+ required: ["smaliDir"],
123
+ },
124
+ },
125
+ {
126
+ name: "smali_read_class",
127
+ description: "读取指定类的 smali 源码。className 支持 com.foo.Bar 或 com/foo/Bar。",
128
+ inputSchema: {
129
+ type: "object",
130
+ properties: {
131
+ smaliDir: { type: "string" },
132
+ className: { type: "string" },
133
+ },
134
+ required: ["smaliDir", "className"],
135
+ },
136
+ },
137
+ {
138
+ name: "smali_search",
139
+ description: "在 smali 目录中按正则搜索内容(用于分析 API 调用、字符串、加解密、权限等)。返回 文件:行号 命中。",
140
+ inputSchema: {
141
+ type: "object",
142
+ properties: {
143
+ smaliDir: { type: "string" },
144
+ pattern: { type: "string", description: "正则表达式" },
145
+ flags: { type: "string", description: "正则 flags,默认 i" },
146
+ maxResults: { type: "number", description: "最大命中数,默认 500" },
147
+ },
148
+ required: ["smaliDir", "pattern"],
149
+ },
150
+ },
151
+ {
152
+ name: "blackdex_prepare",
153
+ description: "免 root 方案第一步:在设备安装并启动 BlackDex(需提供其 APK 路径)。随后在设备上选目标应用脱壳。",
154
+ inputSchema: {
155
+ type: "object",
156
+ properties: {
157
+ blackdexApkPath: { type: "string", description: "BlackDex APK 本地路径" },
158
+ serial: { type: "string" },
159
+ },
160
+ },
161
+ },
162
+ {
163
+ name: "blackdex_pull",
164
+ description: "免 root 方案第二步:把 BlackDex 脱壳 dump 出的 dex 从设备拉回本机。",
165
+ inputSchema: {
166
+ type: "object",
167
+ properties: {
168
+ outDir: { type: "string" },
169
+ targetPkg: { type: "string", description: "目标应用包名(用于定位 dump 子目录)" },
170
+ serial: { type: "string" },
171
+ },
172
+ },
173
+ },
174
+ {
175
+ name: "find_app_entry",
176
+ description: "定位应用入口(只读):解析 MAIN/LAUNCHER Activity、Application 类名,并尽量给出对应 smali 路径与 onCreate 行号。不修改任何文件、不注入代码。",
177
+ inputSchema: {
178
+ type: "object",
179
+ properties: {
180
+ projectDir: {
181
+ type: "string",
182
+ description: "apktool 解码后的工程目录(含 AndroidManifest.xml / smali*)",
183
+ },
184
+ manifestPath: { type: "string", description: "已解码的 AndroidManifest.xml 路径" },
185
+ apkPath: {
186
+ type: "string",
187
+ description: "原始 APK(需本机 aapt/aapt2;仅能拿到 launchable-activity)",
188
+ },
189
+ smaliDir: {
190
+ type: "string",
191
+ description: "可选 smali 根目录,用于定位入口 Activity 的 onCreate",
192
+ },
193
+ },
194
+ },
195
+ },
196
+ {
197
+ name: "apk_decode",
198
+ description: "用 apktool 解码 APK 为可编辑工程目录(AndroidManifest.xml + smali/resources)。需 tools/apktool*.jar 或 PATH 中的 apktool。",
199
+ inputSchema: {
200
+ type: "object",
201
+ properties: {
202
+ apkPath: { type: "string", description: "本地 APK 路径" },
203
+ outDir: {
204
+ type: "string",
205
+ description: "输出工程目录,默认 unpack-output/apktool/<apk名>",
206
+ },
207
+ },
208
+ required: ["apkPath"],
209
+ },
210
+ },
211
+ {
212
+ name: "apk_repack",
213
+ description: "将 apktool 工程重新打包并对齐/签名,输出可安装 APK。通用 rebuild,不注入业务代码。默认使用 tools/debug.keystore(自动生成)。",
214
+ inputSchema: {
215
+ type: "object",
216
+ properties: {
217
+ projectDir: { type: "string", description: "apktool 工程目录" },
218
+ outApk: {
219
+ type: "string",
220
+ description: "输出签名 APK 路径,默认 unpack-output/repack/<名>-signed.apk",
221
+ },
222
+ keystore: { type: "string", description: "可选 keystore 路径" },
223
+ alias: { type: "string", description: "证书别名,默认 androiddebugkey" },
224
+ storepass: { type: "string", description: "keystore 密码,默认 android" },
225
+ keypass: { type: "string", description: "key 密码,默认同 storepass" },
226
+ },
227
+ required: ["projectDir"],
228
+ },
229
+ },
230
+ {
231
+ name: "smali_patch",
232
+ description: "通用修改 smali(学习/验证重打包):可定位入口 Activity,按 mode 写入调用方提供的片段,或插入内置 Log 验证标记。不生成广告/外链模板。仅用于有权修改的自有或授权工程。",
233
+ inputSchema: {
234
+ type: "object",
235
+ properties: {
236
+ smaliPath: { type: "string", description: "直接指定 .smali 文件" },
237
+ projectDir: {
238
+ type: "string",
239
+ description: "apktool 工程;未传 smaliPath 时自动定位 MAIN/LAUNCHER",
240
+ },
241
+ className: { type: "string", description: "可选,指定类名(如 com.foo.MainActivity)" },
242
+ smaliDir: { type: "string" },
243
+ mode: {
244
+ type: "string",
245
+ description: "insert_verify_log | insert_after_onCreate_super | insert_at_line | replace",
246
+ },
247
+ content: {
248
+ type: "string",
249
+ description: "调用方提供的 smali 文本(insert_after_onCreate_super / insert_at_line / replace)",
250
+ },
251
+ find: { type: "string", description: "replace 模式的查找原文" },
252
+ line: { type: "number", description: "insert_at_line:在该行之后插入(1-based)" },
253
+ dryRun: { type: "boolean", description: "true=只预览不写盘" },
254
+ backup: { type: "boolean", description: "写盘前备份 .bak,默认 true" },
255
+ },
256
+ required: ["mode"],
257
+ },
258
+ },
259
+ ];
260
+ const server = new Server({ name: "android-decompile-mcp", version: "1.0.0" }, { capabilities: { tools: {} } });
261
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
262
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
263
+ const { name } = req.params;
264
+ const args = (req.params.arguments ?? {});
265
+ try {
266
+ switch (name) {
267
+ case "list_devices": {
268
+ const d = await listDevices();
269
+ return textResult(d.length
270
+ ? d.map((x) => `${x.serial}\t${x.state}\t${x.model ?? ""}`).join("\n")
271
+ : "未检测到设备。");
272
+ }
273
+ case "list_packages": {
274
+ const s = await resolveSerial(args.serial);
275
+ const pkgs = await listPackages(s, args.keyword);
276
+ return textResult(`共 ${pkgs.length} 个包:\n` + pkgs.join("\n"));
277
+ }
278
+ case "detect_packer": {
279
+ let apk = args.apkPath;
280
+ if (!apk && args.pkg) {
281
+ const { baseApk } = await pullApkFromDevice(args.pkg, DEFAULT_OUT, args.serial);
282
+ apk = baseApk;
283
+ }
284
+ if (!apk)
285
+ throw new Error("请提供 apkPath 或 pkg。");
286
+ const r = detectPacker(apk);
287
+ return textResult(`APK: ${apk}\n` +
288
+ `是否加固: ${r.packed ? "是" : "否"}\n` +
289
+ `加固厂商: ${r.packers.join(", ") || "未识别"}\n` +
290
+ `dex 数量: ${r.dexCount},条目总数: ${r.entryCount}\n` +
291
+ (r.hits.length ? `命中特征:\n` + r.hits.map((h) => ` ${h.packer} <= ${h.entry}`).join("\n") : ""));
292
+ }
293
+ case "pull_apk": {
294
+ const r = await pullApkFromDevice(args.pkg, args.outDir || DEFAULT_OUT, args.serial);
295
+ return textResult(`已拉取 APK:\n${r.localApks.join("\n")}\n主 APK: ${r.baseApk}`);
296
+ }
297
+ case "static_unpack": {
298
+ let apk = args.apkPath;
299
+ if (!apk && args.pkg) {
300
+ const { baseApk } = await pullApkFromDevice(args.pkg, args.outDir || DEFAULT_OUT, args.serial);
301
+ apk = baseApk;
302
+ }
303
+ if (!apk)
304
+ throw new Error("请提供 apkPath 或 pkg。");
305
+ const r = await staticUnpack(apk, args.outDir || DEFAULT_OUT);
306
+ return textResult(`已解包: ${r.apk}\n输出目录: ${r.outDir}\ndex 文件:\n${r.dexFiles.join("\n")}\n${r.note}`);
307
+ }
308
+ case "frida_prepare": {
309
+ const r = await fridaPrepare(args.serial, args.serverLocalPath);
310
+ return textResult(`设备: ${r.serial}\n运行中: ${r.running}\n${r.message}`);
311
+ }
312
+ case "frida_unpack": {
313
+ const r = await fridaUnpack(args.pkg, args.outDir || DEFAULT_OUT, {
314
+ serial: args.serial,
315
+ spawn: args.spawn,
316
+ deepSearch: args.deepSearch,
317
+ });
318
+ return textResult(`frida-dexdump 退出码: ${r.code}\n${r.dexHint}\n--- 日志 ---\n${r.log}`);
319
+ }
320
+ case "dex_to_smali": {
321
+ const out = args.outDir || join(DEFAULT_OUT, "smali");
322
+ const r = await dexToSmali(args.dexInput, out);
323
+ return textResult(`已反编译 ${r.dexFiles.length} 个 dex\n` +
324
+ `smali 输出目录:\n${r.classDirs.join("\n")}\n` +
325
+ `可用 smali_list_classes / smali_read_class / smali_search 进一步分析。`);
326
+ }
327
+ case "smali_list_classes": {
328
+ const classes = await smaliListClasses(args.smaliDir, args.keyword);
329
+ return textResult(`共 ${classes.length} 个类:\n` + classes.join("\n"));
330
+ }
331
+ case "smali_read_class": {
332
+ const src = await smaliReadClass(args.smaliDir, args.className);
333
+ return textResult(src);
334
+ }
335
+ case "smali_search": {
336
+ const m = await smaliSearch(args.smaliDir, args.pattern, {
337
+ flags: args.flags,
338
+ maxResults: args.maxResults,
339
+ });
340
+ return textResult(`命中 ${m.length} 处:\n` +
341
+ m.map((x) => `${x.file}:${x.line}: ${x.text}`).join("\n"));
342
+ }
343
+ case "blackdex_prepare": {
344
+ const r = await blackdexPrepare(args.blackdexApkPath, args.serial);
345
+ return textResult(`设备: ${r.serial}\n已安装: ${r.installed}\n已启动: ${r.launched}\n${r.message}`);
346
+ }
347
+ case "blackdex_pull": {
348
+ const r = await blackdexPull(args.outDir || DEFAULT_OUT, args.serial, args.targetPkg);
349
+ return textResult(`已拉取到: ${r.pulledTo}\n--- 远端结构 ---\n${r.listing}`);
350
+ }
351
+ case "find_app_entry": {
352
+ if (!args.projectDir && !args.manifestPath && !args.apkPath) {
353
+ throw new Error("请提供 projectDir、manifestPath 或 apkPath 之一。");
354
+ }
355
+ const info = await findAppEntry({
356
+ projectDir: args.projectDir,
357
+ manifestPath: args.manifestPath,
358
+ apkPath: args.apkPath,
359
+ smaliDir: args.smaliDir,
360
+ });
361
+ return textResult(formatAppEntry(info));
362
+ }
363
+ case "apk_decode": {
364
+ const r = await apkDecode(args.apkPath, args.outDir);
365
+ return textResult(`已解码\nAPK: ${r.apkPath}\n工程目录: ${r.projectDir}\n` +
366
+ `下一步可用 find_app_entry(projectDir=...) 定位入口,或 apk_repack 重新打包。\n` +
367
+ `--- apktool ---\n${r.log}`);
368
+ }
369
+ case "apk_repack": {
370
+ const r = await apkRepack({
371
+ projectDir: args.projectDir,
372
+ outApk: args.outApk,
373
+ keystore: args.keystore,
374
+ alias: args.alias,
375
+ storepass: args.storepass,
376
+ keypass: args.keypass,
377
+ });
378
+ return textResult(`重新打包完成\n工程: ${r.projectDir}\n未签名: ${r.unsignedApk}\n` +
379
+ `已签名 APK: ${r.signedApk}\nkeystore: ${r.keystore}\n--- 日志 ---\n${r.log}`);
380
+ }
381
+ case "smali_patch": {
382
+ const mode = args.mode;
383
+ if (!mode)
384
+ throw new Error("请提供 mode。");
385
+ if (!args.smaliPath && !args.projectDir && !args.className) {
386
+ throw new Error("请提供 smaliPath 或 projectDir(可加 className)。");
387
+ }
388
+ const r = await smaliPatch({
389
+ smaliPath: args.smaliPath,
390
+ projectDir: args.projectDir,
391
+ className: args.className,
392
+ smaliDir: args.smaliDir,
393
+ mode,
394
+ content: args.content,
395
+ find: args.find,
396
+ line: args.line,
397
+ dryRun: args.dryRun,
398
+ backup: args.backup,
399
+ });
400
+ return textResult(formatSmaliPatch(r));
401
+ }
402
+ default:
403
+ throw new Error(`未知工具: ${name}`);
404
+ }
405
+ }
406
+ catch (err) {
407
+ return { content: [{ type: "text", text: `错误: ${err?.message ?? err}` }], isError: true };
408
+ }
409
+ });
410
+ async function main() {
411
+ const transport = new StdioServerTransport();
412
+ await server.connect(transport);
413
+ // stderr 打印,避免污染 stdio 协议通道
414
+ console.error("[android-decompile-mcp] MCP server 已启动 (stdio)");
415
+ }
416
+ main().catch((e) => {
417
+ console.error("启动失败:", e);
418
+ process.exit(1);
419
+ });
package/dist/repack.js ADDED
@@ -0,0 +1,189 @@
1
+ import { join, resolve, dirname } from "node:path";
2
+ import { existsSync, readdirSync } from "node:fs";
3
+ import { copyFile, stat } from "node:fs/promises";
4
+ import { fileURLToPath } from "node:url";
5
+ import { exec, hasCommand, ensureDir } from "./util.js";
6
+ const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
7
+ async function ensureJava() {
8
+ if (!(await hasCommand("java"))) {
9
+ throw new Error("未找到 java,apktool / 签名需要 JRE。");
10
+ }
11
+ }
12
+ function findJar(envKey, patterns) {
13
+ if (process.env[envKey] && existsSync(process.env[envKey])) {
14
+ return process.env[envKey];
15
+ }
16
+ const toolsDir = join(PROJECT_ROOT, "tools");
17
+ if (!existsSync(toolsDir))
18
+ return undefined;
19
+ try {
20
+ const names = readdirSync(toolsDir);
21
+ for (const n of names) {
22
+ if (patterns.some((re) => re.test(n)))
23
+ return join(toolsDir, n);
24
+ }
25
+ }
26
+ catch {
27
+ /* ignore */
28
+ }
29
+ return undefined;
30
+ }
31
+ async function findApktoolJar() {
32
+ const jar = findJar("APKTOOL_JAR", [/^apktool.*\.jar$/i]);
33
+ if (jar)
34
+ return jar;
35
+ if (await hasCommand("apktool"))
36
+ return "apktool"; // wrapper script
37
+ throw new Error("未找到 apktool。请将 apktool*.jar 放到 tools/,或设置 APKTOOL_JAR,或安装 apktool 到 PATH。");
38
+ }
39
+ async function runApktool(args, timeoutMs = 300000) {
40
+ await ensureJava();
41
+ const tool = await findApktoolJar();
42
+ if (tool.endsWith(".jar") || tool.includes(".jar")) {
43
+ return exec("java", ["-jar", tool, ...args], { timeoutMs });
44
+ }
45
+ return exec(tool, args, { timeoutMs });
46
+ }
47
+ /** apktool d:解码 APK 为可编辑工程目录。 */
48
+ export async function apkDecode(apkPath, outDir) {
49
+ const st = await stat(apkPath).catch(() => null);
50
+ if (!st?.isFile())
51
+ throw new Error(`APK 不存在:${apkPath}`);
52
+ const projectDir = outDir || join(PROJECT_ROOT, "unpack-output", "apktool", basenameNoExt(apkPath));
53
+ await ensureDir(dirname(projectDir));
54
+ const r = await runApktool(["d", "-f", "-o", projectDir, apkPath]);
55
+ if (r.code !== 0) {
56
+ throw new Error(`apktool decode 失败 (code=${r.code})\n${r.stdout}\n${r.stderr}`);
57
+ }
58
+ return {
59
+ apkPath,
60
+ projectDir,
61
+ log: (r.stdout + "\n" + r.stderr).trim(),
62
+ };
63
+ }
64
+ function basenameNoExt(p) {
65
+ const base = p.replace(/\\/g, "/").split("/").pop() || "app";
66
+ return base.replace(/\.apk$/i, "");
67
+ }
68
+ async function ensureDebugKeystore(keystorePath) {
69
+ if (existsSync(keystorePath))
70
+ return;
71
+ await ensureDir(dirname(keystorePath));
72
+ if (!(await hasCommand("keytool"))) {
73
+ throw new Error("未找到 keytool,无法生成调试签名证书。请安装 JDK。");
74
+ }
75
+ const r = await exec("keytool", [
76
+ "-genkeypair",
77
+ "-v",
78
+ "-keystore",
79
+ keystorePath,
80
+ "-storepass",
81
+ "android",
82
+ "-keypass",
83
+ "android",
84
+ "-alias",
85
+ "androiddebugkey",
86
+ "-keyalg",
87
+ "RSA",
88
+ "-keysize",
89
+ "2048",
90
+ "-validity",
91
+ "10000",
92
+ "-dname",
93
+ "CN=Android Debug,O=Android,C=US",
94
+ ], { timeoutMs: 60000 });
95
+ if (r.code !== 0) {
96
+ throw new Error(`生成 debug keystore 失败:\n${r.stderr || r.stdout}`);
97
+ }
98
+ }
99
+ async function zipalign(inApk, outApk) {
100
+ if (!(await hasCommand("zipalign"))) {
101
+ await copyFile(inApk, outApk);
102
+ return "未找到 zipalign,已跳过对齐(直接复制)。";
103
+ }
104
+ const r = await exec("zipalign", ["-f", "4", inApk, outApk], { timeoutMs: 120000 });
105
+ if (r.code !== 0) {
106
+ throw new Error(`zipalign 失败:\n${r.stderr || r.stdout}`);
107
+ }
108
+ return "zipalign 完成。";
109
+ }
110
+ async function signApk(apkPath, outApk, keystore, alias, storepass, keypass) {
111
+ if (await hasCommand("apksigner")) {
112
+ const r = await exec("apksigner", [
113
+ "sign",
114
+ "--ks",
115
+ keystore,
116
+ "--ks-key-alias",
117
+ alias,
118
+ "--ks-pass",
119
+ `pass:${storepass}`,
120
+ "--key-pass",
121
+ `pass:${keypass}`,
122
+ "--out",
123
+ outApk,
124
+ apkPath,
125
+ ], { timeoutMs: 120000 });
126
+ if (r.code !== 0)
127
+ throw new Error(`apksigner 失败:\n${r.stderr || r.stdout}`);
128
+ return "apksigner 签名完成。";
129
+ }
130
+ // jarsigner 就地签名后复制
131
+ await copyFile(apkPath, outApk);
132
+ if (!(await hasCommand("jarsigner"))) {
133
+ throw new Error("未找到 apksigner 或 jarsigner,无法签名。请安装 Android build-tools 或 JDK。");
134
+ }
135
+ const r = await exec("jarsigner", [
136
+ "-sigalg",
137
+ "SHA256withRSA",
138
+ "-digestalg",
139
+ "SHA-256",
140
+ "-keystore",
141
+ keystore,
142
+ "-storepass",
143
+ storepass,
144
+ "-keypass",
145
+ keypass,
146
+ outApk,
147
+ alias,
148
+ ], { timeoutMs: 120000 });
149
+ if (r.code !== 0)
150
+ throw new Error(`jarsigner 失败:\n${r.stderr || r.stdout}`);
151
+ return "jarsigner 签名完成。";
152
+ }
153
+ /**
154
+ * 将 apktool 工程重新打包并签名,输出可安装 APK。
155
+ * 仅做通用 rebuild/sign,不注入任何业务代码。
156
+ */
157
+ export async function apkRepack(opts) {
158
+ const projectDir = resolve(opts.projectDir);
159
+ if (!existsSync(join(projectDir, "apktool.yml")) && !existsSync(join(projectDir, "AndroidManifest.xml"))) {
160
+ throw new Error(`不像 apktool 工程目录(缺少 apktool.yml / AndroidManifest.xml):${projectDir}`);
161
+ }
162
+ const buildDir = join(projectDir, "dist");
163
+ await ensureDir(buildDir);
164
+ const unsignedApk = join(buildDir, "unsigned.apk");
165
+ const build = await runApktool(["b", "-f", "-o", unsignedApk, projectDir]);
166
+ if (build.code !== 0) {
167
+ throw new Error(`apktool build 失败 (code=${build.code})\n${build.stdout}\n${build.stderr}`);
168
+ }
169
+ const logs = [(build.stdout + "\n" + build.stderr).trim()];
170
+ const alignedApk = join(buildDir, "aligned.apk");
171
+ logs.push(await zipalign(unsignedApk, alignedApk));
172
+ const keystore = opts.keystore || join(PROJECT_ROOT, "tools", "debug.keystore");
173
+ const alias = opts.alias || "androiddebugkey";
174
+ const storepass = opts.storepass || "android";
175
+ const keypass = opts.keypass || storepass;
176
+ await ensureDebugKeystore(keystore);
177
+ const signedApk = opts.outApk ||
178
+ join(PROJECT_ROOT, "unpack-output", "repack", `${basenameNoExt(projectDir)}-signed.apk`);
179
+ await ensureDir(dirname(signedApk));
180
+ logs.push(await signApk(alignedApk, signedApk, keystore, alias, storepass, keypass));
181
+ return {
182
+ projectDir,
183
+ unsignedApk,
184
+ alignedApk,
185
+ signedApk,
186
+ keystore,
187
+ log: logs.filter(Boolean).join("\n"),
188
+ };
189
+ }