@skrillex1224/android-toolkit 0.1.8 → 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/README.md +51 -64
- package/browser.d.ts +31 -0
- package/dist/browser.js +107 -0
- package/dist/browser.js.map +7 -0
- package/dist/index.cjs +1910 -0
- package/dist/index.cjs.map +7 -0
- package/dist/index.js +1880 -0
- package/dist/index.js.map +7 -0
- package/index.d.ts +157 -184
- package/package.json +28 -10
- package/entrys/node.js +0 -26
- package/index.js +0 -1
- package/scripts/postinstall.js +0 -72
- package/src/apify-kit.js +0 -217
- package/src/constants.js +0 -75
- package/src/context.js +0 -89
- package/src/device.js +0 -761
- package/src/errors.js +0 -37
- package/src/frida-client.js +0 -1068
- package/src/internals/compression.js +0 -188
- package/src/internals/frida/webview_event_agent.js +0 -227
- package/src/launch.js +0 -70
- package/src/logger.js +0 -111
- package/src/share.js +0 -644
package/src/frida-client.js
DELETED
|
@@ -1,1068 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import {spawn} from 'node:child_process';
|
|
3
|
-
|
|
4
|
-
import {Code} from './constants.js';
|
|
5
|
-
import {adbShell} from './device.js';
|
|
6
|
-
import {CrawlerError} from './errors.js';
|
|
7
|
-
import {Logger, sleep} from './logger.js';
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* 启动一个持续运行的 Frida WebView 事件记录器。
|
|
11
|
-
*
|
|
12
|
-
* 业务方负责传入:
|
|
13
|
-
* - 要 hook 的 WebView class 列表;
|
|
14
|
-
* - 要关心的事件名或关键词;
|
|
15
|
-
* - 输出文件路径。
|
|
16
|
-
*
|
|
17
|
-
* toolkit 只做三件事:
|
|
18
|
-
* 1. 找到目标 App 当前真实 pid;
|
|
19
|
-
* 2. 把业务配置和通用 Frida agent 合并成临时脚本;
|
|
20
|
-
* 3. 收集 agent 打出的 marker 行并在 stop() 时返回。
|
|
21
|
-
*/
|
|
22
|
-
export async function startWebViewEventRecorder(ctx, config = {}, options = {}) {
|
|
23
|
-
const startedAt = Date.now();
|
|
24
|
-
await ensureFridaServer(ctx);
|
|
25
|
-
assertFridaReady(ctx, ctx.fridaWebViewEventAgentScript, 'frida webview event agent script');
|
|
26
|
-
const marker = options.marker || 'ANDROID_WEBVIEW_EVENT_JSON ';
|
|
27
|
-
const scriptPath = writeConfiguredWebViewEventAgent(ctx, config);
|
|
28
|
-
const pid = await resolvePid(ctx, ctx.packageName);
|
|
29
|
-
Logger.info('frida webview recorder start', {
|
|
30
|
-
label: options.label || 'webview-event-recorder',
|
|
31
|
-
serial: ctx.serial,
|
|
32
|
-
packageName: ctx.packageName,
|
|
33
|
-
pid,
|
|
34
|
-
scriptPath
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
const recorder = startFridaLineRecorder(ctx.fridaPath, [
|
|
38
|
-
'-q',
|
|
39
|
-
'-t',
|
|
40
|
-
'inf',
|
|
41
|
-
'-D',
|
|
42
|
-
ctx.serial,
|
|
43
|
-
'-p',
|
|
44
|
-
pid,
|
|
45
|
-
'-l',
|
|
46
|
-
scriptPath
|
|
47
|
-
], {
|
|
48
|
-
marker,
|
|
49
|
-
label: options.label || 'webview-event-recorder',
|
|
50
|
-
maxLines: Number(options.maxLines || 6000),
|
|
51
|
-
outputPath: options.outputPath || ''
|
|
52
|
-
});
|
|
53
|
-
await recorder.waitForStartup(Number(options.startupTimeoutMs || 3000));
|
|
54
|
-
Logger.info('frida webview recorder ready', {duration: Logger.duration(startedAt), pid});
|
|
55
|
-
return recorder;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* 在目标 App 进程内部执行一段短 Frida 脚本,并返回 marker 输出。
|
|
60
|
-
*
|
|
61
|
-
* 适合做“从 App 内部启动非 exported Activity”“读取当前 Activity View 树”等
|
|
62
|
-
* 小动作。业务脚本必须自己 console.log marker JSON;toolkit 只负责 attach、
|
|
63
|
-
* 收集输出和错误显式化。
|
|
64
|
-
*/
|
|
65
|
-
export async function runScript(ctx, source, options = {}) {
|
|
66
|
-
const startedAt = Date.now();
|
|
67
|
-
const label = options.label || 'script';
|
|
68
|
-
await ensureFridaServer(ctx);
|
|
69
|
-
assertFridaReady(ctx, ctx.fridaWebViewEventAgentScript, 'frida webview event agent script');
|
|
70
|
-
const marker = options.marker || 'ANDROID_TOOLKIT_SCRIPT_JSON ';
|
|
71
|
-
const scriptPath = writeTempScript(ctx, source, label);
|
|
72
|
-
const pid = await resolvePid(ctx, options.packageName || ctx.packageName);
|
|
73
|
-
Logger.info('frida script start', {
|
|
74
|
-
label,
|
|
75
|
-
serial: ctx.serial,
|
|
76
|
-
packageName: options.packageName || ctx.packageName,
|
|
77
|
-
pid,
|
|
78
|
-
timeoutMs: options.timeoutMs || 10000
|
|
79
|
-
});
|
|
80
|
-
const recorder = startFridaLineRecorder(ctx.fridaPath, [
|
|
81
|
-
'-q',
|
|
82
|
-
'-t',
|
|
83
|
-
String(Number(options.fridaTimeoutSeconds || 5)),
|
|
84
|
-
'-D',
|
|
85
|
-
ctx.serial,
|
|
86
|
-
'-p',
|
|
87
|
-
pid,
|
|
88
|
-
'-l',
|
|
89
|
-
scriptPath
|
|
90
|
-
], {
|
|
91
|
-
marker,
|
|
92
|
-
label: options.label || 'script',
|
|
93
|
-
maxLines: Number(options.maxLines || 1200),
|
|
94
|
-
outputPath: options.outputPath || ''
|
|
95
|
-
});
|
|
96
|
-
await recorder.waitForExit(Number(options.timeoutMs || 10000));
|
|
97
|
-
const result = await recorder.stop();
|
|
98
|
-
const scriptEvents = result.events.filter((event) => event?.type !== 'parse_error');
|
|
99
|
-
if (scriptEvents.length === 0) {
|
|
100
|
-
Logger.warn('frida script produced no event', {
|
|
101
|
-
label,
|
|
102
|
-
duration: Logger.duration(startedAt),
|
|
103
|
-
lineCount: result.lines.length
|
|
104
|
-
});
|
|
105
|
-
throw new CrawlerError({
|
|
106
|
-
message: `frida_script_failed: ${label} did not emit ${marker.trim()}`,
|
|
107
|
-
code: Code.FridaUnavailable,
|
|
108
|
-
context: {lines: result.lines.slice(-40)}
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
const event = scriptEvents.at(-1);
|
|
112
|
-
Logger.info('frida script done', {
|
|
113
|
-
label,
|
|
114
|
-
duration: Logger.duration(startedAt),
|
|
115
|
-
eventCount: scriptEvents.length,
|
|
116
|
-
ok: event?.ok
|
|
117
|
-
});
|
|
118
|
-
return event;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* 在目标 App 进程内查询 SQLite。
|
|
123
|
-
*
|
|
124
|
-
* 这是通用底层能力,只负责:
|
|
125
|
-
* - 按 dbPath 或 dbDir/dbNamePrefix 找数据库文件;
|
|
126
|
-
* - 执行业务方传入的 SQL 和参数;
|
|
127
|
-
* - 把 cursor rows 作为字符串对象返回。
|
|
128
|
-
*
|
|
129
|
-
* toolkit 不理解表名、字段含义、消息关联关系;这些规则必须留在具体 androider。
|
|
130
|
-
*/
|
|
131
|
-
export async function querySQLite(ctx, options = {}) {
|
|
132
|
-
if (!options.sql) {
|
|
133
|
-
throw new CrawlerError({
|
|
134
|
-
message: 'sqlite_query_failed: sql is required',
|
|
135
|
-
code: Code.InvalidRequest
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const startedAt = Date.now();
|
|
140
|
-
const config = {
|
|
141
|
-
dbPath: String(options.dbPath || ''),
|
|
142
|
-
dbDir: String(options.dbDir || ''),
|
|
143
|
-
dbNamePrefix: String(options.dbNamePrefix || ''),
|
|
144
|
-
dbNameIncludes: String(options.dbNameIncludes || ''),
|
|
145
|
-
dbNameExcludes: normalizeStringArray(options.dbNameExcludes),
|
|
146
|
-
sql: String(options.sql || ''),
|
|
147
|
-
args: normalizeStringArray(options.args),
|
|
148
|
-
maxRows: Math.max(1, Number(options.maxRows || 200))
|
|
149
|
-
};
|
|
150
|
-
Logger.info('sqlite query start', {
|
|
151
|
-
label: options.label || 'query-sqlite',
|
|
152
|
-
dbPath: config.dbPath,
|
|
153
|
-
dbDir: config.dbDir,
|
|
154
|
-
maxRows: config.maxRows
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
const event = await runScript(ctx, `
|
|
158
|
-
Java.perform(function () {
|
|
159
|
-
function emit(payload) { console.log('ANDROID_TOOLKIT_SCRIPT_JSON ' + JSON.stringify(payload)); }
|
|
160
|
-
var config = ${JSON.stringify(config)};
|
|
161
|
-
var JavaString = Java.use('java.lang.String');
|
|
162
|
-
|
|
163
|
-
function safeString(cursor, index) {
|
|
164
|
-
try { return cursor.isNull(index) ? '' : String(cursor.getString(index)); } catch (_) { return ''; }
|
|
165
|
-
}
|
|
166
|
-
function stringArray(values) {
|
|
167
|
-
if (!values || values.length === 0) return null;
|
|
168
|
-
var out = [];
|
|
169
|
-
for (var i = 0; i < values.length; i++) out.push(String(values[i]));
|
|
170
|
-
return Java.array('java.lang.String', out);
|
|
171
|
-
}
|
|
172
|
-
function matchesName(name) {
|
|
173
|
-
if (config.dbNamePrefix && name.indexOf(config.dbNamePrefix) !== 0) return false;
|
|
174
|
-
if (config.dbNameIncludes && name.indexOf(config.dbNameIncludes) < 0) return false;
|
|
175
|
-
for (var i = 0; i < config.dbNameExcludes.length; i++) {
|
|
176
|
-
if (String(name).indexOf(config.dbNameExcludes[i]) >= 0) return false;
|
|
177
|
-
}
|
|
178
|
-
return true;
|
|
179
|
-
}
|
|
180
|
-
function dbPaths() {
|
|
181
|
-
if (config.dbPath) return [config.dbPath];
|
|
182
|
-
var File = Java.use('java.io.File');
|
|
183
|
-
var dir = File.$new(config.dbDir);
|
|
184
|
-
var files = dir.listFiles();
|
|
185
|
-
var out = [];
|
|
186
|
-
if (!files) return out;
|
|
187
|
-
for (var i = 0; i < files.length; i++) {
|
|
188
|
-
var name = String(files[i].getName());
|
|
189
|
-
if (matchesName(name)) out.push(String(files[i].getAbsolutePath()));
|
|
190
|
-
}
|
|
191
|
-
return out;
|
|
192
|
-
}
|
|
193
|
-
function queryOne(dbPath) {
|
|
194
|
-
var SQLiteDatabase = Java.use('android.database.sqlite.SQLiteDatabase');
|
|
195
|
-
var db = SQLiteDatabase.openDatabase(JavaString.$new(dbPath), null, 1);
|
|
196
|
-
try {
|
|
197
|
-
var cursor = db.rawQuery(JavaString.$new(config.sql), stringArray(config.args));
|
|
198
|
-
var columns = [];
|
|
199
|
-
var columnCount = cursor.getColumnCount();
|
|
200
|
-
for (var c = 0; c < columnCount; c++) columns.push(String(cursor.getColumnName(c)));
|
|
201
|
-
var rows = [];
|
|
202
|
-
var truncated = false;
|
|
203
|
-
while (cursor.moveToNext()) {
|
|
204
|
-
if (rows.length >= config.maxRows) {
|
|
205
|
-
truncated = true;
|
|
206
|
-
break;
|
|
207
|
-
}
|
|
208
|
-
var row = {};
|
|
209
|
-
for (var i = 0; i < columns.length; i++) row[columns[i]] = safeString(cursor, i);
|
|
210
|
-
rows.push(row);
|
|
211
|
-
}
|
|
212
|
-
cursor.close();
|
|
213
|
-
return { dbPath: dbPath, columns: columns, rows: rows, rowCount: rows.length, truncated: truncated };
|
|
214
|
-
} finally {
|
|
215
|
-
db.close();
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
try {
|
|
219
|
-
var paths = dbPaths();
|
|
220
|
-
if (paths.length === 0) {
|
|
221
|
-
emit({ ok: false, error: 'sqlite database not found', config: config });
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
var databases = [];
|
|
225
|
-
for (var d = 0; d < paths.length; d++) databases.push(queryOne(paths[d]));
|
|
226
|
-
emit({ ok: true, databases: databases, rows: databases.length === 1 ? databases[0].rows : [] });
|
|
227
|
-
} catch (error) {
|
|
228
|
-
emit({ ok: false, error: String(error), stack: String(error.stack || '') });
|
|
229
|
-
}
|
|
230
|
-
});
|
|
231
|
-
`, {
|
|
232
|
-
label: options.label || 'query-sqlite',
|
|
233
|
-
packageName: options.packageName || ctx.packageName,
|
|
234
|
-
timeoutMs: options.timeoutMs || 12000,
|
|
235
|
-
fridaTimeoutSeconds: options.fridaTimeoutSeconds || 10,
|
|
236
|
-
maxLines: options.maxLines || 1200
|
|
237
|
-
});
|
|
238
|
-
if (!event.ok) {
|
|
239
|
-
throw new CrawlerError({
|
|
240
|
-
message: `sqlite_query_failed: ${event.error || ''}`.trim(),
|
|
241
|
-
code: Code.ContentUnavailable,
|
|
242
|
-
context: event
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
Logger.info('sqlite query done', {
|
|
246
|
-
label: options.label || 'query-sqlite',
|
|
247
|
-
duration: Logger.duration(startedAt),
|
|
248
|
-
databaseCount: Array.isArray(event.databases) ? event.databases.length : 0,
|
|
249
|
-
rowCount: Array.isArray(event.rows) ? event.rows.length : 0
|
|
250
|
-
});
|
|
251
|
-
return event;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* 确保设备侧 frida-server 已经启动。
|
|
256
|
-
*
|
|
257
|
-
* 安卓采集链路依赖 host 侧 `frida -D <serial>` attach 到目标 App;
|
|
258
|
-
* 如果设备重启、frida-server 被系统杀掉,或者被普通 shell 用户启动,
|
|
259
|
-
* 后续 attach 会直接失败。这里在每次 Frida 调用前做轻量检查:
|
|
260
|
-
* - root 用户的 frida-server 已存在:直接复用;
|
|
261
|
-
* - 只有 shell 用户进程:用 su 清理后重启;
|
|
262
|
-
* - 没有进程:优先用 su 启动 `/data/local/tmp/frida-server`,再退回 shell 启动。
|
|
263
|
-
*/
|
|
264
|
-
export async function ensureFridaServer(ctx, options = {}) {
|
|
265
|
-
const serverPath = options.serverPath || ctx.fridaServerPath || '/data/local/tmp/frida-server';
|
|
266
|
-
const current = await listFridaServerRows(ctx);
|
|
267
|
-
if (current.some((row) => row.user === 'root')) {
|
|
268
|
-
Logger.debug('frida-server ready', {serial: ctx.serial, user: 'root'});
|
|
269
|
-
return true;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
const rootAvailable = await canUseSu(ctx);
|
|
273
|
-
if (current.length > 0 && rootAvailable) {
|
|
274
|
-
Logger.warn('frida-server 正在以非 root 用户运行,尝试切换为 root 进程');
|
|
275
|
-
await adbShell(ctx, ['su', '-c', 'pkill -f frida-server || true'], {timeoutMs: 5000}).catch(() => { });
|
|
276
|
-
await sleep(800);
|
|
277
|
-
} else if (current.length > 0) {
|
|
278
|
-
Logger.debug('frida-server ready', {serial: ctx.serial, user: 'shell'});
|
|
279
|
-
return true;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
const exists = await adbShell(ctx, ['ls', serverPath], {timeoutMs: 5000}).then(() => true).catch(() => false);
|
|
283
|
-
if (!exists) {
|
|
284
|
-
throw new CrawlerError({
|
|
285
|
-
message: `frida_unavailable: frida-server not found on device: ${serverPath}`,
|
|
286
|
-
code: Code.FridaUnavailable,
|
|
287
|
-
context: {serverPath}
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const command = `chmod 755 ${shellQuote(serverPath)} && ${shellQuote(serverPath)} >/dev/null 2>&1 &`;
|
|
292
|
-
if (rootAvailable) {
|
|
293
|
-
await adbShell(ctx, ['su', '-c', command], {timeoutMs: 5000}).catch(() => { });
|
|
294
|
-
} else {
|
|
295
|
-
await adbShell(ctx, ['sh', '-c', command], {timeoutMs: 5000}).catch(() => { });
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
await sleep(1500);
|
|
299
|
-
const after = await listFridaServerRows(ctx);
|
|
300
|
-
if (after.some((row) => row.user === 'root') || (!rootAvailable && after.length > 0)) {
|
|
301
|
-
Logger.info('frida-server started', {serial: ctx.serial, rootAvailable});
|
|
302
|
-
return true;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
throw new CrawlerError({
|
|
306
|
-
message: 'frida_unavailable: frida-server did not start on device',
|
|
307
|
-
code: Code.FridaUnavailable,
|
|
308
|
-
context: {serverPath, rootAvailable, current, after}
|
|
309
|
-
});
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/**
|
|
313
|
-
* 从 App 内部启动 Activity。
|
|
314
|
-
*
|
|
315
|
-
* shell 的 `am start` 只能打开 exported Activity;很多 App 内部页面
|
|
316
|
-
* 不能被外部直接启动。这里 attach 到 App 自己的进程,用当前 Application
|
|
317
|
-
* 发起 startActivity,等价于 App 内部跳转。
|
|
318
|
-
*/
|
|
319
|
-
export async function startActivityInsideApp(ctx, className, options = {}) {
|
|
320
|
-
const event = await runScript(ctx, `
|
|
321
|
-
Java.perform(function () {
|
|
322
|
-
function emit(payload) { console.log('ANDROID_TOOLKIT_SCRIPT_JSON ' + JSON.stringify(payload)); }
|
|
323
|
-
Java.scheduleOnMainThread(function () {
|
|
324
|
-
try {
|
|
325
|
-
var ActivityThread = Java.use('android.app.ActivityThread');
|
|
326
|
-
var Intent = Java.use('android.content.Intent');
|
|
327
|
-
var app = ActivityThread.currentApplication();
|
|
328
|
-
var intent = Intent.$new();
|
|
329
|
-
intent.setClassName(${JSON.stringify(options.packageName || ctx.packageName)}, ${JSON.stringify(className)});
|
|
330
|
-
intent.addFlags(0x10000000);
|
|
331
|
-
app.startActivity(intent);
|
|
332
|
-
emit({ ok: true, className: ${JSON.stringify(className)} });
|
|
333
|
-
} catch (error) {
|
|
334
|
-
emit({ ok: false, className: ${JSON.stringify(className)}, error: String(error), stack: String(error.stack || '') });
|
|
335
|
-
}
|
|
336
|
-
});
|
|
337
|
-
});
|
|
338
|
-
`, {
|
|
339
|
-
label: options.label || 'start-activity-inside-app',
|
|
340
|
-
packageName: options.packageName || ctx.packageName,
|
|
341
|
-
timeoutMs: options.timeoutMs || 12000,
|
|
342
|
-
fridaTimeoutSeconds: options.fridaTimeoutSeconds || 10
|
|
343
|
-
});
|
|
344
|
-
if (!event.ok) {
|
|
345
|
-
throw new CrawlerError({
|
|
346
|
-
message: `automation_failed: startActivityInsideApp failed ${event.error || className}`,
|
|
347
|
-
code: Code.AutomationFailed,
|
|
348
|
-
context: event
|
|
349
|
-
});
|
|
350
|
-
}
|
|
351
|
-
return event;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* 查找当前 App 内部可见 EditText,并直接写入文本。
|
|
356
|
-
*
|
|
357
|
-
* 这是 Android 版的“定位输入框并 fill”基础能力:
|
|
358
|
-
* - 不走 OCR;
|
|
359
|
-
* - 不依赖屏幕比例坐标;
|
|
360
|
-
* - 业务方只传通用筛选条件,例如 hintIncludes;
|
|
361
|
-
* - toolkit 在 App 进程内遍历 Activity DecorView,选中输入框后 requestFocus/performClick,
|
|
362
|
-
* 再写入 Editable,保证中文不会被 adb input text 打成乱码。
|
|
363
|
-
*/
|
|
364
|
-
export async function setVisibleInputText(ctx, text, options = {}) {
|
|
365
|
-
const event = await runScript(ctx, `
|
|
366
|
-
Java.perform(function () {
|
|
367
|
-
function emit(payload) { console.log('ANDROID_TOOLKIT_SCRIPT_JSON ' + JSON.stringify(payload)); }
|
|
368
|
-
var JavaObject = Java.use('java.lang.Object');
|
|
369
|
-
function toJsString(value) {
|
|
370
|
-
if (value === null || value === undefined) return '';
|
|
371
|
-
try { return Java.cast(value, JavaObject).toString() + ''; } catch (_) { }
|
|
372
|
-
try { return value.toString.overload().call(value) + ''; } catch (_) { }
|
|
373
|
-
try { return value.toString() + ''; } catch (_) { }
|
|
374
|
-
return '';
|
|
375
|
-
}
|
|
376
|
-
function rectOf(view) {
|
|
377
|
-
var Rect = Java.use('android.graphics.Rect');
|
|
378
|
-
var rect = Rect.$new();
|
|
379
|
-
try { view.getGlobalVisibleRect(rect); } catch (_) { }
|
|
380
|
-
return { left: rect.left.value, top: rect.top.value, right: rect.right.value, bottom: rect.bottom.value };
|
|
381
|
-
}
|
|
382
|
-
function walk(view, out, depth) {
|
|
383
|
-
if (!view || depth > ${Number(options.maxDepth || 20)} || out.length > ${Number(options.maxNodes || 3000)}) return;
|
|
384
|
-
out.push(view);
|
|
385
|
-
try {
|
|
386
|
-
var ViewGroup = Java.use('android.view.ViewGroup');
|
|
387
|
-
var group = Java.cast(view, ViewGroup);
|
|
388
|
-
for (var i = 0; i < group.getChildCount(); i++) walk(group.getChildAt(i), out, depth + 1);
|
|
389
|
-
} catch (_) { }
|
|
390
|
-
}
|
|
391
|
-
function activeActivities() {
|
|
392
|
-
var ActivityThread = Java.use('android.app.ActivityThread');
|
|
393
|
-
var Activity = Java.use('android.app.Activity');
|
|
394
|
-
var ArrayMap = Java.use('android.util.ArrayMap');
|
|
395
|
-
var thread = ActivityThread.currentActivityThread();
|
|
396
|
-
var field = thread.getClass().getDeclaredField('mActivities');
|
|
397
|
-
field.setAccessible(true);
|
|
398
|
-
var map = Java.cast(field.get(thread), ArrayMap);
|
|
399
|
-
var out = [];
|
|
400
|
-
for (var i = 0; i < map.size(); i++) {
|
|
401
|
-
var record = map.valueAt(i);
|
|
402
|
-
var recordClass = record.getClass();
|
|
403
|
-
var activityField = recordClass.getDeclaredField('activity');
|
|
404
|
-
var pausedField = recordClass.getDeclaredField('paused');
|
|
405
|
-
activityField.setAccessible(true);
|
|
406
|
-
pausedField.setAccessible(true);
|
|
407
|
-
if (!pausedField.getBoolean(record)) out.push(Java.cast(activityField.get(record), Activity));
|
|
408
|
-
}
|
|
409
|
-
return out;
|
|
410
|
-
}
|
|
411
|
-
Java.scheduleOnMainThread(function () {
|
|
412
|
-
try {
|
|
413
|
-
var EditText = Java.use('android.widget.EditText');
|
|
414
|
-
var InputMethodManager = Java.use('android.view.inputmethod.InputMethodManager');
|
|
415
|
-
var targetText = ${JSON.stringify(String(text || ''))};
|
|
416
|
-
var hintIncludes = ${JSON.stringify(String(options.hintIncludes || ''))};
|
|
417
|
-
var activities = activeActivities();
|
|
418
|
-
var chosen = null;
|
|
419
|
-
var focused = null;
|
|
420
|
-
var firstVisible = null;
|
|
421
|
-
for (var a = 0; a < activities.length; a++) {
|
|
422
|
-
var views = [];
|
|
423
|
-
walk(activities[a].getWindow().getDecorView(), views, 0);
|
|
424
|
-
for (var i = 0; i < views.length; i++) {
|
|
425
|
-
var view = views[i];
|
|
426
|
-
if (!view.isShown()) continue;
|
|
427
|
-
try {
|
|
428
|
-
var editText = Java.cast(view, EditText);
|
|
429
|
-
var rect = rectOf(view);
|
|
430
|
-
if (rect.right <= rect.left || rect.bottom <= rect.top) continue;
|
|
431
|
-
var item = { view: editText, bounds: rect, text: toJsString(editText.getText()), hint: toJsString(editText.getHint()), className: toJsString(view.getClass().getName()), focused: Boolean(view.hasFocus()) };
|
|
432
|
-
if (hintIncludes && item.hint.indexOf(hintIncludes) >= 0) {
|
|
433
|
-
chosen = item;
|
|
434
|
-
break;
|
|
435
|
-
}
|
|
436
|
-
if (item.focused && !focused) focused = item;
|
|
437
|
-
if (!firstVisible) firstVisible = item;
|
|
438
|
-
} catch (_) { }
|
|
439
|
-
}
|
|
440
|
-
if (chosen) break;
|
|
441
|
-
}
|
|
442
|
-
chosen = chosen || (!hintIncludes ? (focused || firstVisible) : null);
|
|
443
|
-
if (!chosen) {
|
|
444
|
-
emit({ ok: false, error: 'visible EditText not found' });
|
|
445
|
-
return;
|
|
446
|
-
}
|
|
447
|
-
var JavaString = Java.use('java.lang.String');
|
|
448
|
-
var BufferType = Java.use('android.widget.TextView$BufferType');
|
|
449
|
-
var SpannableStringBuilder = Java.use('android.text.SpannableStringBuilder');
|
|
450
|
-
chosen.view.requestFocus();
|
|
451
|
-
try { chosen.view.performClick(); } catch (_) { }
|
|
452
|
-
try {
|
|
453
|
-
var imm = InputMethodManager.getInstance.overload().call(InputMethodManager);
|
|
454
|
-
imm.showSoftInput(chosen.view, 0);
|
|
455
|
-
} catch (_) { }
|
|
456
|
-
chosen.view.setText(SpannableStringBuilder.$new(JavaString.$new(targetText)), BufferType.EDITABLE.value);
|
|
457
|
-
chosen.view.setSelection(targetText.length);
|
|
458
|
-
var actualText = toJsString(chosen.view.getText());
|
|
459
|
-
emit({
|
|
460
|
-
ok: true,
|
|
461
|
-
text: actualText,
|
|
462
|
-
hint: toJsString(chosen.view.getHint()),
|
|
463
|
-
className: chosen.className,
|
|
464
|
-
bounds: chosen.bounds
|
|
465
|
-
});
|
|
466
|
-
} catch (error) {
|
|
467
|
-
emit({ ok: false, error: String(error), stack: String(error.stack || '') });
|
|
468
|
-
}
|
|
469
|
-
});
|
|
470
|
-
});
|
|
471
|
-
`, {
|
|
472
|
-
label: options.label || 'set-visible-input-text',
|
|
473
|
-
packageName: options.packageName || ctx.packageName,
|
|
474
|
-
timeoutMs: options.timeoutMs || 7000
|
|
475
|
-
});
|
|
476
|
-
if (!event.ok) {
|
|
477
|
-
throw new CrawlerError({
|
|
478
|
-
message: `automation_failed: setVisibleInputText failed ${event.error || ''}`.trim(),
|
|
479
|
-
code: Code.AutomationFailed,
|
|
480
|
-
context: event
|
|
481
|
-
});
|
|
482
|
-
}
|
|
483
|
-
return event;
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
/**
|
|
487
|
-
* 触发当前输入框的 IME action。
|
|
488
|
-
*
|
|
489
|
-
* 这对应软键盘右下角的“搜索 / 完成 / 发送”等动作,
|
|
490
|
-
* 不是硬件 ENTER。很多 App 会区分这两种输入:
|
|
491
|
-
* `adb shell input keyevent ENTER` 只发送按键事件;
|
|
492
|
-
* 软键盘按钮会调用当前 InputConnection.performEditorAction(actionCode)。
|
|
493
|
-
*/
|
|
494
|
-
export async function performEditorAction(ctx, action = 'search', options = {}) {
|
|
495
|
-
const actionCode = editorActionCode(action);
|
|
496
|
-
const event = await runScript(ctx, `
|
|
497
|
-
Java.perform(function () {
|
|
498
|
-
function emit(payload) { console.log('ANDROID_TOOLKIT_SCRIPT_JSON ' + JSON.stringify(payload)); }
|
|
499
|
-
Java.scheduleOnMainThread(function () {
|
|
500
|
-
try {
|
|
501
|
-
var InputMethodManager = Java.use('android.view.inputmethod.InputMethodManager');
|
|
502
|
-
var IInputConnectionWrapper = Java.use('com.android.internal.view.IInputConnectionWrapper');
|
|
503
|
-
var imm = InputMethodManager.getInstance.overload().call(InputMethodManager);
|
|
504
|
-
var field = imm.getClass().getDeclaredField('mServedInputConnectionWrapper');
|
|
505
|
-
field.setAccessible(true);
|
|
506
|
-
var rawConnection = field.get(imm);
|
|
507
|
-
if (!rawConnection) {
|
|
508
|
-
emit({ ok: false, error: 'mServedInputConnectionWrapper is empty', actionCode: ${actionCode} });
|
|
509
|
-
return;
|
|
510
|
-
}
|
|
511
|
-
var connection = Java.cast(rawConnection, IInputConnectionWrapper);
|
|
512
|
-
connection.performEditorAction.overload('int').call(connection, ${actionCode});
|
|
513
|
-
emit({
|
|
514
|
-
ok: true,
|
|
515
|
-
actionCode: ${actionCode},
|
|
516
|
-
className: String(connection.getClass().getName())
|
|
517
|
-
});
|
|
518
|
-
} catch (error) {
|
|
519
|
-
emit({ ok: false, actionCode: ${actionCode}, error: String(error), stack: String(error.stack || '') });
|
|
520
|
-
}
|
|
521
|
-
});
|
|
522
|
-
});
|
|
523
|
-
`, {
|
|
524
|
-
label: options.label || 'perform-editor-action',
|
|
525
|
-
packageName: options.packageName || ctx.packageName,
|
|
526
|
-
timeoutMs: options.timeoutMs || 7000
|
|
527
|
-
});
|
|
528
|
-
if (!event.ok) {
|
|
529
|
-
throw new CrawlerError({
|
|
530
|
-
message: `automation_failed: performEditorAction failed ${event.error || ''}`.trim(),
|
|
531
|
-
code: Code.AutomationFailed,
|
|
532
|
-
context: event
|
|
533
|
-
});
|
|
534
|
-
}
|
|
535
|
-
return event;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
/**
|
|
539
|
-
* 在当前可见 WebView 中执行一段 JavaScript,并返回 evaluateJavascript 的回调值。
|
|
540
|
-
*
|
|
541
|
-
* 这是 Android 版的 `page.evaluate()` 基础能力:
|
|
542
|
-
* - toolkit 只负责找到当前 Activity 里的可见 WebView;
|
|
543
|
-
* - 业务方传入要执行的 JS 字符串;
|
|
544
|
-
* - JS 返回值保持 Android WebView 的原始 callback 形态,业务方自行解析。
|
|
545
|
-
*
|
|
546
|
-
* 注意:这里不关心具体页面选择器,也不内置任何微信/抖音等业务逻辑。
|
|
547
|
-
*/
|
|
548
|
-
export async function evaluateVisibleWebView(ctx, script, options = {}) {
|
|
549
|
-
const event = await evaluateVisibleWebViews(ctx, script, {
|
|
550
|
-
...options,
|
|
551
|
-
maxCandidates: 1
|
|
552
|
-
});
|
|
553
|
-
const first = event.candidates?.[0];
|
|
554
|
-
if (!first) {
|
|
555
|
-
throw new CrawlerError({
|
|
556
|
-
message: 'automation_failed: evaluateVisibleWebView failed visible WebView not found',
|
|
557
|
-
code: Code.AutomationFailed,
|
|
558
|
-
context: event
|
|
559
|
-
});
|
|
560
|
-
}
|
|
561
|
-
return {
|
|
562
|
-
ok: true,
|
|
563
|
-
value: first.value,
|
|
564
|
-
className: first.className,
|
|
565
|
-
castClassName: first.castClassName,
|
|
566
|
-
url: first.url,
|
|
567
|
-
index: first.index
|
|
568
|
-
};
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
/**
|
|
572
|
-
* 在所有匹配的可见 WebView 中执行同一段 JavaScript。
|
|
573
|
-
*
|
|
574
|
-
* 这是 `evaluateVisibleWebView` 的批量版本,用来处理一个 Activity 内存在多个
|
|
575
|
-
* WebView 的场景。toolkit 只返回每个候选的原始 evaluateJavascript callback;
|
|
576
|
-
* 业务方根据自己的页面结构判断哪个候选才是目标页面。
|
|
577
|
-
*/
|
|
578
|
-
export async function evaluateVisibleWebViews(ctx, script, options = {}) {
|
|
579
|
-
const webViewClasses = normalizeWebViewClasses(options.webViewClasses);
|
|
580
|
-
const urlIncludes = String(options.urlIncludes || '').trim();
|
|
581
|
-
const maxCandidates = Math.max(1, Number(options.maxCandidates || 12));
|
|
582
|
-
|
|
583
|
-
const event = await runScript(ctx, `
|
|
584
|
-
Java.perform(function () {
|
|
585
|
-
function emit(payload) { console.log('ANDROID_TOOLKIT_SCRIPT_JSON ' + JSON.stringify(payload)); }
|
|
586
|
-
var JavaObject = Java.use('java.lang.Object');
|
|
587
|
-
function toJsString(value) {
|
|
588
|
-
if (value === null || value === undefined) return '';
|
|
589
|
-
try { return Java.cast(value, JavaObject).toString() + ''; } catch (_) { }
|
|
590
|
-
try { return value.toString.overload().call(value) + ''; } catch (_) { }
|
|
591
|
-
try { return value.toString() + ''; } catch (_) { }
|
|
592
|
-
return '';
|
|
593
|
-
}
|
|
594
|
-
function walk(view, out, depth) {
|
|
595
|
-
if (!view || depth > ${Number(options.maxDepth || 25)} || out.length > ${Number(options.maxNodes || 5000)}) return;
|
|
596
|
-
out.push(view);
|
|
597
|
-
try {
|
|
598
|
-
var ViewGroup = Java.use('android.view.ViewGroup');
|
|
599
|
-
var group = Java.cast(view, ViewGroup);
|
|
600
|
-
for (var i = 0; i < group.getChildCount(); i++) walk(group.getChildAt(i), out, depth + 1);
|
|
601
|
-
} catch (_) { }
|
|
602
|
-
}
|
|
603
|
-
function activeActivities() {
|
|
604
|
-
var ActivityThread = Java.use('android.app.ActivityThread');
|
|
605
|
-
var Activity = Java.use('android.app.Activity');
|
|
606
|
-
var ArrayMap = Java.use('android.util.ArrayMap');
|
|
607
|
-
var thread = ActivityThread.currentActivityThread();
|
|
608
|
-
var field = thread.getClass().getDeclaredField('mActivities');
|
|
609
|
-
field.setAccessible(true);
|
|
610
|
-
var map = Java.cast(field.get(thread), ArrayMap);
|
|
611
|
-
var out = [];
|
|
612
|
-
for (var i = 0; i < map.size(); i++) {
|
|
613
|
-
var record = map.valueAt(i);
|
|
614
|
-
var recordClass = record.getClass();
|
|
615
|
-
var activityField = recordClass.getDeclaredField('activity');
|
|
616
|
-
var pausedField = recordClass.getDeclaredField('paused');
|
|
617
|
-
activityField.setAccessible(true);
|
|
618
|
-
pausedField.setAccessible(true);
|
|
619
|
-
if (!pausedField.getBoolean(record)) out.push(Java.cast(activityField.get(record), Activity));
|
|
620
|
-
}
|
|
621
|
-
return out;
|
|
622
|
-
}
|
|
623
|
-
function useClass(name, loader) {
|
|
624
|
-
try { return Java.use(name); } catch (_) { }
|
|
625
|
-
try { return Java.ClassFactory.get(loader).use(name); } catch (_) { }
|
|
626
|
-
return null;
|
|
627
|
-
}
|
|
628
|
-
function castWebView(view, actualClassName) {
|
|
629
|
-
var configuredNames = ${JSON.stringify(webViewClasses)};
|
|
630
|
-
var names = [actualClassName];
|
|
631
|
-
for (var n = 0; n < configuredNames.length; n++) {
|
|
632
|
-
if (names.indexOf(configuredNames[n]) < 0) names.push(configuredNames[n]);
|
|
633
|
-
}
|
|
634
|
-
var loader = null;
|
|
635
|
-
try { loader = view.getClass().getClassLoader(); } catch (_) { }
|
|
636
|
-
for (var i = 0; i < names.length; i++) {
|
|
637
|
-
try {
|
|
638
|
-
var Klass = useClass(names[i], loader);
|
|
639
|
-
if (!Klass) continue;
|
|
640
|
-
var casted = Java.cast(view, Klass);
|
|
641
|
-
if (!casted.evaluateJavascript) continue;
|
|
642
|
-
return { view: casted, className: names[i] };
|
|
643
|
-
} catch (_) { }
|
|
644
|
-
}
|
|
645
|
-
return null;
|
|
646
|
-
}
|
|
647
|
-
var Callback = Java.registerClass({
|
|
648
|
-
name: 'com.android.toolkit.JsValueCallback' + Date.now(),
|
|
649
|
-
implements: [Java.use('android.webkit.ValueCallback')],
|
|
650
|
-
methods: {
|
|
651
|
-
onReceiveValue: function (value) {
|
|
652
|
-
var item = pending[currentIndex] || {};
|
|
653
|
-
results.push({
|
|
654
|
-
index: item.index,
|
|
655
|
-
className: item.className,
|
|
656
|
-
castClassName: item.castClassName,
|
|
657
|
-
shown: item.shown,
|
|
658
|
-
url: item.url,
|
|
659
|
-
value: toJsString(value)
|
|
660
|
-
});
|
|
661
|
-
currentIndex += 1;
|
|
662
|
-
evaluateNext();
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
});
|
|
666
|
-
var pending = [];
|
|
667
|
-
var results = [];
|
|
668
|
-
var currentIndex = 0;
|
|
669
|
-
function evaluateNext() {
|
|
670
|
-
if (currentIndex >= pending.length) {
|
|
671
|
-
emit({ ok: true, candidates: results });
|
|
672
|
-
return;
|
|
673
|
-
}
|
|
674
|
-
try {
|
|
675
|
-
pending[currentIndex].view.evaluateJavascript(${JSON.stringify(String(script || ''))}, Callback.$new());
|
|
676
|
-
} catch (error) {
|
|
677
|
-
var item = pending[currentIndex] || {};
|
|
678
|
-
results.push({
|
|
679
|
-
index: item.index,
|
|
680
|
-
className: item.className,
|
|
681
|
-
castClassName: item.castClassName,
|
|
682
|
-
shown: item.shown,
|
|
683
|
-
url: item.url,
|
|
684
|
-
error: String(error)
|
|
685
|
-
});
|
|
686
|
-
currentIndex += 1;
|
|
687
|
-
evaluateNext();
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
Java.scheduleOnMainThread(function () {
|
|
691
|
-
try {
|
|
692
|
-
var activities = activeActivities();
|
|
693
|
-
var candidates = [];
|
|
694
|
-
var expectedUrl = ${JSON.stringify(urlIncludes)};
|
|
695
|
-
var maxCandidates = ${maxCandidates};
|
|
696
|
-
for (var a = 0; a < activities.length; a++) {
|
|
697
|
-
var views = [];
|
|
698
|
-
walk(activities[a].getWindow().getDecorView(), views, 0);
|
|
699
|
-
for (var i = 0; i < views.length; i++) {
|
|
700
|
-
var className = toJsString(views[i].getClass().getName());
|
|
701
|
-
var normalizedClassName = className.toLowerCase();
|
|
702
|
-
if (normalizedClassName.indexOf('webview') < 0) continue;
|
|
703
|
-
var casted = castWebView(views[i], className);
|
|
704
|
-
if (!casted) continue;
|
|
705
|
-
var shown = false;
|
|
706
|
-
try { shown = Boolean(views[i].isShown()); } catch (_) { }
|
|
707
|
-
var url = '';
|
|
708
|
-
try { url = toJsString(casted.view.getUrl()); } catch (_) { }
|
|
709
|
-
var meta = { index: candidates.length, className: className, castClassName: casted.className, shown: shown, url: url };
|
|
710
|
-
candidates.push(meta);
|
|
711
|
-
if (shown && (!expectedUrl || url.indexOf(expectedUrl) >= 0)) {
|
|
712
|
-
pending.push({
|
|
713
|
-
index: meta.index,
|
|
714
|
-
className: meta.className,
|
|
715
|
-
castClassName: meta.castClassName,
|
|
716
|
-
shown: meta.shown,
|
|
717
|
-
url: meta.url,
|
|
718
|
-
view: casted.view
|
|
719
|
-
});
|
|
720
|
-
if (pending.length >= maxCandidates) break;
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
if (pending.length >= maxCandidates) break;
|
|
724
|
-
}
|
|
725
|
-
if (pending.length === 0) {
|
|
726
|
-
emit({ ok: false, error: 'visible WebView not found', candidates: candidates.slice(0, 20) });
|
|
727
|
-
return;
|
|
728
|
-
}
|
|
729
|
-
evaluateNext();
|
|
730
|
-
} catch (error) {
|
|
731
|
-
emit({ ok: false, error: String(error), stack: String(error.stack || '') });
|
|
732
|
-
}
|
|
733
|
-
});
|
|
734
|
-
});
|
|
735
|
-
`, {
|
|
736
|
-
label: options.label || 'evaluate-visible-webview',
|
|
737
|
-
packageName: options.packageName || ctx.packageName,
|
|
738
|
-
timeoutMs: options.timeoutMs || 12000,
|
|
739
|
-
fridaTimeoutSeconds: options.fridaTimeoutSeconds || 8,
|
|
740
|
-
maxLines: options.maxLines || 80
|
|
741
|
-
});
|
|
742
|
-
if (!event.ok) {
|
|
743
|
-
throw new CrawlerError({
|
|
744
|
-
message: `automation_failed: evaluateVisibleWebViews failed ${event.error || ''}`.trim(),
|
|
745
|
-
code: Code.AutomationFailed,
|
|
746
|
-
context: event
|
|
747
|
-
});
|
|
748
|
-
}
|
|
749
|
-
return event;
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
function normalizeWebViewClasses(value) {
|
|
753
|
-
if (Array.isArray(value) && value.length > 0) {
|
|
754
|
-
const clean = value.map((item) => String(item || '').trim()).filter(Boolean);
|
|
755
|
-
if (clean.length > 0) return clean;
|
|
756
|
-
}
|
|
757
|
-
return [
|
|
758
|
-
'android.webkit.WebView',
|
|
759
|
-
'com.tencent.xweb.WebView',
|
|
760
|
-
'com.tencent.xweb.pinus.sdk.WebView',
|
|
761
|
-
'com.tencent.xweb.pinus.PSWebview',
|
|
762
|
-
'com.tencent.mm.ui.widget.MMWebView'
|
|
763
|
-
];
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
function normalizeStringArray(value) {
|
|
767
|
-
if (!Array.isArray(value)) return [];
|
|
768
|
-
return value.map((item) => String(item ?? '')).filter((item) => item);
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
/**
|
|
772
|
-
* 找到目标包名当前可 attach 的 pid。
|
|
773
|
-
*
|
|
774
|
-
* 微信会出现历史 zombie pid;这里优先使用 pidof 返回的最后一个非 Z 状态 pid,
|
|
775
|
-
* 再从 `ps -A` 兜底扫描,避免 Frida attach 到已经退出的进程。
|
|
776
|
-
*/
|
|
777
|
-
export async function resolvePid(ctx, packageName = ctx.packageName) {
|
|
778
|
-
Logger.debug('resolve pid start', {serial: ctx.serial, packageName});
|
|
779
|
-
const pidof = await adbShell(ctx, ['pidof', packageName]).catch(() => '');
|
|
780
|
-
const pidCandidates = String(pidof || '').trim().split(/\s+/).filter(Boolean);
|
|
781
|
-
const ps = await adbShell(ctx, ['ps', '-A', '-o', 'USER,PID,PPID,STAT,NAME']).catch(() => '');
|
|
782
|
-
const psRows = parsePsRows(ps);
|
|
783
|
-
|
|
784
|
-
for (let i = pidCandidates.length - 1; i >= 0; i -= 1) {
|
|
785
|
-
const pid = pidCandidates[i];
|
|
786
|
-
const row = psRows.find((item) => item.pid === pid);
|
|
787
|
-
if (row && row.name === packageName && !row.stat.includes('Z')) {
|
|
788
|
-
Logger.debug('resolve pid done', {serial: ctx.serial, packageName, pid});
|
|
789
|
-
return pid;
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
for (const row of psRows) {
|
|
793
|
-
if (row.name === packageName && !row.stat.includes('Z')) {
|
|
794
|
-
Logger.debug('resolve pid done', {serial: ctx.serial, packageName, pid: row.pid});
|
|
795
|
-
return row.pid;
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
const fallbackPs = ps || await adbShell(ctx, ['ps', '-A']);
|
|
800
|
-
for (const line of String(fallbackPs || '').split('\n')) {
|
|
801
|
-
const parts = line.trim().split(/\s+/);
|
|
802
|
-
if (parts.length >= 2 && parts.at(-1) === packageName && !parts.some((part) => /^Z/.test(part))) {
|
|
803
|
-
Logger.debug('resolve pid done', {serial: ctx.serial, packageName, pid: parts[1]});
|
|
804
|
-
return parts[1];
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
throw new CrawlerError({
|
|
808
|
-
message: `frida_unavailable: process not found: ${packageName}`,
|
|
809
|
-
code: Code.FridaUnavailable,
|
|
810
|
-
context: {packageName}
|
|
811
|
-
});
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
async function listFridaServerRows(ctx) {
|
|
815
|
-
const ps = await adbShell(ctx, ['ps', '-A', '-o', 'USER,PID,PPID,STAT,NAME']).catch(() => '');
|
|
816
|
-
return parsePsRows(ps).filter((row) => row.name === 'frida-server');
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
async function canUseSu(ctx) {
|
|
820
|
-
const out = await adbShell(ctx, ['su', '-c', 'id'], {timeoutMs: 5000}).catch(() => '');
|
|
821
|
-
return String(out || '').includes('uid=0');
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
function startFridaLineRecorder(command, args, options = {}) {
|
|
825
|
-
const marker = options.marker || 'ANDROID_WEBVIEW_EVENT_JSON ';
|
|
826
|
-
const label = options.label || 'frida-recorder';
|
|
827
|
-
const maxLines = Math.max(50, Number(options.maxLines || 6000));
|
|
828
|
-
const lines = [];
|
|
829
|
-
const events = [];
|
|
830
|
-
let rawBuffer = '';
|
|
831
|
-
let stopped = false;
|
|
832
|
-
let exited = false;
|
|
833
|
-
let stdoutClosed = false;
|
|
834
|
-
let stderrClosed = false;
|
|
835
|
-
let exitCode = null;
|
|
836
|
-
let exitSignal = null;
|
|
837
|
-
let fatalError = null;
|
|
838
|
-
|
|
839
|
-
const child = spawn(command, args, {stdio: ['ignore', 'pipe', 'pipe']});
|
|
840
|
-
|
|
841
|
-
const append = (chunk) => {
|
|
842
|
-
rawBuffer += String(chunk || '');
|
|
843
|
-
const parts = rawBuffer.split(/\r?\n/);
|
|
844
|
-
rawBuffer = parts.pop() || '';
|
|
845
|
-
for (const line of parts) {
|
|
846
|
-
lines.push(line);
|
|
847
|
-
if (lines.length > maxLines) lines.shift();
|
|
848
|
-
const payload = extractMarkerLinePayload(line, marker);
|
|
849
|
-
if (!payload) continue;
|
|
850
|
-
try {
|
|
851
|
-
events.push(JSON.parse(payload));
|
|
852
|
-
} catch (error) {
|
|
853
|
-
events.push({type: 'parse_error', raw: payload, error: String(error)});
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
};
|
|
857
|
-
|
|
858
|
-
child.stdout.on('data', append);
|
|
859
|
-
child.stderr.on('data', append);
|
|
860
|
-
child.stdout.on('close', () => {
|
|
861
|
-
stdoutClosed = true;
|
|
862
|
-
});
|
|
863
|
-
child.stderr.on('close', () => {
|
|
864
|
-
stderrClosed = true;
|
|
865
|
-
});
|
|
866
|
-
child.on('error', (error) => {
|
|
867
|
-
lines.push(`${label} error: ${error.message || String(error)}`);
|
|
868
|
-
fatalError = makeFridaUnavailableError(label, error.message || String(error), lines);
|
|
869
|
-
});
|
|
870
|
-
child.on('exit', (code, signal) => {
|
|
871
|
-
exited = true;
|
|
872
|
-
exitCode = code;
|
|
873
|
-
exitSignal = signal;
|
|
874
|
-
});
|
|
875
|
-
|
|
876
|
-
const snapshot = () => ({
|
|
877
|
-
pid: child.pid,
|
|
878
|
-
marker,
|
|
879
|
-
events: [...events],
|
|
880
|
-
lines: [...lines],
|
|
881
|
-
exitCode,
|
|
882
|
-
exitSignal,
|
|
883
|
-
error: fatalError ? {message: fatalError.message, code: fatalError.code} : null
|
|
884
|
-
});
|
|
885
|
-
|
|
886
|
-
const waitForStartup = async (timeoutMs) => {
|
|
887
|
-
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
888
|
-
while (Date.now() <= deadline) {
|
|
889
|
-
if (fatalError) throw fatalError;
|
|
890
|
-
if (events.length > 0) return snapshot();
|
|
891
|
-
if (exited) {
|
|
892
|
-
throw makeFridaUnavailableError(label, `Frida exited before startup code=${exitCode} signal=${exitSignal || ''}`.trim(), lines);
|
|
893
|
-
}
|
|
894
|
-
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
895
|
-
}
|
|
896
|
-
return snapshot();
|
|
897
|
-
};
|
|
898
|
-
|
|
899
|
-
const waitForEvent = async (predicate, timeoutMs) => {
|
|
900
|
-
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
901
|
-
while (Date.now() <= deadline) {
|
|
902
|
-
if (fatalError) throw fatalError;
|
|
903
|
-
const matched = events.find((event) => predicate(event));
|
|
904
|
-
if (matched) return matched;
|
|
905
|
-
if (exited) {
|
|
906
|
-
throw makeFridaUnavailableError(label, `Frida exited while waiting event code=${exitCode} signal=${exitSignal || ''}`.trim(), lines);
|
|
907
|
-
}
|
|
908
|
-
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
909
|
-
}
|
|
910
|
-
throw makeFridaUnavailableError(label, `Frida event wait timeout: ${options.readyEventLabel || 'event'}`, lines);
|
|
911
|
-
};
|
|
912
|
-
|
|
913
|
-
const waitForExit = async (timeoutMs) => {
|
|
914
|
-
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
915
|
-
while (Date.now() <= deadline) {
|
|
916
|
-
if (fatalError) throw fatalError;
|
|
917
|
-
if (exited) return snapshot();
|
|
918
|
-
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
919
|
-
}
|
|
920
|
-
return snapshot();
|
|
921
|
-
};
|
|
922
|
-
|
|
923
|
-
const stop = async () => {
|
|
924
|
-
if (stopped) return snapshot();
|
|
925
|
-
stopped = true;
|
|
926
|
-
if (rawBuffer) append('\n');
|
|
927
|
-
if (!child.killed) child.kill('SIGTERM');
|
|
928
|
-
await new Promise((resolve) => {
|
|
929
|
-
const timer = setTimeout(resolve, 1500);
|
|
930
|
-
child.once('exit', () => {
|
|
931
|
-
clearTimeout(timer);
|
|
932
|
-
resolve();
|
|
933
|
-
});
|
|
934
|
-
});
|
|
935
|
-
if (!exited) child.kill('SIGKILL');
|
|
936
|
-
if (!exited) {
|
|
937
|
-
await new Promise((resolve) => {
|
|
938
|
-
const timer = setTimeout(resolve, 1000);
|
|
939
|
-
child.once('exit', () => {
|
|
940
|
-
clearTimeout(timer);
|
|
941
|
-
resolve();
|
|
942
|
-
});
|
|
943
|
-
});
|
|
944
|
-
}
|
|
945
|
-
await waitForOutputFlush();
|
|
946
|
-
if (rawBuffer) append('\n');
|
|
947
|
-
const result = snapshot();
|
|
948
|
-
if (options.outputPath) {
|
|
949
|
-
fs.writeFileSync(options.outputPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
|
|
950
|
-
}
|
|
951
|
-
Logger.info(`frida ${label} stopped events=${events.length}`);
|
|
952
|
-
return result;
|
|
953
|
-
};
|
|
954
|
-
|
|
955
|
-
return {
|
|
956
|
-
pid: child.pid,
|
|
957
|
-
waitForEvent,
|
|
958
|
-
waitForStartup,
|
|
959
|
-
waitForExit,
|
|
960
|
-
stop,
|
|
961
|
-
snapshot
|
|
962
|
-
};
|
|
963
|
-
|
|
964
|
-
async function waitForOutputFlush(timeoutMs = 500) {
|
|
965
|
-
const deadline = Date.now() + timeoutMs;
|
|
966
|
-
while (Date.now() < deadline) {
|
|
967
|
-
if (stdoutClosed && stderrClosed) return;
|
|
968
|
-
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
function makeFridaUnavailableError(label, message, lines) {
|
|
974
|
-
return new CrawlerError({
|
|
975
|
-
message: `frida_unavailable: ${message}`,
|
|
976
|
-
code: Code.FridaUnavailable,
|
|
977
|
-
context: {
|
|
978
|
-
label,
|
|
979
|
-
recentLines: [...lines].slice(-20)
|
|
980
|
-
}
|
|
981
|
-
});
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
function writeConfiguredWebViewEventAgent(ctx, config) {
|
|
985
|
-
const base = fs.readFileSync(ctx.fridaWebViewEventAgentScript, 'utf8');
|
|
986
|
-
const out = `/tmp/android-toolkit-webview-event-${safeFilePart(ctx.runId || 'manual')}-${Date.now()}.js`;
|
|
987
|
-
const source = [
|
|
988
|
-
`globalThis.__ANDROID_TOOLKIT_WEBVIEW_EVENT_CONFIG__ = ${JSON.stringify(config || {})};`,
|
|
989
|
-
base
|
|
990
|
-
].join('\n');
|
|
991
|
-
fs.writeFileSync(out, source, 'utf8');
|
|
992
|
-
return out;
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
function writeTempScript(ctx, source, label) {
|
|
996
|
-
const out = `/tmp/android-toolkit-${safeFilePart(label || 'script')}-${safeFilePart(ctx.runId || 'manual')}-${Date.now()}.js`;
|
|
997
|
-
fs.writeFileSync(out, String(source || ''), 'utf8');
|
|
998
|
-
return out;
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
function extractMarkerLinePayload(raw, marker) {
|
|
1002
|
-
const index = String(raw || '').indexOf(marker);
|
|
1003
|
-
if (index < 0) return '';
|
|
1004
|
-
const start = index + marker.length;
|
|
1005
|
-
const newline = String(raw || '').indexOf('\n', start);
|
|
1006
|
-
const end = newline < 0 ? String(raw || '').length : newline;
|
|
1007
|
-
return String(raw || '').slice(start, end).trim();
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
function parsePsRows(rawPs) {
|
|
1011
|
-
const rows = [];
|
|
1012
|
-
for (const line of String(rawPs || '').split('\n')) {
|
|
1013
|
-
const parts = line.trim().split(/\s+/);
|
|
1014
|
-
if (parts.length < 5 || parts[0] === 'USER') continue;
|
|
1015
|
-
const [user, pid, ppid, stat, ...nameParts] = parts;
|
|
1016
|
-
rows.push({user, pid, ppid, stat, name: nameParts.join(' ')});
|
|
1017
|
-
}
|
|
1018
|
-
return rows;
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
function assertFridaReady(ctx, scriptPath, scriptLabel) {
|
|
1022
|
-
if (!ctx.serial) throw new Error('device.serial is empty');
|
|
1023
|
-
assertCommandOrFile(ctx.fridaPath, 'frida cli');
|
|
1024
|
-
if (!fs.existsSync(scriptPath)) throw new Error(`${scriptLabel} not found: ${scriptPath}`);
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1027
|
-
function assertCommandOrFile(value, label) {
|
|
1028
|
-
const command = String(value || '').trim();
|
|
1029
|
-
if (!command) throw new Error(`${label} is empty`);
|
|
1030
|
-
const looksLikePath = command.includes('/') || command.startsWith('.');
|
|
1031
|
-
if (looksLikePath && !fs.existsSync(command)) throw new Error(`${label} not found: ${command}`);
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
function editorActionCode(action) {
|
|
1035
|
-
if (typeof action === 'number' && Number.isFinite(action)) return Math.trunc(action);
|
|
1036
|
-
const clean = String(action || '').trim().toLowerCase();
|
|
1037
|
-
const codes = {
|
|
1038
|
-
none: 1,
|
|
1039
|
-
go: 2,
|
|
1040
|
-
search: 3,
|
|
1041
|
-
send: 4,
|
|
1042
|
-
next: 5,
|
|
1043
|
-
done: 6,
|
|
1044
|
-
previous: 7
|
|
1045
|
-
};
|
|
1046
|
-
return codes[clean] || codes.search;
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
function safeFilePart(value) {
|
|
1050
|
-
return String(value).replace(/[^a-zA-Z0-9_.-]+/g, '-');
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
function shellQuote(value) {
|
|
1054
|
-
return `'${String(value || '').replace(/'/g, `'\\''`)}'`;
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
export const Frida = {
|
|
1058
|
-
startWebViewEventRecorder,
|
|
1059
|
-
runScript,
|
|
1060
|
-
querySQLite,
|
|
1061
|
-
ensureFridaServer,
|
|
1062
|
-
startActivityInsideApp,
|
|
1063
|
-
setVisibleInputText,
|
|
1064
|
-
performEditorAction,
|
|
1065
|
-
evaluateVisibleWebView,
|
|
1066
|
-
evaluateVisibleWebViews,
|
|
1067
|
-
resolvePid
|
|
1068
|
-
};
|